paperlint 11.0.0 → 11.1.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 (76) hide show
  1. package/dist/adapters/hotcrp/index.d.ts +1 -1
  2. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  3. package/dist/adapters/hotcrp/index.js +1 -1
  4. package/dist/adapters/hotcrp/index.js.map +1 -1
  5. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  6. package/dist/adapters/hotcrp/portal.io.js +17 -2
  7. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  8. package/dist/adapters/hotcrp/response.d.ts +7 -1
  9. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  10. package/dist/adapters/hotcrp/response.js +35 -3
  11. package/dist/adapters/hotcrp/response.js.map +1 -1
  12. package/dist/adapters/hotcrp/site.d.ts +1 -1
  13. package/dist/adapters/hotcrp/site.d.ts.map +1 -1
  14. package/dist/adapters/hotcrp/site.js +1 -2
  15. package/dist/adapters/hotcrp/site.js.map +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +6 -1
  18. package/dist/cli.js.map +1 -1
  19. package/dist/domain/submission-fields.d.ts +77 -0
  20. package/dist/domain/submission-fields.d.ts.map +1 -0
  21. package/dist/domain/submission-fields.js +127 -0
  22. package/dist/domain/submission-fields.js.map +1 -0
  23. package/dist/domain/submission.d.ts +10 -0
  24. package/dist/domain/submission.d.ts.map +1 -1
  25. package/dist/domain/submission.js.map +1 -1
  26. package/dist/lib/paper-config.d.ts.map +1 -1
  27. package/dist/lib/paper-config.js +2 -0
  28. package/dist/lib/paper-config.js.map +1 -1
  29. package/dist/paper-settings.d.ts +5 -0
  30. package/dist/paper-settings.d.ts.map +1 -1
  31. package/dist/paper-settings.js +5 -2
  32. package/dist/paper-settings.js.map +1 -1
  33. package/dist/ports/submission-portal.d.ts +3 -1
  34. package/dist/ports/submission-portal.d.ts.map +1 -1
  35. package/dist/presets.d.ts +3 -0
  36. package/dist/presets.d.ts.map +1 -1
  37. package/dist/presets.js +3 -1
  38. package/dist/presets.js.map +1 -1
  39. package/dist/submission.d.ts +24 -5
  40. package/dist/submission.d.ts.map +1 -1
  41. package/dist/submission.js +140 -50
  42. package/dist/submission.js.map +1 -1
  43. package/dist/tex-requirements.d.ts +3 -0
  44. package/dist/tex-requirements.d.ts.map +1 -1
  45. package/dist/tex-requirements.js +30 -2
  46. package/dist/tex-requirements.js.map +1 -1
  47. package/docs/submission.md +29 -6
  48. package/lib/paper-config.ts +3 -0
  49. package/package.json +1 -1
  50. package/presets/msr.jsonc +66 -0
  51. package/presets/venue-profile.schema.json +41 -0
  52. package/skills/submission-portal/SKILL.md +51 -20
  53. package/skills/submission-portal/SKILL.md.spec.ts +50 -19
  54. package/src/adapters/hotcrp/cassette/README.md +7 -6
  55. package/src/adapters/hotcrp/cassette/get-searchcompletion.json +100 -0
  56. package/src/adapters/hotcrp/index.ts +1 -0
  57. package/src/adapters/hotcrp/portal.io.ts +22 -2
  58. package/src/adapters/hotcrp/portal.test.ts +103 -2
  59. package/src/adapters/hotcrp/response.ts +45 -4
  60. package/src/adapters/hotcrp/site.ts +2 -3
  61. package/src/cli.ts +6 -1
  62. package/src/domain/submission-fields.test.ts +183 -0
  63. package/src/domain/submission-fields.ts +190 -0
  64. package/src/domain/submission.ts +11 -0
  65. package/src/new-paper.test.ts +1 -0
  66. package/src/paper-settings.test.ts +6 -0
  67. package/src/paper-settings.ts +11 -3
  68. package/src/ports/submission-portal.ts +3 -0
  69. package/src/presets.ts +12 -2
  70. package/src/submission-fields-run.test.ts +247 -0
  71. package/src/submission-new.test.ts +4 -4
  72. package/src/submission-update.test.ts +7 -1
  73. package/src/submission.test.ts +1 -1
  74. package/src/submission.ts +207 -54
  75. package/src/tex-requirements.test.ts +21 -0
  76. package/src/tex-requirements.ts +67 -2
@@ -21,7 +21,12 @@ test("update: a dry run by default — the paper's PDF sent, nothing saved, exit
21
21
  [
22
22
  "update",
23
23
  7,
24
- { pdf: { name: "paper.pdf", bytes: PDF }, abstract: null, submit: false },
24
+ {
25
+ pdf: { name: "paper.pdf", bytes: PDF },
26
+ abstract: null,
27
+ submit: false,
28
+ fields: {},
29
+ },
25
30
  { save: false },
26
31
  ],
27
32
  ]);
@@ -58,6 +63,7 @@ test("update --save --submit --abstract --pdf: all of it sent, and said to be sa
58
63
  pdf: { name: "other.pdf", bytes: PDF },
59
64
  abstract: "The abstract.",
60
65
  submit: true,
66
+ fields: {},
61
67
  },
62
68
  { save: true },
63
69
  ],
@@ -239,7 +239,7 @@ test("usage: refused before anything is read, exit 2", async () => {
239
239
 
240
240
  test("usage: a subcommand is required, one paper folder, update-only flags refused on show", () => {
241
241
  expect(usageProblem({ ...ARGS, sub: undefined }) ?? "").toMatch(
242
- /needs a subcommand: new or show or update — got ``/,
242
+ /needs a subcommand: new or show or update or topics — got ``/,
243
243
  );
244
244
  expect(usageProblem({ ...ARGS, sub: "upload" }) ?? "").toMatch(
245
245
  /got `upload`/,
package/src/submission.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `paperlint submission show|update [paper-dir]` — read and change a paper's submission on the
3
- * venue's portal, through the portal's API.
2
+ * `paperlint submission new|show|update|topics [paper-dir]` — create, read and change a paper's
3
+ * submission on the venue's portal, through the portal's API.
4
4
  *
5
5
  * The command is named by the job, not by the portal: WHERE the submission lives is data. The venue
6
6
  * preset declares the portal (`"portal": { "kind": "hotcrp", "url": … }`), the paper's
@@ -11,6 +11,12 @@
11
11
  * whether it is the local `paper.pdf`, by sha256
12
12
  * update send the PDF (and, if asked, an abstract, or "submitted"). A DRY RUN unless `--save`:
13
13
  * the portal checks the change and keeps nothing
14
+ * topics the topics the portal offers, against the ones the venue preset records
15
+ *
16
+ * `new` and `update` also send the paper's `fields` — its answers to the venue's own form fields
17
+ * (topics, PC conflicts, …), checked against the preset's `submission_fields` before anything is
18
+ * sent (`src/domain/submission-fields.ts`). A checkbox field the portal did not take is said, and
19
+ * fails the command: HotCRP keeps an option it does not know as nothing, without an error.
14
20
  *
15
21
  * 🔴 THE TOKEN IS AN INPUT, AND ONLY AN INPUT. It comes from the environment (the root reads it), is
16
22
  * handed to the adapter, and never appears in anything printed here.
@@ -33,6 +39,12 @@ import {
33
39
  type SupportedPortal,
34
40
  type UpdateOutcome,
35
41
  } from "./domain/submission.ts";
42
+ import {
43
+ droppedFields,
44
+ parsePaperFields,
45
+ type FieldValues,
46
+ type VenueFields,
47
+ } from "./domain/submission-fields.ts";
36
48
  import type { Files } from "./ports/files.ts";
37
49
  import type {
38
50
  PortalSite,
@@ -111,7 +123,7 @@ export function tokenRefusedLines(
111
123
  return [why, ...tokenSteps(portal, site, variable)];
112
124
  }
113
125
 
114
- export const SUBCOMMANDS = ["new", "show", "update"] as const;
126
+ export const SUBCOMMANDS = ["new", "show", "update", "topics"] as const;
115
127
  export type Subcommand = (typeof SUBCOMMANDS)[number];
116
128
  const isSubcommand = (s: string | undefined): s is Subcommand =>
117
129
  SUBCOMMANDS.some((x) => x === s);
@@ -158,6 +170,15 @@ export interface SubmissionTarget {
158
170
  readonly config: string;
159
171
  /** The authors the paper declares for the portal; null when it declares none. */
160
172
  readonly authors: readonly Author[] | null;
173
+ /**
174
+ * The paper's answers to the venue's own form fields, checked against `venueFields`: only `new`
175
+ * and `update` send them, so only they stop on a problem — `show` and `topics` read past it.
176
+ */
177
+ readonly answers: Result<FieldValues, string>;
178
+ /** The answers to send: `answers`' value once a sending subcommand checked it; empty otherwise. */
179
+ readonly fields: FieldValues;
180
+ /** The venue's own form fields, as its preset records them; null when it records none. */
181
+ readonly venueFields: VenueFields | null;
161
182
  /** The cycle attempt is accepted: a new submission belongs to a new cycle. */
162
183
  readonly accepted: boolean;
163
184
  /** The paper's built PDF, absolute. */
@@ -182,35 +203,55 @@ const noPresetLine = (
182
203
  ? `${configOf(paperDir)}: no "extends" — the venue preset it names declares the portal ("portal": { "kind": …, "url": … })`
183
204
  : `${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`;
184
205
 
206
+ /** The paper's `fields`, checked against its venue preset's form, or the line naming what is wrong. */
207
+ function fieldsOf(
208
+ paperDir: string,
209
+ p: Extract<PaperPreset, { readonly kind: "resolved" }>,
210
+ ): Result<FieldValues, string> {
211
+ const fields = parsePaperFields(
212
+ p.settings.fields,
213
+ p.preset.submissionFields,
214
+ p.preset.chain.join(", "),
215
+ );
216
+ return fields.ok ? fields : err(`${configOf(paperDir)}: ${fields.error}`);
217
+ }
218
+
219
+ /** The venue preset's portal, if it declares one that paperlint can talk to; else the line to act on. */
220
+ function portalOfPreset(
221
+ p: Extract<PaperPreset, { readonly kind: "resolved" }>,
222
+ ): Result<SupportedPortal, string> {
223
+ const declared = p.preset.portal;
224
+ return declared === null
225
+ ? err(
226
+ `the venue preset declares no "portal" (read: ${p.preset.chain.join(", ")}) — add "portal": { "kind": "hotcrp", "url": "<the site>" } to the venue's preset, or upload by hand`,
227
+ )
228
+ : supportedPortal(declared);
229
+ }
230
+
185
231
  /** A resolved paper → its portal and submission, or the line naming the file and key to set. */
186
232
  function targetOf(
187
233
  paperDir: string,
188
234
  p: Extract<PaperPreset, { readonly kind: "resolved" }>,
189
235
  ): Result<SubmissionTarget, string> {
190
- const declared = p.preset.portal;
191
- if (declared === null)
192
- return err(
193
- `the venue preset declares no "portal" (read: ${p.preset.chain.join(", ")}) — add "portal": { "kind": "hotcrp", "url": "<the site>" } to the venue's preset, or upload by hand`,
194
- );
195
- const portal = supportedPortal(declared);
236
+ const portal = portalOfPreset(p);
196
237
  if (!portal.ok) return portal;
197
- const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
198
238
  const identity = p.preset.identity;
199
- const call = identity.type === "venue" ? identity.url : null;
200
239
  const own =
201
240
  p.settings.cycles === null
202
241
  ? []
203
242
  : currentDeadlines(p.settings.cycles.current);
204
- const deadlines = deadlinesInForce(own, p.preset.deadlines);
205
243
  return ok({
206
244
  portal: portal.value,
207
245
  id: p.settings.submission?.id ?? null,
208
246
  config: configOf(paperDir),
209
247
  authors: p.settings.authors,
248
+ answers: fieldsOf(paperDir, p),
249
+ fields: {},
250
+ venueFields: p.preset.submissionFields,
210
251
  accepted: p.settings.cycles?.current.kind === "accepted",
211
- pdf,
212
- call,
213
- deadlines,
252
+ pdf: join(paperDir, p.settings.pdf ?? "paper.pdf"),
253
+ call: identity.type === "venue" ? identity.url : null,
254
+ deadlines: deadlinesInForce(own, p.preset.deadlines),
214
255
  });
215
256
  }
216
257
 
@@ -312,6 +353,28 @@ export function showLines(
312
353
  export const checkPdfLine = (shown: string, call: string | null): string =>
313
354
  `before you rely on it, open ${shown} yourself and check it against ${call === null ? "the venue's call for papers" : `the call for papers, ${call}`}: the page count and what counts toward the limit, the template and its class options, anonymity, and that every figure, table and reference renders — paperlint passing is not the venue accepting the format`;
314
355
 
356
+ /** The fields the paper sent, one line each, as the portal will get them. Pure. */
357
+ export const fieldLines = (fields: FieldValues): readonly string[] =>
358
+ Object.entries(fields).map(
359
+ ([key, value]) => `field ${key}: ${JSON.stringify(value)}`,
360
+ );
361
+
362
+ /** The checkbox fields the portal did not take, said; none when it took them all. Pure. */
363
+ export function droppedLines(
364
+ fields: FieldValues,
365
+ venue: VenueFields | null,
366
+ o: UpdateOutcome,
367
+ ): readonly string[] {
368
+ const dropped = droppedFields(fields, venue, {
369
+ changes: o.changes,
370
+ messageFields: o.messages.map((m) => m.field),
371
+ });
372
+ return dropped.map(
373
+ (key) =>
374
+ `NOT TAKEN ${key}: the portal kept none of ${JSON.stringify(fields[key])} — an option it does not know is dropped without an error; \`paperlint submission topics\` lists what it offers`,
375
+ );
376
+ }
377
+
315
378
  /** `update`'s report. Pure. */
316
379
  export function updateLines(
317
380
  o: UpdateOutcome,
@@ -346,6 +409,12 @@ type RunDeps = SubmissionDeps & {
346
409
  readonly refused: (f: PortalFailure) => readonly string[];
347
410
  };
348
411
 
412
+ const logAll = (lines: readonly string[], deps: RunDeps): void => {
413
+ lines.forEach((l) => {
414
+ deps.log(l);
415
+ });
416
+ };
417
+
349
418
  /** A failure printed — the portal's words, then what to do about the token if it was the token — exit 1. */
350
419
  function failed(f: PortalFailure, deps: RunDeps): number {
351
420
  [failureText(f), ...deps.refused(f)].forEach((l) => {
@@ -395,15 +464,17 @@ function abstractOf(
395
464
  : ok(new TextDecoder().decode(bytes).trim());
396
465
  }
397
466
 
398
- /** What `update` sends: the PDF's bytes, the abstract's text, and whether to mark it submitted. */
467
+ /** What `update` sends: the PDF's bytes, the abstract's text, whether to mark it submitted, the fields. */
399
468
  const changeOf = (
400
469
  a: SubmissionArgs & { readonly pdfPath: string },
401
470
  bytes: Uint8Array,
402
471
  abstract: string | null,
472
+ fields: FieldValues,
403
473
  ) => ({
404
474
  pdf: { name: basename(a.pdfPath), bytes },
405
475
  abstract,
406
476
  submit: a.submit,
477
+ fields,
407
478
  });
408
479
 
409
480
  /** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
@@ -422,18 +493,16 @@ async function runUpdate(
422
493
  );
423
494
  const abstract = abstractOf(a, deps);
424
495
  if (!abstract.ok) return (deps.err(abstract.error), 2);
425
- deps.log(checkPdfLine(shown, t.call));
426
- const r = await portal.update(t.id, changeOf(a, bytes, abstract.value), {
427
- save: a.save,
428
- });
496
+ logAll([checkPdfLine(shown, t.call), ...fieldLines(t.fields)], deps);
497
+ const change = changeOf(a, bytes, abstract.value, t.fields);
498
+ const r = await portal.update(t.id, change, { save: a.save });
429
499
  if (!r.ok) return failed(r.error, deps);
430
500
  const local = { shown, sha256: sha256Hex(bytes) };
431
- updateLines(r.value, local, a.save).forEach((l) => {
432
- deps.log(l);
433
- });
501
+ const dropped = droppedLines(t.fields, t.venueFields, r.value);
502
+ logAll([...updateLines(r.value, local, a.save), ...dropped], deps);
434
503
  const fine =
435
504
  r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
436
- return fine ? 0 : 1;
505
+ return fine && dropped.length === 0 ? 0 : 1;
437
506
  }
438
507
 
439
508
  // ── new ──────────────────────────────────────────────────────────────────────────────
@@ -541,31 +610,37 @@ const noReadableTitle = (text: string): string =>
541
610
  : `the \\title in ${MAIN} uses a command paperlint does not read («${text.replaceAll(OPAQUE, "…")}»), and the PDF names no Title — set \\hypersetup{pdftitle={<the title as plain text>}} and rebuild`;
542
611
 
543
612
  /** What `new` will send, as the author reads it before saying yes. Pure. */
544
- export function newLines(
545
- title: string,
546
- abstract: string,
547
- authors: readonly Author[],
548
- ): readonly string[] {
613
+ export function newLines(p: NewSubmission): readonly string[] {
549
614
  return [
550
- `title ${title}`,
551
- ...authors.map(
615
+ `title ${p.title}`,
616
+ ...p.authors.map(
552
617
  (a, i) =>
553
618
  `author ${String(i + 1).padEnd(6)} ${a.name} <${a.email}>${a.affiliation === null ? "" : `, ${a.affiliation}`}`,
554
619
  ),
555
- `abstract ${String(wordCount(abstract))} words:`,
556
- ` ${abstract}`,
620
+ `abstract ${String(wordCount(p.abstract))} words:`,
621
+ ` ${p.abstract}`,
622
+ ...fieldLines(p.fields),
557
623
  ];
558
624
  }
559
625
 
560
- /** What the author does on the site after `new`: what paperlint does not send. Pure. */
626
+ /** What is left after `new`: the venue's fields the paper did not answer, what only the site takes, the submit. Pure. */
561
627
  export function onTheSiteLines(
562
628
  site: PortalSite,
629
+ t: Pick<SubmissionTarget, "fields" | "venueFields">,
563
630
  id: number | null,
564
631
  paperArg: string,
565
632
  ): readonly string[] {
633
+ const sent = Object.keys(t.fields);
634
+ const unanswered = Object.keys(t.venueFields?.fields ?? {}).filter(
635
+ (k) => !sent.includes(k),
636
+ );
566
637
  return [
567
- `do on the site, ${site.submissionPage(id)} — paperlint sends only the title, the abstract, the authors and the PDF:`,
568
- ...site.siteOnly.map((f) => ` - ${f}`),
638
+ `still to do for ${site.submissionPage(id)} — paperlint sent the title, the abstract, the authors and the PDF${sent.length ? `, and ${sent.join(", ")}` : ""}:`,
639
+ ...unanswered.map(
640
+ (k) =>
641
+ ` - answer ${k} in the paper's paperlint.json "fields" (the venue preset records it), then: npx paperlint submission update ${paperArg} (a dry run; --save after your yes)`,
642
+ ),
643
+ ...site.siteOnly.map((f) => ` - on the site: ${f}`),
569
644
  ` - then mark it submitted: npx paperlint submission update ${paperArg} --submit (a dry run; --save after your yes)`,
570
645
  ];
571
646
  }
@@ -695,6 +770,7 @@ function newSubmissionOf(
695
770
  ...text.value,
696
771
  authors: authors.value,
697
772
  pdf: { name: basename(a.pdfPath), bytes: pdf },
773
+ fields: t.fields,
698
774
  };
699
775
  return ok({ paper, shown });
700
776
  }
@@ -721,11 +797,18 @@ function recordCreated(
721
797
  return 0;
722
798
  }
723
799
 
724
- const logAll = (lines: readonly string[], deps: RunDeps): void => {
725
- lines.forEach((l) => {
726
- deps.log(l);
727
- });
728
- };
800
+ /** After a creation the portal took: what is still to do, and the id recorded when it saved one. */
801
+ function afterCreated(
802
+ t: SubmissionTarget,
803
+ id: number | null,
804
+ a: SubmissionArgs & { readonly paperDir: string },
805
+ deps: RunDeps,
806
+ ): number {
807
+ const paperArg =
808
+ a.paperDir === deps.cwd ? "." : shownPath(deps.cwd, a.paperDir);
809
+ logAll(onTheSiteLines(deps.portalSite, t, id, paperArg), deps);
810
+ return id === null ? 0 : recordCreated(t, id, paperArg, deps);
811
+ }
729
812
 
730
813
  /**
731
814
  * `new`: create the paper's submission — title and abstract from the build, the authors the paper
@@ -741,17 +824,85 @@ async function runNew(
741
824
  if (!n.ok) return (deps.err(n.error), 2);
742
825
  const { paper, shown } = n.value;
743
826
  deps.log(checkPdfLine(shown, t.call));
744
- logAll(newLines(paper.title, paper.abstract, paper.authors), deps);
827
+ logAll(newLines(paper), deps);
745
828
  const r = await portal.create(paper, { save: a.save });
746
829
  if (!r.ok) return failed(r.error, deps);
747
- logAll(createLines(r.value, a.save), deps);
830
+ const dropped = droppedLines(t.fields, t.venueFields, r.value);
831
+ logAll([...createLines(r.value, a.save), ...dropped], deps);
748
832
  const fine =
749
833
  r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
750
834
  if (!fine || (a.save && r.value.id === null)) return 1;
751
- const paperArg =
752
- a.paperDir === deps.cwd ? "." : shownPath(deps.cwd, a.paperDir);
753
- logAll(onTheSiteLines(deps.portalSite, r.value.id, paperArg), deps);
754
- return r.value.id === null ? 0 : recordCreated(t, r.value.id, paperArg, deps);
835
+ const recorded = afterCreated(t, r.value.id, a, deps);
836
+ return dropped.length === 0 ? recorded : 1;
837
+ }
838
+
839
+ /** The subcommands that run before the paper has a submission id: `new` creates it, `topics` needs none. */
840
+ const NEEDS_NO_ID: readonly string[] = ["new", "topics"];
841
+ /** The subcommands that send the paper's answers to the venue's fields. */
842
+ const SENDS_FIELDS: readonly string[] = ["new", "update"];
843
+
844
+ /**
845
+ * What the subcommand needs before a token is looked up: the submission id (`show`, `update`) and
846
+ * the paper's checked answers (`new`, `update`) — or the line naming what to fix.
847
+ */
848
+ function readyFor(
849
+ sub: string | undefined,
850
+ t: SubmissionTarget,
851
+ ): Result<
852
+ { readonly id: number | null; readonly fields: FieldValues },
853
+ string
854
+ > {
855
+ const id = NEEDS_NO_ID.some((s) => s === sub) ? ok(null) : idOf(t);
856
+ if (!id.ok) return id;
857
+ const fields = SENDS_FIELDS.some((s) => s === sub) ? t.answers : ok({});
858
+ return fields.ok ? ok({ id: id.value, fields: fields.value }) : fields;
859
+ }
860
+
861
+ /** The checkbox field the preset records the portal's topics under: the one named `topics`. */
862
+ const TOPICS = "topics";
863
+
864
+ /** `topics`' report: each topic the portal offers against the preset's options, and whether they agree. Pure. */
865
+ export function topicsReport(
866
+ t: Pick<SubmissionTarget, "portal" | "venueFields">,
867
+ offered: readonly string[],
868
+ ): { readonly lines: readonly string[]; readonly agrees: boolean } {
869
+ const declared = t.venueFields?.fields[TOPICS];
870
+ const head = `topics on ${t.portal.url}: ${String(offered.length)}`;
871
+ if (t.venueFields === null || declared?.kind !== "checkboxes")
872
+ return {
873
+ lines: [
874
+ head,
875
+ ...offered.map((n) => ` ${n}`),
876
+ `the venue preset records no "submission_fields" with "topics" — record these, spelled as listed, before a paper can send any`,
877
+ ],
878
+ agrees: false,
879
+ };
880
+ const recorded = declared.options;
881
+ const extra = offered.filter((n) => !recorded.includes(n));
882
+ const missing = recorded.filter((n) => !offered.includes(n));
883
+ return {
884
+ lines: [
885
+ `${head}; the preset records ${String(recorded.length)} (read ${t.venueFields.read})`,
886
+ ...offered.map(
887
+ (n) => ` ${recorded.includes(n) ? "✓" : "+ not in the preset:"} ${n}`,
888
+ ),
889
+ ...missing.map((n) => ` − in the preset, not offered: ${n}`),
890
+ ],
891
+ agrees: extra.length === 0 && missing.length === 0,
892
+ };
893
+ }
894
+
895
+ /** `topics`: what the portal offers, and whether the preset records the same — exit 1 when not. */
896
+ async function runTopics(
897
+ t: SubmissionTarget,
898
+ portal: SubmissionPortal,
899
+ deps: RunDeps,
900
+ ): Promise<number> {
901
+ const r = await portal.options();
902
+ if (!r.ok) return failed(r.error, deps);
903
+ const report = topicsReport(t, r.value.topics);
904
+ logAll(report.lines, deps);
905
+ return report.agrees ? 0 : 1;
755
906
  }
756
907
 
757
908
  //** The flags that change something, as given. */
@@ -769,8 +920,8 @@ export function usageProblem(a: SubmissionArgs): string | null {
769
920
  if (a.extra.length > 0)
770
921
  return `\`submission ${a.sub}\` takes one paper folder — got also \`${a.extra.join(" ")}\``;
771
922
  const writing = writingFlags(a);
772
- if (a.sub === "show" && writing.length > 0)
773
- return `${writing.join(", ")}: only \`submission new\` and \`submission update\` change anything; \`show\` reads`;
923
+ if ((a.sub === "show" || a.sub === "topics") && writing.length > 0)
924
+ return `${writing.join(", ")}: only \`submission new\` and \`submission update\` change anything; \`${a.sub}\` reads`;
774
925
  return a.sub === "new" && a.submit
775
926
  ? "--submit: `submission new` creates the submission; mark it submitted with `submission update --submit` once `show` says the portal holds this build"
776
927
  : null;
@@ -805,8 +956,8 @@ export async function runSubmission(
805
956
  const paperDir = resolve(deps.cwd, a.paperDir);
806
957
  const t = submissionTarget(paperDir, deps.presets);
807
958
  if (!t.ok) return (deps.err(t.error), 2);
808
- const id = a.sub === "new" ? ok(null) : idOf(t.value);
809
- if (!id.ok) return (deps.err(id.error), 2);
959
+ const ready = readyFor(a.sub, t.value);
960
+ if (!ready.ok) return (deps.err(ready.error), 2);
810
961
  const site = deps.site(t.value.portal);
811
962
  const found = lookUpToken(tokenNames(site), deps.env);
812
963
  if (!found.ok) return tokenMissing(t.value.portal, site, found.error, deps);
@@ -817,9 +968,11 @@ export async function runSubmission(
817
968
  portalSite: site,
818
969
  refused: (f) => tokenRefusedLines(t.value.portal, site, f),
819
970
  };
820
- if (id.value === null)
821
- return runNew(t.value, portal, { ...a, pdfPath, paperDir }, withToken);
822
- const target = { ...t.value, id: id.value };
971
+ const sending = { ...t.value, fields: ready.value.fields };
972
+ if (a.sub === "topics") return runTopics(sending, portal, withToken);
973
+ if (ready.value.id === null)
974
+ return runNew(sending, portal, { ...a, pdfPath, paperDir }, withToken);
975
+ const target = { ...sending, id: ready.value.id };
823
976
  return a.sub === "show"
824
977
  ? runShow(target, portal, pdfPath, withToken)
825
978
  : runUpdate(target, portal, { ...a, pdfPath }, withToken);
@@ -54,6 +54,27 @@ test("a venue without the link to its call for papers is refused at load, saying
54
54
  );
55
55
  });
56
56
 
57
+ test("🔴 submission_fields naming a key the submission object carries itself is refused, naming it", () => {
58
+ // Guards: a field `submission` spread over the PDF's `content_file` would drop the upload.
59
+ const preset = (key: string) => ({
60
+ type: "venue",
61
+ name: "X",
62
+ url: "https://example.org/cfp",
63
+ extends: "paperlint:acm-sigconf",
64
+ submission_fields: {
65
+ source: "form",
66
+ url: "https://example.org/paper/new",
67
+ read: "2026-10-20",
68
+ fields: { [key]: { kind: "text" } },
69
+ },
70
+ });
71
+ assert.equal(
72
+ refusalOf("v.jsonc", preset("submission")),
73
+ 'v.jsonc: "submission_fields" names "submission", which paperlint sends itself (object, pid, title, abstract, authors, submission, status) — a field of that name would overwrite it',
74
+ );
75
+ assert.equal(refusalOf("v.jsonc", preset("topics")), "");
76
+ });
77
+
57
78
  test("a template family with a venue's name and url is refused, naming each key", () => {
58
79
  assert.equal(
59
80
  refusalOf("f.jsonc", {
@@ -36,6 +36,12 @@ import {
36
36
  } from "./domain/talk.ts";
37
37
  import type { VenuePortal } from "./domain/submission.ts";
38
38
  import type { Reading } from "./domain/deadline.ts";
39
+ import {
40
+ RESERVED_FIELD_KEYS,
41
+ reservedKey,
42
+ type FieldKind,
43
+ type VenueFields,
44
+ } from "./domain/submission-fields.ts";
39
45
 
40
46
  /** CTAN package name → the names that prove it is installed. */
41
47
  export type PackageProofs = Readonly<Record<string, readonly string[]>>;
@@ -222,6 +228,8 @@ export interface PresetFile {
222
228
  readonly portal: VenuePortal | null;
223
229
  /** The edition's deadlines as they were read; null when the file names none (a child's list replaces its parent's). */
224
230
  readonly deadlines: readonly Reading[] | null;
231
+ /** The fields the edition's submission form asks, as read; null when the file records none. */
232
+ readonly submissionFields: VenueFields | null;
225
233
  }
226
234
 
227
235
  type KindsJson = Readonly<
@@ -280,6 +288,54 @@ interface PresetBodyJson {
280
288
  readonly talk?: VenueTalkJson;
281
289
  readonly portal?: VenuePortal;
282
290
  readonly deadlines?: readonly Reading[];
291
+ readonly submission_fields?: SubmissionFieldsJson;
292
+ }
293
+
294
+ /** A preset's `submission_fields` after the schema accepted it. */
295
+ interface SubmissionFieldsJson {
296
+ readonly source: VenueFields["source"];
297
+ readonly url: string;
298
+ readonly read: string;
299
+ readonly fields: Readonly<
300
+ Record<
301
+ string,
302
+ {
303
+ readonly kind: FieldKind;
304
+ readonly required?: boolean;
305
+ readonly label?: string;
306
+ readonly options?: readonly string[];
307
+ }
308
+ >
309
+ >;
310
+ }
311
+
312
+ /** A preset's `submission_fields`, or null when it records none; a reserved field name is refused. */
313
+ function submissionFieldsOf(
314
+ j: SubmissionFieldsJson | undefined,
315
+ file: string,
316
+ ): VenueFields | null {
317
+ if (j === undefined) return null;
318
+ const reserved = reservedKey(Object.keys(j.fields));
319
+ if (reserved !== null)
320
+ throw new Error(
321
+ `${file}: "submission_fields" names "${reserved}", which paperlint sends itself (${RESERVED_FIELD_KEYS.join(", ")}) — a field of that name would overwrite it`,
322
+ );
323
+ return {
324
+ source: j.source,
325
+ url: j.url,
326
+ read: j.read,
327
+ fields: Object.fromEntries(
328
+ Object.entries(j.fields).map(([key, f]) => [
329
+ key,
330
+ {
331
+ kind: f.kind,
332
+ required: f.required ?? false,
333
+ label: f.label ?? null,
334
+ options: f.options ?? [],
335
+ },
336
+ ]),
337
+ ),
338
+ };
283
339
  }
284
340
 
285
341
  /** An optional field as the typed preset holds it: absent is null. */
@@ -424,6 +480,16 @@ function deadlinesOf(
424
480
  }));
425
481
  }
426
482
 
483
+ /** A preset's blocks about its portal: where, the deadlines read there, the form's own fields. */
484
+ const portalBlocks = (
485
+ j: PresetJson,
486
+ file: string,
487
+ ): Pick<PresetFile, "portal" | "deadlines" | "submissionFields"> => ({
488
+ portal: portalOf(j.portal),
489
+ deadlines: deadlinesOf(j.deadlines, file),
490
+ submissionFields: submissionFieldsOf(j.submission_fields, file),
491
+ });
492
+
427
493
  /**
428
494
  * The text of one preset file → the typed file, or an Error naming every problem. The ONE parser
429
495
  * of a preset: the toolchain reads its `tex`, the venue rules its `format`, the config its `rules`.
@@ -458,8 +524,7 @@ export function parsePreset(
458
524
  rules: j.rules ?? {},
459
525
  registerAnchors: anchorsOf(j.register),
460
526
  talk: j.talk === undefined ? null : venueTalkOf(j.talk),
461
- portal: portalOf(j.portal),
462
- deadlines: deadlinesOf(j.deadlines, file),
527
+ ...portalBlocks(j, file),
463
528
  };
464
529
  }
465
530