paperlint 12.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CONTRIBUTING.md +2 -1
  2. package/dist/adapters/memory/index.d.ts +5 -0
  3. package/dist/adapters/memory/index.d.ts.map +1 -1
  4. package/dist/adapters/memory/index.js +16 -0
  5. package/dist/adapters/memory/index.js.map +1 -1
  6. package/dist/adapters/node/index.d.ts +1 -0
  7. package/dist/adapters/node/index.d.ts.map +1 -1
  8. package/dist/adapters/node/index.js +1 -0
  9. package/dist/adapters/node/index.js.map +1 -1
  10. package/dist/adapters/node/move-file.io.d.ts +4 -0
  11. package/dist/adapters/node/move-file.io.d.ts.map +1 -0
  12. package/dist/adapters/node/move-file.io.js +12 -0
  13. package/dist/adapters/node/move-file.io.js.map +1 -0
  14. package/dist/adapters/yaml/index.d.ts +3 -0
  15. package/dist/adapters/yaml/index.d.ts.map +1 -0
  16. package/dist/adapters/yaml/index.js +17 -0
  17. package/dist/adapters/yaml/index.js.map +1 -0
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +48 -7
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cycle-rules.d.ts +3 -3
  22. package/dist/cycle-rules.d.ts.map +1 -1
  23. package/dist/cycle-rules.js +1 -2
  24. package/dist/cycle-rules.js.map +1 -1
  25. package/dist/domain/cycle.d.ts +16 -2
  26. package/dist/domain/cycle.d.ts.map +1 -1
  27. package/dist/domain/cycle.js +21 -42
  28. package/dist/domain/cycle.js.map +1 -1
  29. package/dist/domain/paper-layout.d.ts.map +1 -1
  30. package/dist/domain/paper-layout.js +5 -2
  31. package/dist/domain/paper-layout.js.map +1 -1
  32. package/dist/domain/paths.d.ts +12 -0
  33. package/dist/domain/paths.d.ts.map +1 -1
  34. package/dist/domain/paths.js +22 -1
  35. package/dist/domain/paths.js.map +1 -1
  36. package/dist/domain/scaffold-layout.js +1 -1
  37. package/dist/domain/versions-migration.d.ts +51 -0
  38. package/dist/domain/versions-migration.d.ts.map +1 -0
  39. package/dist/domain/versions-migration.js +252 -0
  40. package/dist/domain/versions-migration.js.map +1 -0
  41. package/dist/domain/versions.d.ts +167 -0
  42. package/dist/domain/versions.d.ts.map +1 -0
  43. package/dist/domain/versions.js +457 -0
  44. package/dist/domain/versions.js.map +1 -0
  45. package/dist/eslint-rules/paper-stages.d.ts +7 -0
  46. package/dist/eslint-rules/paper-stages.d.ts.map +1 -1
  47. package/dist/eslint-rules/paper-stages.js +62 -7
  48. package/dist/eslint-rules/paper-stages.js.map +1 -1
  49. package/dist/eslint-rules/rule-context.d.ts +2 -0
  50. package/dist/eslint-rules/rule-context.d.ts.map +1 -1
  51. package/dist/ports/move-file.d.ts +7 -0
  52. package/dist/ports/move-file.d.ts.map +1 -0
  53. package/dist/ports/move-file.js +2 -0
  54. package/dist/ports/move-file.js.map +1 -0
  55. package/dist/ports/yaml.d.ts +5 -0
  56. package/dist/ports/yaml.d.ts.map +1 -0
  57. package/dist/ports/yaml.js +2 -0
  58. package/dist/ports/yaml.js.map +1 -0
  59. package/dist/submission.d.ts +2 -0
  60. package/dist/submission.d.ts.map +1 -1
  61. package/dist/submission.js +49 -9
  62. package/dist/submission.js.map +1 -1
  63. package/dist/version-rules.d.ts +16 -0
  64. package/dist/version-rules.d.ts.map +1 -0
  65. package/dist/version-rules.js +201 -0
  66. package/dist/version-rules.js.map +1 -0
  67. package/dist/versions-command.d.ts +37 -0
  68. package/dist/versions-command.d.ts.map +1 -0
  69. package/dist/versions-command.js +209 -0
  70. package/dist/versions-command.js.map +1 -0
  71. package/dist/versions.d.ts +76 -0
  72. package/dist/versions.d.ts.map +1 -0
  73. package/dist/versions.js +164 -0
  74. package/dist/versions.js.map +1 -0
  75. package/docs/configuration.md +76 -2
  76. package/docs/layout.md +5 -5
  77. package/docs/rules/cycle/camera-ready-source.md +62 -0
  78. package/docs/rules/cycle/draft-drift.md +47 -0
  79. package/docs/rules/cycle/port-done.md +2 -2
  80. package/docs/rules/cycle/record.md +2 -1
  81. package/docs/rules/cycle/submitted-frozen.md +65 -0
  82. package/docs/rules/paper/allowed-files-only.md +1 -1
  83. package/docs/rules/versions/layout.md +88 -0
  84. package/docs/rules.md +25 -11
  85. package/docs/submission.md +21 -3
  86. package/eslint-rules/paper-stages.test.mjs +62 -0
  87. package/eslint-rules/paper-stages.ts +67 -8
  88. package/eslint-rules/prereg-coverage.test.ts +1 -0
  89. package/eslint-rules/rule-context.ts +2 -0
  90. package/package.json +2 -1
  91. package/presets/agent.jsonc +75 -0
  92. package/presets/agent.md +70 -0
  93. package/presets/agentverify.jsonc +72 -0
  94. package/presets/agentverify.md +85 -0
  95. package/presets/ieee-conference.md +1 -1
  96. package/presets/nlbse.jsonc +76 -0
  97. package/presets/nlbse.md +85 -0
  98. package/scripts/coverage-config.test.ts +3 -0
  99. package/skills/CLAUDE.md +2 -2
  100. package/skills/CLAUDE.md.spec.ts +1 -1
  101. package/skills/paper-pipeline/references/pipeline-status-template.md +4 -1
  102. package/skills/submit-paper/SKILL.md +18 -10
  103. package/skills/submit-paper/SKILL.md.spec.ts +17 -9
  104. package/skills/submit-paper/references/publishers/acm.md +5 -3
  105. package/src/adapters/memory/index.test.ts +22 -0
  106. package/src/adapters/memory/index.ts +21 -0
  107. package/src/adapters/node/index.ts +1 -0
  108. package/src/adapters/node/move-file.io.ts +21 -0
  109. package/src/adapters/node/move-file.test.ts +51 -0
  110. package/src/adapters/yaml/index.test.ts +16 -0
  111. package/src/adapters/yaml/index.ts +17 -0
  112. package/src/cli.test.ts +43 -0
  113. package/src/cli.ts +58 -6
  114. package/src/cycle-rules.ts +4 -5
  115. package/src/domain/cycle.test.ts +27 -0
  116. package/src/domain/cycle.ts +38 -6
  117. package/src/domain/paper-layout.ts +5 -2
  118. package/src/domain/paths.test.ts +28 -1
  119. package/src/domain/paths.ts +34 -1
  120. package/src/domain/scaffold-layout.ts +1 -1
  121. package/src/domain/versions-migration.test.ts +410 -0
  122. package/src/domain/versions-migration.ts +429 -0
  123. package/src/domain/versions.test.ts +564 -0
  124. package/src/domain/versions.ts +706 -0
  125. package/src/paper-settings-commands.test.ts +1 -1
  126. package/src/ports/move-file.ts +8 -0
  127. package/src/ports/yaml.ts +5 -0
  128. package/src/preset-card-rule.test.ts +3 -0
  129. package/src/presets.test.ts +55 -31
  130. package/src/rule-cases.test.ts +157 -8
  131. package/src/submission-freeze.test.ts +320 -0
  132. package/src/submission-new.test.ts +11 -1
  133. package/src/submission.ts +82 -11
  134. package/src/tex-venue-rules.test.ts +3 -0
  135. package/src/venue-rules.test.ts +2 -2
  136. package/src/version-rules.test.ts +222 -0
  137. package/src/version-rules.ts +282 -0
  138. package/src/versions-command.test.ts +594 -0
  139. package/src/versions-command.ts +360 -0
  140. package/src/versions.ts +286 -0
  141. package/dist/eslint-rules/cycle-stage.d.ts +0 -39
  142. package/dist/eslint-rules/cycle-stage.d.ts.map +0 -1
  143. package/dist/eslint-rules/cycle-stage.js +0 -119
  144. package/dist/eslint-rules/cycle-stage.js.map +0 -1
  145. package/docs/rules/cycle/stage.md +0 -85
  146. package/eslint-rules/cycle-stage.test.mjs +0 -131
  147. package/eslint-rules/cycle-stage.ts +0 -141
@@ -1,7 +1,7 @@
1
1
  /** `paperlint submission new`: what it reads and sends, the dry run, the save that records the id, the refusals. */
2
2
  import { map } from "remeda";
3
3
  import { expect, test } from "vitest";
4
- import { builtPaper } from "../test/recorded-fixture.ts";
4
+ import { builtPaper, recordText } from "../test/recorded-fixture.ts";
5
5
  import { latexReader } from "./adapters/latex/index.ts";
6
6
  import { absolutePath } from "./domain/paths.ts";
7
7
  import { err, ok } from "./domain/result.ts";
@@ -104,6 +104,16 @@ test("new --save in the cycles form: the id goes into the open attempt, the last
104
104
  const fs = newFiles({
105
105
  paper: { authors: AUTHORS, cycles: [closed, cycle("second", null)] },
106
106
  });
107
+ // Built: a save in the cycles form freezes the PDF with the source the build read.
108
+ fs.writeAtomic(
109
+ absolutePath(`${PAPER}/_build/sources.json`),
110
+ new TextEncoder().encode(
111
+ recordText(
112
+ { "paper.tex": DEFAULT_TEX },
113
+ { inputs: [["paper.tex", "body"]] },
114
+ ),
115
+ ),
116
+ );
107
117
  const r = await run({ sub: "new", save: true }, { files: fs });
108
118
  expect(r.code, r.errs.join("\n")).toBe(0);
109
119
  const written: unknown = JSON.parse(
package/src/submission.ts CHANGED
@@ -82,6 +82,13 @@ import { originOf, type PaperSource } from "./domain/paper-source.ts";
82
82
  import type { SourcesCodec } from "./ports/sources-codec.ts";
83
83
  import { MAIN } from "./build.ts";
84
84
  import { assessSent, venueMessages, type VenueMessage } from "./venue-rules.ts";
85
+ import {
86
+ freezePlan,
87
+ freezeSent,
88
+ NOT_FROZEN,
89
+ versionLines,
90
+ type FreezePlan,
91
+ } from "./versions.ts";
85
92
 
86
93
  /** How to make and set the site's token: the page, the scopes, the variable. Pure. */
87
94
  export function tokenSteps(
@@ -168,6 +175,8 @@ export interface SubmissionDeps {
168
175
  file: string,
169
176
  messages: readonly VenueMessage[],
170
177
  ) => Promise<string>;
178
+ /** The instant a frozen version's lock entry records (`versions/lock.json`), ISO 8601. */
179
+ readonly now: () => string;
171
180
  readonly log: (line: string) => void;
172
181
  readonly err: (line: string) => void;
173
182
  }
@@ -468,7 +477,12 @@ async function runShow(
468
477
  shown: shownPath(deps.cwd, pdfPath),
469
478
  sha256: bytes === null ? null : sha256Hex(bytes),
470
479
  };
471
- showLines(t, view.value, local).forEach((l) => {
480
+ // With `cycles`, the frozen versions beside the portal's copy: which is under review, which is history.
481
+ const versions = versionLines(t.paperDir, deps);
482
+ [
483
+ ...showLines(t, view.value, local),
484
+ ...(versions.ok ? ["", "versions", ...versions.value] : []),
485
+ ].forEach((l) => {
472
486
  deps.log(l);
473
487
  });
474
488
  return 0;
@@ -567,15 +581,71 @@ async function runUpdate(
567
581
  const refused = await venueCheck(t, bytes, a.save, deps);
568
582
  logAll(fieldLines(t.fields), deps);
569
583
  if (refused) return 2;
584
+ const plan = planFor(t, a.save, deps);
585
+ if (!plan.ok) return (deps.err(plan.error), 2);
570
586
  const change = changeOf(a, bytes, abstract.value, t.fields);
571
587
  const r = await portal.update(t.id, change, { save: a.save });
572
588
  if (!r.ok) return failed(r.error, deps);
573
- const local = { shown, sha256: sha256Hex(bytes) };
574
- const dropped = droppedLines(t.fields, t.venueFields, r.value);
575
- logAll([...updateLines(r.value, local, a.save), ...dropped], deps);
576
- const fine =
577
- r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
578
- return fine && dropped.length === 0 ? 0 : 1;
589
+ const sent = { shown, bytes, save: a.save, plan: plan.value };
590
+ return afterUpdate(t, r.value, sent, deps);
591
+ }
592
+
593
+ /** The portal's answer to `update` printed, and what it kept frozen: the exit code. */
594
+ function afterUpdate(
595
+ t: SubmissionTarget,
596
+ o: UpdateOutcome,
597
+ sent: {
598
+ readonly shown: string;
599
+ readonly bytes: Uint8Array;
600
+ readonly save: boolean;
601
+ readonly plan: FreezePlan | null;
602
+ },
603
+ deps: RunDeps,
604
+ ): number {
605
+ const local = { shown: sent.shown, sha256: sha256Hex(sent.bytes) };
606
+ const dropped = droppedLines(t.fields, t.venueFields, o);
607
+ logAll([...updateLines(o, local, sent.save), ...dropped], deps);
608
+ const fine = took(o);
609
+ const kept = fine && !o.dryRun ? sent.plan : null;
610
+ const frozen = freezeKept(t, kept, sent.bytes, deps);
611
+ return fine && dropped.length === 0 ? frozen : 1;
612
+ }
613
+
614
+ /** The portal answered valid, with a 2xx. */
615
+ const took = (o: {
616
+ readonly valid: boolean;
617
+ readonly httpStatus: number;
618
+ }): boolean => o.valid && o.httpStatus >= 200 && o.httpStatus < 300;
619
+
620
+ /** What a `--save` will freeze, decided before anything is sent; null for a dry run. */
621
+ const planFor = (
622
+ t: SubmissionTarget,
623
+ save: boolean,
624
+ deps: SubmissionDeps,
625
+ ): Result<FreezePlan | null, string> =>
626
+ save ? freezePlan(t.paperDir, t.resolved.settings.cycles, deps) : ok(null);
627
+
628
+ /** The PDF a save sent, frozen with its source when there is a plan: 0, or 1 when the lock did not take it. */
629
+ function freezeKept(
630
+ t: SubmissionTarget,
631
+ plan: FreezePlan | null,
632
+ pdf: Uint8Array,
633
+ deps: RunDeps,
634
+ ): number {
635
+ if (plan === null) return 0;
636
+ if (plan.kind === "flat") return (deps.log(NOT_FROZEN), 0);
637
+ const r = freezeSent(
638
+ {
639
+ paperDir: t.paperDir,
640
+ plan,
641
+ pdf,
642
+ shownPaper: shownPath(deps.cwd, t.paperDir),
643
+ },
644
+ deps,
645
+ );
646
+ if (!r.ok) return (deps.err(r.error), 1);
647
+ deps.log(r.value);
648
+ return 0;
579
649
  }
580
650
 
581
651
  // ── new ──────────────────────────────────────────────────────────────────────────────
@@ -900,15 +970,16 @@ async function runNew(
900
970
  const refused = await venueCheck(t, paper.pdf.bytes, a.save, deps);
901
971
  logAll(newLines(paper), deps);
902
972
  if (refused) return 2;
973
+ const plan = planFor(t, a.save, deps);
974
+ if (!plan.ok) return (deps.err(plan.error), 2);
903
975
  const r = await portal.create(paper, { save: a.save });
904
976
  if (!r.ok) return failed(r.error, deps);
905
977
  const dropped = droppedLines(t.fields, t.venueFields, r.value);
906
978
  logAll([...createLines(r.value, a.save), ...dropped], deps);
907
- const fine =
908
- r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
909
- if (!fine || (a.save && r.value.id === null)) return 1;
979
+ if (!took(r.value) || (a.save && r.value.id === null)) return 1;
980
+ const frozen = freezeKept(t, plan.value, paper.pdf.bytes, deps);
910
981
  const recorded = afterCreated(t, r.value.id, a, deps);
911
- return dropped.length === 0 ? recorded : 1;
982
+ return dropped.length === 0 ? Math.max(recorded, frozen) : 1;
912
983
  }
913
984
 
914
985
  /** The subcommands that run before the paper has a submission id: `new` creates it, `topics` needs none. */
@@ -998,9 +998,12 @@ describe("otherVenues", () => {
998
998
  if (!aidc.ok) return;
999
999
  expect(otherVenues(aidc.value, deps).map((o) => o.label)).toEqual([
1000
1000
  "acm-sigconf",
1001
+ "AGENT 2027",
1001
1002
  "AgenticDev",
1003
+ "AGENTVERIFY",
1002
1004
  "AISec",
1003
1005
  "MSR",
1006
+ "NLBSE",
1004
1007
  ]);
1005
1008
  });
1006
1009
  });
@@ -270,13 +270,13 @@ describe("pdf/profile — the declaration must resolve, or nothing is judged", (
270
270
  "an unknown venue (a typo would silently disable every check)",
271
271
  { extends: "paperlint:agentic-dev", kind: "short" },
272
272
  "preset",
273
- /agenticdev, aidc, aisec, ieee-conference, msr, realm/,
273
+ /agent, agenticdev, agentverify, aidc, aisec, ieee-conference, msr, nlbse, realm/,
274
274
  ],
275
275
  [
276
276
  "the base TeX set is not a venue",
277
277
  { extends: "paperlint:tex-base" },
278
278
  "preset",
279
- /agenticdev, aidc, aisec, ieee-conference, msr, realm/,
279
+ /agent, agenticdev, agentverify, aidc, aisec, ieee-conference, msr, nlbse, realm/,
280
280
  ],
281
281
  [
282
282
  "paperlint.json that is not JSON",
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The rules over a paper's frozen versions, each module called directly over in-memory files: the
3
+ * finding on a planted defect, silence on a clean folder, on another file, and on a paper without
4
+ * cycles. (`rule-cases.test.ts` runs each through real ESLint.)
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { latexReader } from "./adapters/latex/index.ts";
8
+ import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
9
+ import { sourcesCodec } from "./adapters/sources-record/index.ts";
10
+ import { sha256Hex } from "./domain/sha256.ts";
11
+ import { lockText, type Lock } from "./domain/versions.ts";
12
+ import {
13
+ draftRules,
14
+ submittedFrozenRules,
15
+ versionLayoutRules,
16
+ } from "./version-rules.ts";
17
+
18
+ const DIR = "/w/papers/p";
19
+ const enc = (t: string) => new TextEncoder().encode(t);
20
+ const locked = (t: string) => ({
21
+ kind: "file" as const,
22
+ bytes: enc(t).length,
23
+ sha256: sha256Hex(enc(t)),
24
+ frozen: "2026-10-23T16:58:01.000Z",
25
+ });
26
+
27
+ const OPEN = {
28
+ id: "beta-2027",
29
+ venue: { kind: "named", name: "Beta 2027", url: "https://beta.example" },
30
+ opened: "2026-09-09",
31
+ submission: { id: 7 },
32
+ };
33
+ const ACCEPTED = {
34
+ ...OPEN,
35
+ outcome: { kind: "accepted", date: "2026-12-01", evidence: "m.eml" },
36
+ };
37
+ const SOURCE =
38
+ "\\documentclass{article}\n\\begin{document}\nA.\n\\end{document}\n";
39
+ const DRIFTED = SOURCE.replace("A.", "A, then B.\nC.");
40
+
41
+ /** A paper folder: its settings, paper.tex, and `submitted.*` of beta-2027 frozen and locked. */
42
+ function tree(
43
+ o: {
44
+ readonly cycles?: readonly object[] | null;
45
+ readonly draft?: string;
46
+ readonly extra?: Readonly<Record<string, string>>;
47
+ readonly lock?: Lock;
48
+ } = {},
49
+ ): Record<string, string> {
50
+ const pdf = "%PDF sent";
51
+ return {
52
+ [`${DIR}/paperlint.json`]: JSON.stringify(
53
+ o.cycles === null ? { extends: null } : { cycles: o.cycles ?? [OPEN] },
54
+ ),
55
+ [`${DIR}/paper.tex`]: o.draft ?? SOURCE,
56
+ [`${DIR}/versions/beta-2027/submitted.pdf`]: pdf,
57
+ [`${DIR}/versions/beta-2027/submitted.tex`]: SOURCE,
58
+ [`${DIR}/versions/lock.json`]: lockText(
59
+ o.lock ?? {
60
+ "beta-2027/submitted.pdf": locked(pdf),
61
+ "beta-2027/submitted.tex": locked(SOURCE),
62
+ },
63
+ ),
64
+ ...o.extra,
65
+ };
66
+ }
67
+
68
+ interface Seen {
69
+ readonly messageId: string;
70
+ readonly data: Readonly<Record<string, string | number>>;
71
+ }
72
+
73
+ /** Every finding of the scorecard rules on `file` of the tree. */
74
+ function status(
75
+ t: Record<string, string>,
76
+ file = "PIPELINE-STATUS.md",
77
+ ): Seen[] {
78
+ const files = memoryFiles(t);
79
+ const deps = { files, listDir: memoryListDir(files) };
80
+ const rules = { ...versionLayoutRules(deps), ...submittedFrozenRules(deps) };
81
+ const out: Seen[] = [];
82
+ Object.values(rules).forEach((rule) => {
83
+ rule
84
+ .create({
85
+ filename: `${DIR}/${file}`,
86
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
87
+ report: (d) => out.push({ messageId: d.messageId, data: d.data }),
88
+ })
89
+ .root?.();
90
+ });
91
+ return out;
92
+ }
93
+
94
+ /** Every finding of the draft rules on `file` (paper.tex by default), its text read off the tree. */
95
+ function draft(t: Record<string, string>, file = "paper.tex"): Seen[] {
96
+ const files = memoryFiles(t);
97
+ const rules = draftRules({ files, latex: latexReader, codec: sourcesCodec });
98
+ const out: Seen[] = [];
99
+ Object.values(rules).forEach((rule) => {
100
+ rule
101
+ .create({
102
+ filename: `${DIR}/${file}`,
103
+ sourceCode: {
104
+ text: t[`${DIR}/${file}`] ?? "",
105
+ getLocFromIndex: () => ({ line: 1, column: 0 }),
106
+ },
107
+ report: (d) => {
108
+ if ("messageId" in d)
109
+ out.push({ messageId: d.messageId, data: d.data ?? {} });
110
+ },
111
+ })
112
+ .root?.();
113
+ });
114
+ return out;
115
+ }
116
+
117
+ describe("versions/layout and cycle/submitted-frozen", () => {
118
+ it("a submission frozen, every file locked as it stands: silent", () => {
119
+ expect(status(tree())).toStrictEqual([]);
120
+ });
121
+
122
+ it("report on the scorecard only, and say nothing for a paper without cycles", () => {
123
+ const stray = { [`${DIR}/versions/notes.md`]: "x" };
124
+ expect(status(tree({ extra: stray }), "paper.tex")).toStrictEqual([]);
125
+ expect(status(tree({ cycles: null, extra: stray }))).toStrictEqual([]);
126
+ });
127
+
128
+ it("a stray file and a submission not frozen, each said by its rule", () => {
129
+ const t = tree({
130
+ cycles: [
131
+ {
132
+ ...OPEN,
133
+ outcome: { kind: "rejected", date: "2026-10-01", evidence: "m.eml" },
134
+ },
135
+ { ...OPEN, id: "gamma-2027", submission: { id: 9 } },
136
+ ],
137
+ extra: { [`${DIR}/versions/notes.md`]: "x" },
138
+ });
139
+ expect(status(t).map((f) => f.messageId)).toStrictEqual([
140
+ "stray",
141
+ "unfrozen",
142
+ ]);
143
+ });
144
+ });
145
+
146
+ describe("cycle/draft-drift and cycle/camera-ready-source", () => {
147
+ it("the draft as frozen: silent; paper.tex only", () => {
148
+ expect(draft(tree())).toStrictEqual([]);
149
+ expect(draft(tree({ draft: DRIFTED }), "notes.tex")).toStrictEqual([]);
150
+ });
151
+
152
+ it("an open cycle and a drifted draft: the drift, with the lines", () => {
153
+ expect(draft(tree({ draft: DRIFTED }))).toStrictEqual([
154
+ {
155
+ messageId: "drift",
156
+ data: {
157
+ key: "beta-2027/submitted.tex",
158
+ venue: "Beta 2027",
159
+ drift: "2 lines added, 1 removed",
160
+ },
161
+ },
162
+ ]);
163
+ });
164
+
165
+ it("no cycles, a cycle parked between venues, or no frozen source: nothing to measure", () => {
166
+ expect(draft(tree({ cycles: null, draft: DRIFTED }))).toStrictEqual([]);
167
+ const rejected = {
168
+ ...OPEN,
169
+ outcome: { kind: "rejected", date: "2026-12-01", evidence: "m.eml" },
170
+ };
171
+ expect(draft(tree({ cycles: [rejected], draft: DRIFTED }))).toStrictEqual(
172
+ [],
173
+ );
174
+ const t = Object.fromEntries(
175
+ Object.entries(tree({ draft: DRIFTED })).filter(
176
+ ([k]) => !k.endsWith("/submitted.tex"),
177
+ ),
178
+ );
179
+ expect(draft(t)).toStrictEqual([]);
180
+ });
181
+ });
182
+
183
+ describe("cycle/camera-ready-source", () => {
184
+ it("an accepted cycle: silent while the camera-ready is edited, however far the draft moved", () => {
185
+ expect(draft(tree({ cycles: [ACCEPTED], draft: DRIFTED }))).toStrictEqual(
186
+ [],
187
+ );
188
+ // A PDF frozen without its source: nothing to compare (versions/layout names the missing source).
189
+ const pdfOnly = {
190
+ [`${DIR}/versions/beta-2027/camera-ready.pdf`]: "%PDF final",
191
+ };
192
+ expect(
193
+ draft(tree({ cycles: [ACCEPTED], draft: DRIFTED, extra: pdfOnly })),
194
+ ).toStrictEqual([]);
195
+ });
196
+
197
+ it("an accepted cycle with its camera-ready frozen: judged against what was accepted, the draft no more", () => {
198
+ const camera = (source: string) => ({
199
+ [`${DIR}/versions/beta-2027/camera-ready.pdf`]: "%PDF final",
200
+ [`${DIR}/versions/beta-2027/camera-ready.tex`]: source,
201
+ });
202
+ // The accepted source plus a change: it derives, and the draft moving on after it is free.
203
+ expect(
204
+ draft(
205
+ tree({ cycles: [ACCEPTED], draft: "Next.", extra: camera(DRIFTED) }),
206
+ ),
207
+ ).toStrictEqual([]);
208
+ expect(
209
+ draft(
210
+ tree({
211
+ cycles: [ACCEPTED],
212
+ extra: camera("\\documentclass{book}\nAnother paper.\n"),
213
+ }),
214
+ ),
215
+ ).toStrictEqual([
216
+ {
217
+ messageId: "base",
218
+ data: { id: "beta-2027", kept: "0", total: "4" },
219
+ },
220
+ ]);
221
+ });
222
+ });