paperlint 10.4.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/.github/workflows/deadlines.yml +27 -7
  2. package/README.md +16 -15
  3. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  4. package/dist/CLAUDE.md.spec.js +6 -3
  5. package/dist/CLAUDE.md.spec.js.map +1 -1
  6. package/dist/adapters/hotcrp/index.d.ts +6 -4
  7. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  8. package/dist/adapters/hotcrp/index.js +6 -4
  9. package/dist/adapters/hotcrp/index.js.map +1 -1
  10. package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
  11. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  12. package/dist/adapters/hotcrp/portal.io.js +36 -11
  13. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  14. package/dist/adapters/hotcrp/response.d.ts +6 -1
  15. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  16. package/dist/adapters/hotcrp/response.js +17 -0
  17. package/dist/adapters/hotcrp/response.js.map +1 -1
  18. package/dist/adapters/hotcrp/site.d.ts +9 -0
  19. package/dist/adapters/hotcrp/site.d.ts.map +1 -0
  20. package/dist/adapters/hotcrp/site.js +22 -0
  21. package/dist/adapters/hotcrp/site.js.map +1 -0
  22. package/dist/adapters/hotcrp/token.d.ts +13 -0
  23. package/dist/adapters/hotcrp/token.d.ts.map +1 -0
  24. package/dist/adapters/hotcrp/token.js +48 -0
  25. package/dist/adapters/hotcrp/token.js.map +1 -0
  26. package/dist/adapters/latex/abstract.d.ts +22 -0
  27. package/dist/adapters/latex/abstract.d.ts.map +1 -0
  28. package/dist/adapters/latex/abstract.js +187 -0
  29. package/dist/adapters/latex/abstract.js.map +1 -0
  30. package/dist/adapters/latex/captions.d.ts +9 -0
  31. package/dist/adapters/latex/captions.d.ts.map +1 -0
  32. package/dist/adapters/latex/captions.js +40 -0
  33. package/dist/adapters/latex/captions.js.map +1 -0
  34. package/dist/adapters/latex/headings.d.ts +6 -0
  35. package/dist/adapters/latex/headings.d.ts.map +1 -1
  36. package/dist/adapters/latex/headings.js +5 -4
  37. package/dist/adapters/latex/headings.js.map +1 -1
  38. package/dist/adapters/latex/index.d.ts +1 -0
  39. package/dist/adapters/latex/index.d.ts.map +1 -1
  40. package/dist/adapters/latex/index.js +3 -0
  41. package/dist/adapters/latex/index.js.map +1 -1
  42. package/dist/adapters/latex/parse.d.ts +15 -0
  43. package/dist/adapters/latex/parse.d.ts.map +1 -1
  44. package/dist/adapters/latex/parse.js +64 -1
  45. package/dist/adapters/latex/parse.js.map +1 -1
  46. package/dist/adapters/node/list-entries.io.d.ts.map +1 -1
  47. package/dist/adapters/node/list-entries.io.js +3 -1
  48. package/dist/adapters/node/list-entries.io.js.map +1 -1
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +20 -8
  51. package/dist/cli.js.map +1 -1
  52. package/dist/cycle-rules.d.ts.map +1 -1
  53. package/dist/cycle-rules.js +3 -4
  54. package/dist/cycle-rules.js.map +1 -1
  55. package/dist/domain/deadline.d.ts +8 -1
  56. package/dist/domain/deadline.d.ts.map +1 -1
  57. package/dist/domain/deadline.js +26 -12
  58. package/dist/domain/deadline.js.map +1 -1
  59. package/dist/domain/decide-first.d.ts +11 -1
  60. package/dist/domain/decide-first.d.ts.map +1 -1
  61. package/dist/domain/decide-first.js +6 -0
  62. package/dist/domain/decide-first.js.map +1 -1
  63. package/dist/domain/index-columns.d.ts.map +1 -1
  64. package/dist/domain/index-columns.js +2 -0
  65. package/dist/domain/index-columns.js.map +1 -1
  66. package/dist/domain/portal-token.d.ts +44 -0
  67. package/dist/domain/portal-token.d.ts.map +1 -0
  68. package/dist/domain/portal-token.js +32 -0
  69. package/dist/domain/portal-token.js.map +1 -0
  70. package/dist/domain/study-registry.d.ts +65 -8
  71. package/dist/domain/study-registry.d.ts.map +1 -1
  72. package/dist/domain/study-registry.js +63 -19
  73. package/dist/domain/study-registry.js.map +1 -1
  74. package/dist/domain/submission.d.ts +30 -0
  75. package/dist/domain/submission.d.ts.map +1 -1
  76. package/dist/domain/submission.js +68 -0
  77. package/dist/domain/submission.js.map +1 -1
  78. package/dist/lib/paper-config.d.ts +1 -1
  79. package/dist/lib/paper-config.d.ts.map +1 -1
  80. package/dist/lib/paper-config.js +6 -4
  81. package/dist/lib/paper-config.js.map +1 -1
  82. package/dist/outputs-before-decision-rule.d.ts +1 -1
  83. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  84. package/dist/outputs-before-decision-rule.js +21 -17
  85. package/dist/outputs-before-decision-rule.js.map +1 -1
  86. package/dist/paper-settings.d.ts +3 -1
  87. package/dist/paper-settings.d.ts.map +1 -1
  88. package/dist/paper-settings.js +11 -5
  89. package/dist/paper-settings.js.map +1 -1
  90. package/dist/ports/latex.d.ts +23 -0
  91. package/dist/ports/latex.d.ts.map +1 -1
  92. package/dist/ports/list-entries.d.ts +7 -4
  93. package/dist/ports/list-entries.d.ts.map +1 -1
  94. package/dist/ports/study-deps.d.ts +14 -0
  95. package/dist/ports/study-deps.d.ts.map +1 -0
  96. package/dist/ports/study-deps.js +2 -0
  97. package/dist/ports/study-deps.js.map +1 -0
  98. package/dist/ports/study-folder.d.ts +43 -0
  99. package/dist/ports/study-folder.d.ts.map +1 -0
  100. package/dist/ports/study-folder.js +2 -0
  101. package/dist/ports/study-folder.js.map +1 -0
  102. package/dist/ports/submission-portal.d.ts +39 -2
  103. package/dist/ports/submission-portal.d.ts.map +1 -1
  104. package/dist/ports/venue-deadlines.d.ts +1 -1
  105. package/dist/presets.d.ts +1 -2
  106. package/dist/presets.d.ts.map +1 -1
  107. package/dist/presets.js +1 -2
  108. package/dist/presets.js.map +1 -1
  109. package/dist/studies.d.ts.map +1 -1
  110. package/dist/studies.js +10 -7
  111. package/dist/studies.js.map +1 -1
  112. package/dist/study-folder.d.ts +14 -0
  113. package/dist/study-folder.d.ts.map +1 -0
  114. package/dist/study-folder.js +123 -0
  115. package/dist/study-folder.js.map +1 -0
  116. package/dist/study-gate.d.ts +1 -1
  117. package/dist/study-gate.d.ts.map +1 -1
  118. package/dist/study-gate.js +65 -28
  119. package/dist/study-gate.js.map +1 -1
  120. package/dist/study-index-input.d.ts +1 -1
  121. package/dist/study-index-input.d.ts.map +1 -1
  122. package/dist/study-index-input.js +8 -3
  123. package/dist/study-index-input.js.map +1 -1
  124. package/dist/study-modes.d.ts +38 -27
  125. package/dist/study-modes.d.ts.map +1 -1
  126. package/dist/study-modes.js +42 -53
  127. package/dist/study-modes.js.map +1 -1
  128. package/dist/study-registry-rule.d.ts +2 -6
  129. package/dist/study-registry-rule.d.ts.map +1 -1
  130. package/dist/study-registry-rule.js +32 -41
  131. package/dist/study-registry-rule.js.map +1 -1
  132. package/dist/submission.d.ts +49 -11
  133. package/dist/submission.d.ts.map +1 -1
  134. package/dist/submission.js +332 -44
  135. package/dist/submission.js.map +1 -1
  136. package/docs/configuration.md +2 -1
  137. package/docs/design/study-modes.md +46 -28
  138. package/docs/rules/cycle/record.md +9 -6
  139. package/docs/rules/repro/outputs-before-decision.md +3 -0
  140. package/docs/rules/repro/study-registered.md +3 -2
  141. package/docs/rules/tex/heading-case.md +1 -1
  142. package/docs/submission.md +47 -48
  143. package/eslint.config.mjs +39 -24
  144. package/lib/paper-config.ts +7 -4
  145. package/package.json +6 -1
  146. package/presets/msr.jsonc +9 -7
  147. package/presets/msr.md +14 -10
  148. package/scripts/check.ts +1 -1
  149. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  150. package/scripts/refresh-deadlines/compare.ts +101 -0
  151. package/scripts/refresh-deadlines/main.test.ts +140 -0
  152. package/scripts/refresh-deadlines/main.ts +56 -0
  153. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  154. package/scripts/refresh-deadlines/refresh.ts +183 -0
  155. package/skills/grade-paper-writing/SKILL.md +4 -2
  156. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  158. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  159. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  160. package/skills/submission-portal/SKILL.md +60 -44
  161. package/skills/submission-portal/SKILL.md.spec.ts +62 -43
  162. package/skills/submission-portal/submission-portal.test.ts +4 -73
  163. package/src/CLAUDE.md +7 -4
  164. package/src/CLAUDE.md.spec.ts +6 -3
  165. package/src/adapters/hotcrp/cassette/README.md +4 -4
  166. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  167. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  168. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  169. package/src/adapters/hotcrp/index.ts +15 -3
  170. package/src/adapters/hotcrp/portal.io.ts +61 -17
  171. package/src/adapters/hotcrp/portal.test.ts +105 -1
  172. package/src/adapters/hotcrp/response.ts +21 -0
  173. package/src/adapters/hotcrp/site.ts +34 -0
  174. package/src/adapters/hotcrp/token.test.ts +69 -0
  175. package/src/adapters/hotcrp/token.ts +51 -0
  176. package/src/adapters/latex/abstract.test.ts +280 -0
  177. package/src/adapters/latex/abstract.ts +272 -0
  178. package/src/adapters/latex/captions.test.ts +57 -0
  179. package/src/adapters/latex/captions.ts +56 -0
  180. package/src/adapters/latex/headings.ts +5 -4
  181. package/src/adapters/latex/index.test.ts +31 -1
  182. package/src/adapters/latex/index.ts +3 -0
  183. package/src/adapters/latex/parse.ts +71 -3
  184. package/src/adapters/node/list-entries.io.ts +2 -1
  185. package/src/adapters/node/list-entries.test.ts +18 -0
  186. package/src/cli.test.ts +20 -8
  187. package/src/cli.ts +22 -13
  188. package/src/cycle-rules.test.ts +6 -6
  189. package/src/cycle-rules.ts +7 -4
  190. package/src/domain/deadline.test.ts +31 -2
  191. package/src/domain/deadline.ts +42 -16
  192. package/src/domain/decide-first.test.ts +33 -0
  193. package/src/domain/decide-first.ts +16 -1
  194. package/src/domain/index-columns.test.ts +49 -4
  195. package/src/domain/index-columns.ts +2 -0
  196. package/src/domain/portal-token.test.ts +55 -0
  197. package/src/domain/portal-token.ts +63 -0
  198. package/src/domain/study-measurements.test.ts +7 -3
  199. package/src/domain/study-registry.test.ts +34 -6
  200. package/src/domain/study-registry.ts +123 -31
  201. package/src/domain/submission.test.ts +88 -0
  202. package/src/domain/submission.ts +99 -0
  203. package/src/new-paper.test.ts +1 -0
  204. package/src/outputs-before-decision-rule.test.ts +47 -2
  205. package/src/outputs-before-decision-rule.ts +31 -22
  206. package/src/paper-settings.test.ts +20 -0
  207. package/src/paper-settings.ts +17 -5
  208. package/src/ports/latex.ts +25 -0
  209. package/src/ports/list-entries.ts +7 -4
  210. package/src/ports/study-deps.ts +14 -0
  211. package/src/ports/study-folder.ts +38 -0
  212. package/src/ports/submission-portal.ts +43 -1
  213. package/src/ports/venue-deadlines.ts +1 -1
  214. package/src/presets.test.ts +8 -5
  215. package/src/presets.ts +1 -2
  216. package/src/studies-index-modes.test.ts +2 -2
  217. package/src/studies.ts +19 -10
  218. package/src/study-folder.test.ts +237 -0
  219. package/src/study-folder.ts +176 -0
  220. package/src/study-gate.test.ts +158 -7
  221. package/src/study-gate.ts +127 -54
  222. package/src/study-index-input.ts +12 -4
  223. package/src/study-modes.ts +67 -80
  224. package/src/study-registry-rule.test.ts +87 -0
  225. package/src/study-registry-rule.ts +58 -66
  226. package/src/submission-new.test.ts +569 -0
  227. package/src/submission-show.test.ts +210 -0
  228. package/src/submission-update.test.ts +160 -0
  229. package/src/submission.test.ts +140 -518
  230. package/src/submission.ts +494 -61
  231. package/scripts/refresh-deadlines.test.ts +0 -431
  232. package/scripts/refresh-deadlines.ts +0 -387
@@ -0,0 +1,569 @@
1
+ /** `paperlint submission new`: what it reads and sends, the dry run, the save that records the id, the refusals. */
2
+ import { map } from "remeda";
3
+ import { expect, test } from "vitest";
4
+ import { builtPaper } from "../test/recorded-fixture.ts";
5
+ import { latexReader } from "./adapters/latex/index.ts";
6
+ import { absolutePath } from "./domain/paths.ts";
7
+ import { err, ok } from "./domain/result.ts";
8
+ import { OPAQUE } from "./domain/tex-document.ts";
9
+ import type { LatexReader } from "./ports/latex.ts";
10
+ import { titleOf } from "./submission.ts";
11
+ import {
12
+ PAPER,
13
+ SITE,
14
+ files,
15
+ fakePortal,
16
+ CHECK,
17
+ run,
18
+ AUTHORS,
19
+ facts,
20
+ ABSTRACT_TEX,
21
+ DEFAULT_TEX,
22
+ newFiles,
23
+ WANT,
24
+ } from "../test/submission-fixtures.ts";
25
+
26
+ test("new: a dry run by default — title from \\title, abstract from \\begin{abstract} with the author's macro expanded, the declared authors, the PDF", async () => {
27
+ const fs = newFiles({});
28
+ const r = await run({ sub: "new" }, { files: fs });
29
+ expect(r.code, r.errs.join("\n")).toBe(0);
30
+ expect(r.errs).toStrictEqual([]);
31
+ expect(r.seen[0]?.calls).toStrictEqual([["create", WANT, { save: false }]]);
32
+ expect(r.out).toStrictEqual([
33
+ CHECK("papers/p/paper.pdf", "the call for papers, https://example.org/cfp"),
34
+ "title Agents That Lie",
35
+ "author 1 Ada Lovelace <ada@example.org>, Analytical",
36
+ "author 2 Bob Bobson <bob@example.org>",
37
+ "abstract 17 words:",
38
+ ` ${WANT.abstract}`,
39
+ "dry run — the portal checked the new submission and kept nothing; --save creates it for real",
40
+ "HTTP 200",
41
+ "valid yes",
42
+ "changes new, title, abstract, authors, submission",
43
+ `do on the site, ${SITE}/paper/<its number> — paperlint sends only the title, the abstract, the authors and the PDF:`,
44
+ " - PC conflicts (HotCRP's API takes them as pc_conflicts; paperlint does not send them)",
45
+ " - topics, and every field the venue adds (paper type, artifact, consent boxes …)",
46
+ " - then mark it submitted: npx paperlint submission update papers/p --submit (a dry run; --save after your yes)",
47
+ ]);
48
+ // A dry run writes nothing.
49
+ expect(
50
+ JSON.parse(
51
+ new TextDecoder().decode(
52
+ fs.readBytes(absolutePath(`${PAPER}/paperlint.json`)) ??
53
+ new Uint8Array(),
54
+ ),
55
+ ),
56
+ ).toStrictEqual({ extends: "../../venue.jsonc", authors: AUTHORS });
57
+ });
58
+
59
+ test("new --save: the id the portal chose is written into paperlint.json, and `show` is the next command", async () => {
60
+ const fs = newFiles({});
61
+ const r = await run({ sub: "new", save: true }, { files: fs });
62
+ expect(r.code, r.errs.join("\n")).toBe(0);
63
+ expect(r.seen[0]?.calls).toStrictEqual([["create", WANT, { save: true }]]);
64
+ expect(r.out.slice(6, 7)).toStrictEqual(["created submission 42"]);
65
+ expect(r.out.slice(-2)).toStrictEqual([
66
+ `recorded "submission": { "id": 42 } in papers/p/paperlint.json`,
67
+ `next npx paperlint submission show papers/p — it should say "match yes"`,
68
+ ]);
69
+ expect(r.out).toContain(
70
+ `do on the site, ${SITE}/paper/42 — paperlint sends only the title, the abstract, the authors and the PDF:`,
71
+ );
72
+ const written = new TextDecoder().decode(
73
+ fs.readBytes(absolutePath(`${PAPER}/paperlint.json`)) ?? new Uint8Array(),
74
+ );
75
+ expect(JSON.parse(written)).toStrictEqual({
76
+ extends: "../../venue.jsonc",
77
+ authors: AUTHORS,
78
+ submission: { id: 42 },
79
+ });
80
+ // And the next run reads it: `new` again is refused, `show` asks for 42.
81
+ const again = await run({ sub: "new" }, { files: fs });
82
+ expect(again.code).toBe(2);
83
+ expect(again.errs).toStrictEqual([
84
+ `${PAPER}/paperlint.json: the paper already has submission 42 on ${SITE} — \`paperlint submission show\` reads it, \`paperlint submission update\` changes it`,
85
+ ]);
86
+ const show = await run({ sub: "show" }, { files: fs });
87
+ expect(show.seen[0]?.calls).toStrictEqual([["show", 42]]);
88
+ });
89
+
90
+ test("new --save in the cycles form: the id goes into the open attempt, the last cycle", async () => {
91
+ const cycle = (id: string, outcome: unknown) => ({
92
+ id,
93
+ venue: { kind: "preset", extends: "../../venue.jsonc" },
94
+ opened: "2026-10-01",
95
+ ...(outcome === null ? {} : { outcome }),
96
+ });
97
+ const closed = cycle("first", {
98
+ kind: "rejected",
99
+ date: "2026-10-05",
100
+ evidence: "mail.eml",
101
+ });
102
+ const fs = newFiles({
103
+ paper: { authors: AUTHORS, cycles: [closed, cycle("second", null)] },
104
+ });
105
+ const r = await run({ sub: "new", save: true }, { files: fs });
106
+ expect(r.code, r.errs.join("\n")).toBe(0);
107
+ const written: unknown = JSON.parse(
108
+ new TextDecoder().decode(
109
+ fs.readBytes(absolutePath(`${PAPER}/paperlint.json`)) ?? new Uint8Array(),
110
+ ),
111
+ );
112
+ expect(written).toStrictEqual({
113
+ authors: AUTHORS,
114
+ cycles: [closed, { ...cycle("second", null), submission: { id: 42 } }],
115
+ });
116
+ });
117
+
118
+ test('new without "authors": refused with the key to fill — never guessed, nothing sent', async () => {
119
+ const r = await run(
120
+ { sub: "new" },
121
+ { files: newFiles({ paper: { extends: "../../venue.jsonc" } }) },
122
+ );
123
+ expect(r.code).toBe(2);
124
+ expect(r.seen[0]?.calls).toStrictEqual([]);
125
+ expect(r.errs).toStrictEqual([
126
+ `${PAPER}/paperlint.json: no "authors" — add "authors": [{ "name": "…", "email": "…", "affiliation": "…" }], every author in the paper's order, as 127.0.0.1:9 should record them; paperlint never guesses them`,
127
+ ]);
128
+ });
129
+
130
+ test("the build's facts only stand in for the title: none, stale or broken change nothing when \\title reads", async () => {
131
+ const runs = await Promise.all(
132
+ [null, facts({ sha: "0".repeat(64) }), "{"].map((f) =>
133
+ run({ sub: "new" }, { files: newFiles({ facts: f }) }),
134
+ ),
135
+ );
136
+ expect(runs.map((r) => [r.code, r.seen[0]?.calls])).toStrictEqual(
137
+ runs.map(() => [0, [["create", WANT, { save: false }]]]),
138
+ );
139
+ });
140
+
141
+ test("an abstract in a file paper.tex includes is read there, as the build recorded it", async () => {
142
+ const tex = DEFAULT_TEX.replace(ABSTRACT_TEX, "\\input{abstract}\n");
143
+ const r = await run(
144
+ { sub: "new" },
145
+ {
146
+ files: files({
147
+ paper: { extends: "../../venue.jsonc", authors: AUTHORS },
148
+ extra: builtPaper(
149
+ PAPER,
150
+ { "paper.tex": tex, "abstract.tex": ABSTRACT_TEX },
151
+ [
152
+ { path: "paper.tex", role: "body" },
153
+ { path: "abstract.tex", role: "body" },
154
+ ],
155
+ ),
156
+ }),
157
+ },
158
+ );
159
+ expect(r.code, r.errs.join("\n")).toBe(0);
160
+ expect(r.seen[0]?.calls).toStrictEqual([["create", WANT, { save: false }]]);
161
+ });
162
+
163
+ test("two \\title declarations and no PDF Title: refused, naming each place — nothing sent", async () => {
164
+ const r = await run(
165
+ { sub: "new" },
166
+ {
167
+ files: newFiles({
168
+ tex: DEFAULT_TEX.replace(
169
+ "\\title{Agents That\\\\ Lie}\n",
170
+ "\\title{Agents That\\\\ Lie}\n\\title{Second}\n",
171
+ ),
172
+ }),
173
+ },
174
+ );
175
+ expect([r.code, r.errs, r.seen[0]?.calls]).toStrictEqual([
176
+ 2,
177
+ [
178
+ "the paper sets \\title 2 times (paper.tex:3:8, paper.tex:4:8), and the PDF names no Title — which one to send is not guessed: keep one \\title, or set \\hypersetup{pdftitle={<the title as plain text>}} and rebuild",
179
+ ],
180
+ [],
181
+ ]);
182
+ });
183
+
184
+ test("a \\title in a file paper.tex includes is read there, as the build recorded it", async () => {
185
+ const title = "\\title{Agents That\\\\ Lie}\n";
186
+ expect(DEFAULT_TEX).toContain(title);
187
+ const r = await run(
188
+ { sub: "new" },
189
+ {
190
+ files: files({
191
+ paper: { extends: "../../venue.jsonc", authors: AUTHORS },
192
+ extra: builtPaper(
193
+ PAPER,
194
+ {
195
+ "paper.tex": DEFAULT_TEX.replace(title, "\\input{front}\n"),
196
+ "front.tex": title,
197
+ },
198
+ [
199
+ { path: "paper.tex", role: "body" },
200
+ { path: "front.tex", role: "body" },
201
+ ],
202
+ ),
203
+ }),
204
+ },
205
+ );
206
+ expect(r.code, r.errs.join("\n")).toBe(0);
207
+ expect(r.seen[0]?.calls).toStrictEqual([["create", WANT, { save: false }]]);
208
+ });
209
+
210
+ test("new --abstract <file>: its text is sent instead of the build's, whitespace as one space", async () => {
211
+ const fs = newFiles({
212
+ facts: null,
213
+ paper: { extends: "../../venue.jsonc", authors: AUTHORS },
214
+ });
215
+ const r = await run(
216
+ { sub: "new", abstract: "abs.txt" },
217
+ {
218
+ files: {
219
+ ...fs,
220
+ readBytes: (p) =>
221
+ p === "/w/abs.txt"
222
+ ? new TextEncoder().encode(" Line one.\n\nLine two. \n")
223
+ : fs.readBytes(p),
224
+ },
225
+ },
226
+ );
227
+ expect(r.code, r.errs.join("\n")).toBe(0);
228
+ expect(r.seen[0]?.calls).toStrictEqual([
229
+ ["create", { ...WANT, abstract: "Line one. Line two." }, { save: false }],
230
+ ]);
231
+ });
232
+
233
+ test("new: the paper already accepted in its current cycle — refused; a new submission is a new cycle", async () => {
234
+ const r = await run(
235
+ { sub: "new" },
236
+ {
237
+ files: newFiles({
238
+ paper: {
239
+ authors: AUTHORS,
240
+ cycles: [
241
+ {
242
+ id: "one",
243
+ venue: { kind: "preset", extends: "../../venue.jsonc" },
244
+ opened: "2026-10-01",
245
+ outcome: {
246
+ kind: "accepted",
247
+ date: "2026-10-09",
248
+ evidence: "m.eml",
249
+ },
250
+ },
251
+ ],
252
+ },
253
+ }),
254
+ },
255
+ );
256
+ expect(r.code).toBe(2);
257
+ expect(r.errs).toStrictEqual([
258
+ `${PAPER}/paperlint.json: the current attempt is accepted — a new submission belongs to a new cycle`,
259
+ ]);
260
+ });
261
+
262
+ test("new: no PDF, a portal that says invalid, a save the portal did not make — each exits non-zero", async () => {
263
+ const noPdf = await run(
264
+ { sub: "new", pdf: "nope.pdf" },
265
+ { files: newFiles({}) },
266
+ );
267
+ expect(noPdf.code).toBe(2);
268
+ expect(noPdf.errs).toStrictEqual([
269
+ "no PDF at nope.pdf — build the paper, or name one with --pdf",
270
+ ]);
271
+ const invalid = await run(
272
+ { sub: "new" },
273
+ {
274
+ files: newFiles({}),
275
+ portal: fakePortal({
276
+ create: ok({
277
+ httpStatus: 200,
278
+ valid: false,
279
+ changes: ["new"],
280
+ messages: [
281
+ { message: "Entry required.", field: "topics", status: 2 },
282
+ ],
283
+ dryRun: true,
284
+ id: null,
285
+ }),
286
+ }),
287
+ },
288
+ );
289
+ expect(invalid.code).toBe(1);
290
+ expect(invalid.out.slice(-2)).toStrictEqual([
291
+ "changes new",
292
+ " portal says: topics: Entry required.",
293
+ ]);
294
+ const withheld = await run(
295
+ { sub: "new", save: true },
296
+ {
297
+ files: newFiles({}),
298
+ portal: fakePortal({
299
+ create: ok({
300
+ httpStatus: 200,
301
+ valid: true,
302
+ changes: ["new"],
303
+ messages: [],
304
+ dryRun: true,
305
+ id: null,
306
+ }),
307
+ }),
308
+ },
309
+ );
310
+ expect(withheld.code).toBe(1);
311
+ expect(withheld.out).toContain(
312
+ "NOTHING CREATED — the portal did not save it",
313
+ );
314
+ });
315
+
316
+ test("an abstract paperlint cannot read without guessing is refused, naming the construct and its line — nothing sent", async () => {
317
+ const undefinedMacro = await run(
318
+ { sub: "new" },
319
+ {
320
+ files: newFiles({
321
+ tex: DEFAULT_TEX.replace("\\ours{} resolved", "\\nope{} resolved"),
322
+ }),
323
+ },
324
+ );
325
+ expect(undefinedMacro.code).toBe(2);
326
+ expect(undefinedMacro.errs).toStrictEqual([
327
+ "the abstract, paper.tex:7:28: \\nope, which no \\newcommand, \\renewcommand or \\def in the paper defines — paperlint sends no text it would have to guess; give the abstract with --abstract <file>, a plain-text file",
328
+ ]);
329
+ const twice = await run(
330
+ { sub: "new" },
331
+ {
332
+ files: newFiles({
333
+ tex: DEFAULT_TEX.replace(
334
+ "\\newcommand{\\ours}{TestLie}\n",
335
+ "\\newcommand{\\ours}{TestLie}\n\\providecommand{\\ours}{Other}\n",
336
+ ),
337
+ }),
338
+ },
339
+ );
340
+ expect([twice.code, twice.errs]).toStrictEqual([
341
+ 2,
342
+ [
343
+ "the abstract uses \\ours, which the paper defines 2 times (paper.tex:2:1, paper.tex:3:1) — which one TeX uses there is not guessed; give the abstract with --abstract <file>, a plain-text file",
344
+ ],
345
+ ]);
346
+ const none = await run(
347
+ { sub: "new" },
348
+ { files: newFiles({ tex: DEFAULT_TEX.replace(ABSTRACT_TEX, "") }) },
349
+ );
350
+ expect(none.errs).toStrictEqual([
351
+ "paper.tex has no \\begin{abstract}…\\end{abstract} — give the abstract with --abstract <file>, a plain-text file",
352
+ ]);
353
+ expect([undefinedMacro, none].map((r) => r.seen[0]?.calls)).toStrictEqual([
354
+ [],
355
+ [],
356
+ ]);
357
+ });
358
+
359
+ test("titleOf: the source's one \\title as text; one the reader cannot read whole, or several → the PDF's Info Title, then its XMP dc:title; neither → the fix", () => {
360
+ /** A LaTeX reader whose source has a `\\title` with each text in `texts` (at offsets 0, 40, …); `"none"` has none. */
361
+ const titled = (...texts: string[]): Pick<LatexReader, "headings"> => ({
362
+ headings: (src) =>
363
+ src === "none"
364
+ ? []
365
+ : texts.map((text, i) => ({
366
+ command: { kind: "title" },
367
+ argument: "title",
368
+ title: { segments: [{ text, at: 40 * i }] },
369
+ })),
370
+ });
371
+ const plain = titled("Plain \u2028 Title");
372
+ const opaque = titled(`${OPAQUE}: Lies`);
373
+ const twice = titled("One", "Two");
374
+ const where = (o: number) => `@${String(o)}`;
375
+ expect([
376
+ titleOf({ text: "src", where }, plain, { Title: "Ignored" }),
377
+ titleOf({ text: "src", where }, opaque, {
378
+ Title: "Info",
379
+ "dc:title": "XMP",
380
+ }),
381
+ titleOf({ text: "src", where }, opaque, { "dc:title": "From The XMP" }),
382
+ titleOf(null, plain, { Title: "Info" }),
383
+ titleOf({ text: "src", where }, opaque, {}),
384
+ titleOf({ text: "none", where }, plain, null),
385
+ titleOf({ text: "src", where }, twice, { Title: "Info" }),
386
+ titleOf({ text: "src", where }, twice, null),
387
+ ]).toStrictEqual([
388
+ ok("Plain Title"),
389
+ ok("Info"),
390
+ ok("From The XMP"),
391
+ ok("Info"),
392
+ err(
393
+ "the \\title in paper.tex uses a command paperlint does not read («…: Lies»), and the PDF names no Title — set \\hypersetup{pdftitle={<the title as plain text>}} and rebuild",
394
+ ),
395
+ err(
396
+ "no \\title in paper.tex, and the PDF names no Title — add \\title{…}, or \\hypersetup{pdftitle={…}}, and rebuild",
397
+ ),
398
+ ok("Info"),
399
+ err(
400
+ "the paper sets \\title 2 times (@0, @40), and the PDF names no Title — which one to send is not guessed: keep one \\title, or set \\hypersetup{pdftitle={<the title as plain text>}} and rebuild",
401
+ ),
402
+ ]);
403
+ });
404
+
405
+ test("new --save, but paperlint.json changed under it: the id is printed with the line to add by hand, exit 1", async () => {
406
+ const config = absolutePath(`${PAPER}/paperlint.json`);
407
+ const cases = [
408
+ [null, "the file is gone"],
409
+ ["{", "not JSON: "],
410
+ ["[1]", "must be a JSON object"],
411
+ ] as const;
412
+ /** `new --save`, with paperlint.json replaced by `swap` (null: removed) once the portal answered. */
413
+ const savedWith = (swap: string | null) => {
414
+ const fs = newFiles({});
415
+ const created = { now: false };
416
+ const portal = fakePortal({});
417
+ return run(
418
+ { sub: "new", save: true },
419
+ {
420
+ files: {
421
+ ...fs,
422
+ readBytes: (p) =>
423
+ created.now && p === config
424
+ ? swap === null
425
+ ? null
426
+ : new TextEncoder().encode(swap)
427
+ : fs.readBytes(p),
428
+ },
429
+ portal: {
430
+ ...portal,
431
+ portalFor: (site, token) => {
432
+ const inner = portal.portalFor(site, token);
433
+ return {
434
+ ...inner,
435
+ create: async (paper, o) => {
436
+ const a = await inner.create(paper, o);
437
+ created.now = true;
438
+ return a;
439
+ },
440
+ };
441
+ },
442
+ },
443
+ },
444
+ );
445
+ };
446
+ const runs = await Promise.all(map(cases, ([swap]) => savedWith(swap)));
447
+ const head = `submission 42 was created (${SITE}/paper/42), but papers/p/paperlint.json was not changed (`;
448
+ const tail = `) — add "submission": { "id": 42 } to the current attempt by hand`;
449
+ expect(
450
+ map(runs, (r, i) => {
451
+ const line = r.errs[0] ?? "";
452
+ return [
453
+ r.code,
454
+ r.errs.length,
455
+ line.startsWith(head),
456
+ line.includes(cases[i]?.[1] ?? "?"),
457
+ line.endsWith(tail),
458
+ ];
459
+ }),
460
+ ).toStrictEqual(map(cases, () => [1, 1, true, true, true]));
461
+ });
462
+
463
+ test("new --save, but paperlint.json cannot be written: the id, its page and the line to add are printed, exit 1", async () => {
464
+ const fs = newFiles({});
465
+ const r = await run(
466
+ { sub: "new", save: true },
467
+ {
468
+ files: {
469
+ ...fs,
470
+ writeAtomic: () => {
471
+ throw new Error("EROFS: read-only file system");
472
+ },
473
+ },
474
+ },
475
+ );
476
+ expect([r.code, r.errs]).toStrictEqual([
477
+ 1,
478
+ [
479
+ `submission 42 was created (${SITE}/paper/42), but papers/p/paperlint.json was not changed (EROFS: read-only file system) — add "submission": { "id": 42 } to the current attempt by hand`,
480
+ ],
481
+ ]);
482
+ });
483
+
484
+ test("new: no paper.tex — the PDF's Title stands in; without one either, the fix is named", async () => {
485
+ const noTex = newFiles({
486
+ tex: null,
487
+ facts: facts({ title: "From The PDF" }),
488
+ });
489
+ const titled = await run(
490
+ { sub: "new", abstract: "abs.txt" },
491
+ {
492
+ files: {
493
+ ...noTex,
494
+ readBytes: (p) =>
495
+ p === "/w/abs.txt"
496
+ ? new TextEncoder().encode("Given.")
497
+ : noTex.readBytes(p),
498
+ },
499
+ },
500
+ );
501
+ expect(titled.code, titled.errs.join("\n")).toBe(0);
502
+ expect(titled.out[1]).toBe("title From The PDF");
503
+ const noAbstract = await run(
504
+ { sub: "new" },
505
+ { files: newFiles({ tex: null, facts: facts({ title: "From The PDF" }) }) },
506
+ );
507
+ expect(noAbstract.errs).toStrictEqual([
508
+ "no paper.tex to read the abstract from — give the abstract with --abstract <file>, a plain-text file",
509
+ ]);
510
+ const untitled = await run(
511
+ { sub: "new" },
512
+ { files: newFiles({ tex: null }) },
513
+ );
514
+ expect(untitled.code).toBe(2);
515
+ expect(untitled.errs).toStrictEqual([
516
+ "no \\title in paper.tex, and the PDF names no Title — add \\title{…}, or \\hypersetup{pdftitle={…}}, and rebuild",
517
+ ]);
518
+ });
519
+
520
+ test("new: --abstract naming no file is refused before anything is sent; run from the paper folder, the next command says `.`", async () => {
521
+ const missing = await run(
522
+ { sub: "new", abstract: "nope.txt" },
523
+ { files: newFiles({}) },
524
+ );
525
+ expect(missing.code).toBe(2);
526
+ expect(missing.errs).toStrictEqual(["--abstract: no file at nope.txt"]);
527
+ expect(missing.seen[0]?.calls).toStrictEqual([]);
528
+ const here = await run(
529
+ { sub: "new", paperDir: "." },
530
+ {
531
+ files: newFiles({}),
532
+ cwd: PAPER,
533
+ portal: fakePortal({
534
+ create: ok({
535
+ httpStatus: 200,
536
+ valid: true,
537
+ changes: [],
538
+ messages: [],
539
+ dryRun: true,
540
+ id: null,
541
+ }),
542
+ }),
543
+ },
544
+ );
545
+ expect(here.code, here.errs.join("\n")).toBe(0);
546
+ expect(here.out).toContain("changes —");
547
+ expect(here.out).toContain(
548
+ " - then mark it submitted: npx paperlint submission update . --submit (a dry run; --save after your yes)",
549
+ );
550
+ });
551
+
552
+ test("a refusal at a place no file of the paper holds is named at paper.tex", async () => {
553
+ const r = await run(
554
+ { sub: "new" },
555
+ {
556
+ files: newFiles({}),
557
+ latex: {
558
+ ...latexReader,
559
+ abstractText: () => ({
560
+ ok: false,
561
+ error: { kind: "unreadable", what: "\\x", at: 1_000_000 },
562
+ }),
563
+ },
564
+ },
565
+ );
566
+ expect(r.errs).toStrictEqual([
567
+ "the abstract, paper.tex: \\x — paperlint sends no text it would have to guess; give the abstract with --abstract <file>, a plain-text file",
568
+ ]);
569
+ });