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
@@ -0,0 +1,100 @@
1
+ {
2
+ "ok": true,
3
+ "searchcompletion": [
4
+ "has:submission",
5
+ "has:abstract",
6
+ "abstract:{text}",
7
+ "has:response",
8
+ "has:draftresponse",
9
+ "topic:\"DS/ML/AI Technique\"",
10
+ "topic:\"DS/ML/AI Technique: Classical machine learning (e.g., regression, SVMs, nearest neighbour, decision trees, PCA, naive Bayes classifier, and k-means clustering)\"",
11
+ "topic:\"DS/ML/AI Technique: Convolutional neural networks\"",
12
+ "topic:\"DS/ML/AI Technique: LLM or other foundation models (e.g., large vision model)\"",
13
+ "topic:\"DS/ML/AI Technique: Reinforcement learning\"",
14
+ "topic:\"DS/ML/AI Technique: Transformers\"",
15
+ "topic:\"Performance analysis and testing\"",
16
+ "topic:\"Research Method\"",
17
+ "topic:\"Research Method: Controlled experiments with human subjects (e.g., A/B tests, lab studies)\"",
18
+ "topic:\"Research Method: Qualitative analysis (e.g., thematic analysis, grounded theory)\"",
19
+ "topic:\"Research Method: Statistical analysis and modelling\"",
20
+ "topic:\"Research Method: Survey design and analysis\"",
21
+ "topic:\"SE Topic\"",
22
+ "topic:\"SE Topic: AI for software engineering\"",
23
+ "topic:\"SE Topic: API design and evolution\"",
24
+ "topic:\"SE Topic: App store analysis\"",
25
+ "topic:\"SE Topic: Cloud-based and distributed systems\"",
26
+ "topic:\"SE Topic: Debugging, fault localization, and fault prediction\"",
27
+ "topic:\"SE Topic: DevOps and release engineering\"",
28
+ "topic:\"SE Topic: Distributed and collaborative software engineering\"",
29
+ "topic:\"SE Topic: Energy profiling\"",
30
+ "topic:\"SE Topic: Ethical concerns (bias, fairness, explainability, etc.)\"",
31
+ "topic:\"SE Topic: Human aspects\"",
32
+ "topic:\"SE Topic: Legal aspects (licenses, copyright, etc.)\"",
33
+ "topic:\"SE Topic: Privacy and security\"",
34
+ "topic:\"SE Topic: Program comprehension\"",
35
+ "topic:\"SE Topic: Recommender systems\"",
36
+ "topic:\"SE Topic: Software development processes\"",
37
+ "topic:\"SE Topic: Software ecosystems\"",
38
+ "topic:\"SE Topic: Software engineering for AI\"",
39
+ "topic:\"SE Topic: Software maintenance and evolution\"",
40
+ "topic:\"SE Topic: Software measurement and analytics\"",
41
+ "topic:\"SE Topic: Software quality assurance\"",
42
+ "topic:\"SE Topic: Source / test code\"",
43
+ "topic:\"SE Topic: Version control history logs\"",
44
+ "topic:\"Software repository\"",
45
+ "topic:\"Software repository: App stores\"",
46
+ "topic:\"Software repository: Archived communications (mailing lists, Gitter/Slack/Discord channels, etc.)\"",
47
+ "topic:\"Software repository: Build / CI logs\"",
48
+ "topic:\"Software repository: Code reviews\"",
49
+ "topic:\"Software repository: Execution traces and logs\"",
50
+ "topic:\"Software repository: Human interaction data\"",
51
+ "topic:\"Software repository: Issue trackers / defect repositories\"",
52
+ "topic:\"Software repository: Package registries / software releases\"",
53
+ "topic:\"Software repository: Q&A websites\"",
54
+ "show:({formula})",
55
+ "hide:({formula})",
56
+ "show:abstract",
57
+ "hide:abstract",
58
+ "show:ACMCorAut",
59
+ "hide:ACMCorAut",
60
+ "show:acmpagecount",
61
+ "hide:acmpagecount",
62
+ "show:authors",
63
+ "hide:authors",
64
+ "show:commenters",
65
+ "hide:commenters",
66
+ "show:conflict",
67
+ "hide:conflict",
68
+ "show:conflict:{user}",
69
+ "hide:conflict:{user}",
70
+ "show:decision",
71
+ "hide:decision",
72
+ "show:graph({formula})",
73
+ "hide:graph({formula})",
74
+ "show:id",
75
+ "hide:id",
76
+ "show:options",
77
+ "hide:options",
78
+ "show:pagecount",
79
+ "hide:pagecount",
80
+ "show:pcconf",
81
+ "hide:pcconf",
82
+ "show:revtype",
83
+ "hide:revtype",
84
+ "show:shuffle",
85
+ "hide:shuffle",
86
+ "show:status",
87
+ "hide:status",
88
+ "show:timestamp",
89
+ "hide:timestamp",
90
+ "show:title",
91
+ "hide:title",
92
+ "show:topics",
93
+ "hide:topics",
94
+ "show:wordcount",
95
+ "hide:wordcount",
96
+ "show:facets",
97
+ "show:statistics",
98
+ "show:rownumbers"
99
+ ]
100
+ }
@@ -11,6 +11,7 @@ export {
11
11
  } from "./portal.io.ts";
12
12
  export {
13
13
  parseCreate,
14
+ parseOptions,
14
15
  parseShow,
15
16
  parseUpdate,
16
17
  sha256OfHash,
@@ -12,7 +12,15 @@
12
12
  * Create: the same `POST`, with `p=new` and `"pid": "new"` in the object β€” Β«Setting `p=new` will
13
13
  * create a new submission; the response will contain the chosen submission IDΒ»
14
14
  * (`devel/apidoc/submissions.md`, `post /paper`, in kohler/hotcrp). The object carries the title, the
15
- * abstract, the authors and the PDF; nothing else is sent.
15
+ * abstract, the authors and the PDF, and the venue's own fields the paper answers.
16
+ *
17
+ * Fields: a change and a creation carry the paper's `fields` as submission-object keys, as checked
18
+ * against the venue preset's `submission_fields` (`topics`: a list of `"Group: Topic"`; measured
19
+ * 2026-10-20, a bare `"Topic"` is dropped without a word).
20
+ *
21
+ * Options: `GET /api/searchcompletion` β€” the search box's completions, which an author's token may
22
+ * read; they name every topic as `topic:"<name>"`. (`/api/settings`, which describes the form,
23
+ * answers "Permission error" to an author.)
16
24
  *
17
25
  * πŸ”΄ THE TOKEN goes in the `Authorization` header and nowhere else: not into a URL, a message, or an
18
26
  * error. A failure is described by HTTP status, HotCRP's own messages, or the network error's text.
@@ -26,7 +34,12 @@ import type {
26
34
  SubmissionChange,
27
35
  } from "../../domain/submission.ts";
28
36
  import type { SubmissionPortal } from "../../ports/submission-portal.ts";
29
- import { parseCreate, parseShow, parseUpdate } from "./response.ts";
37
+ import {
38
+ parseCreate,
39
+ parseOptions,
40
+ parseShow,
41
+ parseUpdate,
42
+ } from "./response.ts";
30
43
 
31
44
  /**
32
45
  * The form field the PDF travels in, and the name the `json` object's `content_file` refers to it
@@ -91,6 +104,7 @@ export function changeObject(
91
104
  ...(c.pdf === null ? {} : { submission: { content_file: CONTENT_FIELD } }),
92
105
  ...(c.abstract === null ? {} : { abstract: c.abstract }),
93
106
  ...(c.submit ? { status: "submitted" } : {}),
107
+ ...c.fields,
94
108
  };
95
109
  }
96
110
 
@@ -112,6 +126,7 @@ export function createObject(
112
126
  abstract: p.abstract,
113
127
  authors: p.authors.map(authorObject),
114
128
  submission: { content_file: CONTENT_FIELD },
129
+ ...p.fields,
115
130
  };
116
131
  }
117
132
 
@@ -170,5 +185,10 @@ export function hotcrpPortal(o: HotcrpOptions): SubmissionPortal {
170
185
  const a = await post(o, { p: "new", object, pdf: paper.pdf, save });
171
186
  return a.ok ? parseCreate(a.value.status, a.value.body) : a;
172
187
  },
188
+ async options() {
189
+ const url = `${o.url}/api/searchcompletion`;
190
+ const a = await call(url, { method: "GET" }, o.token, READ_TIMEOUT_MS);
191
+ return a.ok ? parseOptions(a.value.status, a.value.body) : a;
192
+ },
173
193
  };
174
194
  }
@@ -24,6 +24,7 @@ import {
24
24
  createObject,
25
25
  hotcrpPortal,
26
26
  parseCreate,
27
+ parseOptions,
27
28
  parseShow,
28
29
  parseUpdate,
29
30
  sha256OfHash,
@@ -79,7 +80,12 @@ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
79
80
  */
80
81
  /** A recorded HotCRP answer (`cassette/README.md`), as the text it was sent as. */
81
82
  function cassette(
82
- name: "get-paper" | "get-noauth" | "post-dot" | "post-ok",
83
+ name:
84
+ | "get-paper"
85
+ | "get-noauth"
86
+ | "post-dot"
87
+ | "post-ok"
88
+ | "get-searchcompletion",
83
89
  ): string {
84
90
  return readFileSync(
85
91
  new URL(`./cassette/${name}.json`, import.meta.url),
@@ -165,6 +171,17 @@ function partsOf(s: Seen): ReadonlyMap<string, Part> {
165
171
  );
166
172
  }
167
173
 
174
+ test("options: GET /api/searchcompletion with the bearer token β†’ the topics", async () => {
175
+ seen.length = 0;
176
+ reply = { status: 200, body: cassette("get-searchcompletion") };
177
+ const r = await portal().options();
178
+ const s = last();
179
+ assert.equal(s.method, "GET");
180
+ assert.equal(s.url.pathname, "/api/searchcompletion");
181
+ assert.equal(s.headers.authorization, `bearer ${TOKEN}`);
182
+ assert.ok(r.ok && r.value.topics.length === 41);
183
+ });
184
+
168
185
  test("show: GET /api/paper?p=<id>&word_limit=hard with the bearer token, parsed whole", async () => {
169
186
  seen.length = 0;
170
187
  reply = { status: 200, body: cassette("get-paper") };
@@ -263,6 +280,7 @@ const CHANGE: SubmissionChange = {
263
280
  pdf: { name: "paper.pdf", bytes: PDF },
264
281
  abstract: "new abstract",
265
282
  submit: true,
283
+ fields: {},
266
284
  };
267
285
 
268
286
  test("update: a dry run by default β€” dry_run=1, the json field, the PDF under its content_file name", async () => {
@@ -359,6 +377,7 @@ const NEW: NewSubmission = {
359
377
  { name: "Bob", email: "bob@example.org", affiliation: null },
360
378
  ],
361
379
  pdf: { name: "paper.pdf", bytes: PDF },
380
+ fields: {},
362
381
  };
363
382
 
364
383
  test("create: a dry run by default β€” p=new, dry_run=1, title/abstract/authors and the PDF; no id", async () => {
@@ -545,6 +564,7 @@ test("a portal that does not answer is unreachable, with the network's words", a
545
564
  await down.show(7),
546
565
  await down.update(7, CHANGE, { save: false }),
547
566
  await down.create(NEW, { save: false }),
567
+ await down.options(),
548
568
  ]) {
549
569
  assert.equal(!r.ok && r.error.kind, "unreachable");
550
570
  assert.ok(!r.ok && r.error.kind === "unreachable" && r.error.detail !== "");
@@ -553,11 +573,92 @@ test("a portal that does not answer is unreachable, with the network's words", a
553
573
 
554
574
  test("changeObject: only what changes is sent", () => {
555
575
  assert.deepEqual(
556
- changeObject(7, { pdf: null, abstract: null, submit: false }),
576
+ changeObject(7, { pdf: null, abstract: null, submit: false, fields: {} }),
557
577
  { object: "paper", pid: 7 },
558
578
  );
559
579
  });
560
580
 
581
+ test("changeObject and createObject: the venue's fields go in as submission-object keys", () => {
582
+ const fields = {
583
+ topics: ["SE Topic: AI for software engineering"],
584
+ pc_conflicts: { "pc@example.org": "collaborator" },
585
+ ACMCorAut: "ada@example.org",
586
+ };
587
+ assert.deepEqual(
588
+ changeObject(7, { pdf: null, abstract: null, submit: false, fields }),
589
+ { object: "paper", pid: 7, ...fields },
590
+ );
591
+ const created = createObject({ ...NEW, fields });
592
+ assert.deepEqual(
593
+ [created["topics"], created["pc_conflicts"], created["ACMCorAut"]],
594
+ [fields.topics, fields.pc_conflicts, fields.ACMCorAut],
595
+ );
596
+ });
597
+
598
+ test("πŸ”΄ parseOptions: the recorded msr2027 completions β†’ its 41 topics, the 4 group names left out", () => {
599
+ const r = parseOptions(200, parsed("get-searchcompletion"));
600
+ assert.ok(r.ok);
601
+ assert.equal(r.value.topics.length, 41);
602
+ assert.ok(r.value.topics.includes("SE Topic: AI for software engineering"));
603
+ assert.ok(r.value.topics.includes("Performance analysis and testing"));
604
+ assert.ok(!r.value.topics.includes("SE Topic"));
605
+ // A completion that is no string is no topic.
606
+ assert.deepEqual(
607
+ parseOptions(200, {
608
+ ok: true,
609
+ searchcompletion: [{ q: "x" }, 'topic:"Only"'],
610
+ }),
611
+ { ok: true, value: { topics: ["Only"] } },
612
+ );
613
+ // A quote or backslash inside the quoted name comes escaped; the topic is the decoded name.
614
+ assert.deepEqual(
615
+ parseOptions(200, {
616
+ ok: true,
617
+ searchcompletion: ['topic:"C++ \\"modern\\" a\\\\b"'],
618
+ }),
619
+ { ok: true, value: { topics: ['C++ "modern" a\\b'] } },
620
+ );
621
+ assert.deepEqual(parseOptions(401, parsed("get-noauth")), {
622
+ ok: false,
623
+ error: {
624
+ kind: "refused",
625
+ httpStatus: 401,
626
+ messages: [{ message: "Missing credentials", field: null, status: 2 }],
627
+ },
628
+ });
629
+ assert.deepEqual(parseOptions(200, { ok: true }), {
630
+ ok: false,
631
+ error: {
632
+ kind: "malformed",
633
+ httpStatus: 200,
634
+ detail: "the answer has no `searchcompletion`",
635
+ },
636
+ });
637
+ });
638
+
639
+ test("πŸ”΄ an HTML message (<5>) is printed as its text, the tags dropped; a plain one (<0>) as it is", () => {
640
+ const r = parseUpdate(200, {
641
+ valid: true,
642
+ message_list: [
643
+ {
644
+ status: 1,
645
+ field: "pc_conflicts",
646
+ message:
647
+ '<5>You may have missed conflicts of interest with <a href="#pcconf:1">A B</a>.',
648
+ },
649
+ { status: 2, field: "topics", message: "<0>Use <b> sparingly" },
650
+ ],
651
+ });
652
+ assert.ok(r.ok);
653
+ assert.deepEqual(
654
+ r.value.messages.map((m) => m.message),
655
+ [
656
+ "You may have missed conflicts of interest with A B.",
657
+ "Use <b> sparingly",
658
+ ],
659
+ );
660
+ });
661
+
561
662
  test("sha256OfHash: sha2-<hex> is a sha256; any other hash is not claimed to be one", () => {
562
663
  assert.equal(sha256OfHash(`sha2-${HEX}`), HEX);
563
664
  assert.equal(sha256OfHash("da39a3ee5e6b4b0d3255bfef95601890afd80709"), null);
@@ -4,7 +4,8 @@
4
4
  * Only the fields the command uses are read; every other key HotCRP sends is dropped here. The field
5
5
  * names are HotCRP's (`devel/openapi.json` and `devel/apidoc/submissions.md` in kohler/hotcrp):
6
6
  * `GET /api/paper` answers `{ ok, paper, message_list? }`, `POST /api/paper` answers
7
- * `{ ok, valid, change_list, message_list, dry_run?, pid }`. A document's `hash` is
7
+ * `{ ok, valid, change_list, message_list, dry_run?, pid }`, `GET /api/searchcompletion` answers
8
+ * `{ ok, searchcompletion: [<completion>, …] }`. A document's `hash` is
8
9
  * `sha2-<64 hex>` when HotCRP stored a sha256; timestamps are Unix seconds.
9
10
  */
10
11
  import { z } from "zod";
@@ -15,6 +16,7 @@ import type {
15
16
  PortalDocument,
16
17
  PortalFailure,
17
18
  PortalMessage,
19
+ PortalOptions,
18
20
  SubmissionView,
19
21
  UpdateOutcome,
20
22
  } from "../../domain/submission.ts";
@@ -70,9 +72,15 @@ export function sha256OfHash(hash: string): Sha256 | null {
70
72
  return hex === undefined ? null : parseSha256(hex);
71
73
  }
72
74
 
73
- /** HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest. */
74
- const textOf = (message: string | undefined): string =>
75
- (message ?? "").replace(/^<\d+>/, "").trim();
75
+ /**
76
+ * HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest,
77
+ * an HTML one's tags dropped (Β«You may have missed conflicts of interest with <a href=…>…</a>Β»).
78
+ */
79
+ function textOf(message: string | undefined): string {
80
+ const m = /^<(\d+)>/.exec(message ?? "");
81
+ const rest = (message ?? "").slice(m?.[0].length ?? 0);
82
+ return (m?.[1] === "5" ? rest.replace(/<[^>]*>/g, "") : rest).trim();
83
+ }
76
84
 
77
85
  /** The messages that say something: HotCRP also sends entries with no text, which print as blanks. */
78
86
  const messagesOf = (
@@ -200,3 +208,36 @@ export function parseCreate(
200
208
  id: saved && typeof pid === "number" ? pid : null,
201
209
  });
202
210
  }
211
+
212
+ const OptionsBody = z.object({
213
+ ok: z.literal(true),
214
+ searchcompletion: z.array(z.unknown()),
215
+ });
216
+
217
+ const TOPIC = /^topic:"(.+)"$/s;
218
+
219
+ /** A quoted search operand with its backslash escapes undone: the name as the form spells it. */
220
+ const unquoted = (quoted: string): string => quoted.replace(/\\(.)/gs, "$1");
221
+
222
+ /**
223
+ * `GET /api/searchcompletion` β†’ the topics an author can choose. A completion `topic:"<name>"` names
224
+ * a topic; a name that only groups others (`SE Topic`, beside `SE Topic: Human aspects`) is not one
225
+ * a submission can carry β€” measured 2026-10-20, HotCRP drops it β€” so it is left out.
226
+ */
227
+ export function parseOptions(
228
+ httpStatus: number,
229
+ body: unknown,
230
+ ): Result<PortalOptions, PortalFailure> {
231
+ const status = statusOf(httpStatus, body);
232
+ const b = OptionsBody.safeParse(body);
233
+ if (!b.success || !is2xx(status))
234
+ return err(refusal(status, body, "the answer has no `searchcompletion`"));
235
+ const names = b.data.searchcompletion
236
+ .map((c) => (typeof c === "string" ? TOPIC.exec(c)?.[1] : undefined))
237
+ .filter((n) => n !== undefined)
238
+ .map(unquoted);
239
+ const groups = new Set(
240
+ names.filter((n) => names.some((x) => x.startsWith(`${n}: `))),
241
+ );
242
+ return ok({ topics: names.filter((n) => !groups.has(n)) });
243
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A HotCRP site before any token, as the `PortalSite` port: the variable its token is read from,
3
3
  * where on the site an author makes one and with which scopes, what `submission new` leaves to the
4
- * site, a submission's page β€” and the API, once there is a token. Every word here is HotCRP's.
4
+ * site (what the venue preset records no field for), a submission's page β€” and the API, once there is a token. Every word here is HotCRP's.
5
5
  */
6
6
  import type { PortalSite } from "../../ports/submission-portal.ts";
7
7
  import { hotcrpPortal } from "./portal.io.ts";
@@ -25,8 +25,7 @@ export const hotcrpSite = (url: string): PortalSite => ({
25
25
  "submeta:read and document:read (show), submeta:write and document:write (update, new) β€” tick all four to use every subcommand",
26
26
  },
27
27
  siteOnly: [
28
- "PC conflicts (HotCRP's API takes them as pc_conflicts; paperlint does not send them)",
29
- "topics, and every field the venue adds (paper type, artifact, consent boxes …)",
28
+ 'every field of the form the venue preset does not record in "submission_fields" (with none recorded: the topics, the PC conflicts, a paper type, consent boxes …)',
30
29
  ],
31
30
  submissionPage: (id) =>
32
31
  `${url}/paper/${id === null ? "<its number>" : String(id)}`,
package/src/cli.ts CHANGED
@@ -247,6 +247,9 @@ const USAGE = `paperlint β€” machine-checkable gates for a paper kept in git
247
247
  send the PDF (default: the paper's own), an abstract, or
248
248
  "submitted". A DRY RUN unless --save: the portal checks the
249
249
  change and keeps nothing
250
+ npx paperlint submission topics [paper]
251
+ the topics the venue's portal offers, against the ones its
252
+ preset records; exit 1 when they differ
250
253
  npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]
251
254
  register a study: the next id in repro/studies.json, the folder
252
255
  repro/<id>-<slug>/ with its README.md and plan.json (a template),
@@ -291,7 +294,9 @@ studies:
291
294
  submission:
292
295
  The venue preset declares the portal β€” "portal": { "kind": "hotcrp", "url": "<site>" } β€” and the
293
296
  paper's paperlint.json its submission there β€” "submission": { "id": <number> }, written by new
294
- β€” and, for new, its "authors": [{ "name", "email", "affiliation"? }]. The token comes only from
297
+ β€” and, for new, its "authors": [{ "name", "email", "affiliation"? }]. Its "fields" answer the
298
+ form's own fields the preset records in "submission_fields" ({ "topics": [...], "pc_conflicts":
299
+ { "<pc email>": "<type>" }, … }); new and update send them, checked first. The token comes only from
295
300
  the environment, one variable per site: HOTCRP_TOKEN_<subdomain> on *.hotcrp.com, else
296
301
  HOTCRP_TOKEN_<host and path> β€” e.g. HOTCRP_TOKEN_MSR2027 for msr2027.hotcrp.com,
297
302
  HOTCRP_TOKEN_AIDC_SUBMIT_ACSAC_ORG for aidc.submit.acsac.org (HotCRP: Account settings β†’
@@ -0,0 +1,183 @@
1
+ /** A paper's answers to its venue's own form fields: checked against the preset's kinds before anything is sent. */
2
+ import assert from "node:assert/strict";
3
+ import { expect, test } from "vitest";
4
+ import {
5
+ droppedFields,
6
+ parsePaperFields,
7
+ type VenueFields,
8
+ } from "./submission-fields.ts";
9
+
10
+ /** The fields of an edition's form, as a preset records them (the topics of msr2027, two of them). */
11
+ const VENUE: VenueFields = {
12
+ source: "portal",
13
+ url: "https://example.org/api/searchcompletion",
14
+ read: "2026-10-20",
15
+ fields: {
16
+ topics: {
17
+ kind: "checkboxes",
18
+ required: true,
19
+ label: null,
20
+ options: [
21
+ "SE Topic: AI for software engineering",
22
+ "Performance analysis and testing",
23
+ ],
24
+ },
25
+ pc_conflicts: {
26
+ kind: "pc_conflicts",
27
+ required: false,
28
+ label: null,
29
+ options: [],
30
+ },
31
+ ACMCorAut: {
32
+ kind: "email",
33
+ required: false,
34
+ label: "ACM corresponding author",
35
+ options: [],
36
+ },
37
+ note: { kind: "text", required: false, label: null, options: [] },
38
+ },
39
+ };
40
+
41
+ test("parsePaperFields: absent on a venue that records no fields β†’ nothing to send; every kind's value passes as given", () => {
42
+ assert.deepEqual(parsePaperFields(null, null, "msr.jsonc"), {
43
+ ok: true,
44
+ value: {},
45
+ });
46
+ const fields = {
47
+ topics: ["SE Topic: AI for software engineering"],
48
+ pc_conflicts: {
49
+ "pc@example.org": "collaborator",
50
+ "chair@example.org": true,
51
+ },
52
+ ACMCorAut: "author@example.org",
53
+ note: "free text",
54
+ };
55
+ assert.deepEqual(parsePaperFields(fields, VENUE, "msr.jsonc"), {
56
+ ok: true,
57
+ value: fields,
58
+ });
59
+ });
60
+
61
+ test("πŸ”΄ parsePaperFields: a topic the form does not offer is refused, naming what it offers β€” before any request", () => {
62
+ assert.deepEqual(
63
+ parsePaperFields(
64
+ { topics: ["AI for software engineering"] },
65
+ VENUE,
66
+ "msr.jsonc",
67
+ ),
68
+ {
69
+ ok: false,
70
+ error:
71
+ '"fields".topics[0]: "AI for software engineering" is not one of the form\'s options β€” the form offers: "SE Topic: AI for software engineering", "Performance analysis and testing"',
72
+ },
73
+ );
74
+ });
75
+
76
+ test("πŸ”΄ parsePaperFields: each other wrong answer is refused by key", () => {
77
+ const refused = (v: unknown): string => {
78
+ const r = parsePaperFields(v, VENUE, "msr.jsonc");
79
+ return r.ok ? "accepted" : r.error;
80
+ };
81
+ assert.equal(
82
+ refused({ topics: [] }),
83
+ '"fields".topics: the form requires at least one β€” the form offers: "SE Topic: AI for software engineering", "Performance analysis and testing"',
84
+ );
85
+ assert.equal(
86
+ refused({
87
+ topics: [
88
+ "Performance analysis and testing",
89
+ "Performance analysis and testing",
90
+ ],
91
+ }),
92
+ '"fields".topics: an option is given twice β€” the form offers: "SE Topic: AI for software engineering", "Performance analysis and testing"',
93
+ );
94
+ assert.equal(
95
+ refused({ topics: ["Performance analysis and testing"], paper_type: "x" }),
96
+ '"fields" has "paper_type", which the venue\'s form does not ask (it asks: topics, pc_conflicts, ACMCorAut, note)',
97
+ );
98
+ assert.match(
99
+ refused({
100
+ topics: ["Performance analysis and testing"],
101
+ ACMCorAut: "Erdni Zergetaev",
102
+ }),
103
+ /^"fields"\.ACMCorAut: /,
104
+ );
105
+ assert.match(
106
+ refused({
107
+ topics: ["Performance analysis and testing"],
108
+ pc_conflicts: { "pc@example.org": "" },
109
+ }),
110
+ /^"fields"\.pc_conflicts\.pc@example\.org: /,
111
+ );
112
+ assert.match(refused({}), /^"fields"\.topics: /);
113
+ assert.match(refused([]), /^"fields": /);
114
+ });
115
+
116
+ test("πŸ”΄ parsePaperFields: no fields at all, on a venue whose form requires one β€” refused, not sent empty", () => {
117
+ const r = parsePaperFields(null, VENUE, "msr.jsonc");
118
+ expect(r.ok ? "accepted" : r.error).toMatch(/^"fields"\.topics: /);
119
+ });
120
+
121
+ test("parsePaperFields: an optional checkbox list may be empty; a required text may not", () => {
122
+ const venue: VenueFields = {
123
+ ...VENUE,
124
+ fields: {
125
+ tags: {
126
+ kind: "checkboxes",
127
+ required: false,
128
+ label: null,
129
+ options: ["x"],
130
+ },
131
+ title2: { kind: "text", required: true, label: null, options: [] },
132
+ },
133
+ };
134
+ expect(parsePaperFields({ tags: [], title2: "t" }, venue, "v")).toStrictEqual(
135
+ { ok: true, value: { tags: [], title2: "t" } },
136
+ );
137
+ const r = parsePaperFields({ title2: " " }, venue, "v");
138
+ expect(r.ok ? "accepted" : r.error).toMatch(/^"fields"\.title2: /);
139
+ });
140
+
141
+ test("πŸ”΄ parsePaperFields: fields on a venue whose preset records none β€” refused, naming where it looked", () => {
142
+ assert.deepEqual(parsePaperFields({ topics: ["x"] }, null, "msr.jsonc"), {
143
+ ok: false,
144
+ error:
145
+ '"fields" is set, but the venue preset records no "submission_fields" (read: msr.jsonc) β€” record the form\'s fields in the venue\'s preset first, or drop "fields"',
146
+ });
147
+ });
148
+
149
+ test("πŸ”΄ droppedFields: a checkbox list sent, missing from the changes and named by the portal β€” dropped; otherwise not", () => {
150
+ const sent = { topics: ["Performance analysis and testing"], note: "x" };
151
+ // Measured on HotCRP 2026-10-20: an unknown topic β†’ `change_list` without `topics`, and
152
+ // Β«topics: At least one selection requiredΒ».
153
+ assert.deepEqual(
154
+ droppedFields(sent, VENUE, {
155
+ changes: [],
156
+ messageFields: ["topics", "pc_conflicts"],
157
+ }),
158
+ ["topics"],
159
+ );
160
+ assert.deepEqual(
161
+ droppedFields(sent, VENUE, {
162
+ changes: ["topics"],
163
+ messageFields: ["topics"],
164
+ }),
165
+ [],
166
+ );
167
+ // The same topics as the portal already holds: no change, and nothing said about them.
168
+ assert.deepEqual(
169
+ droppedFields(sent, VENUE, { changes: [], messageFields: [null] }),
170
+ [],
171
+ );
172
+ assert.deepEqual(
173
+ droppedFields({ topics: [] }, VENUE, {
174
+ changes: [],
175
+ messageFields: ["topics"],
176
+ }),
177
+ [],
178
+ );
179
+ assert.deepEqual(
180
+ droppedFields(sent, null, { changes: [], messageFields: ["topics"] }),
181
+ [],
182
+ );
183
+ });