paperlint 6.2.2 → 6.3.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 (134) hide show
  1. package/README.md +8 -1
  2. package/dist/adapters/hotcrp/index.d.ts +7 -0
  3. package/dist/adapters/hotcrp/index.d.ts.map +1 -0
  4. package/dist/adapters/hotcrp/index.js +7 -0
  5. package/dist/adapters/hotcrp/index.js.map +1 -0
  6. package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
  7. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/portal.io.js +90 -0
  9. package/dist/adapters/hotcrp/portal.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/response.d.ts +13 -0
  11. package/dist/adapters/hotcrp/response.d.ts.map +1 -0
  12. package/dist/adapters/hotcrp/response.js +138 -0
  13. package/dist/adapters/hotcrp/response.js.map +1 -0
  14. package/dist/cli.d.ts +3 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +109 -34
  17. package/dist/cli.js.map +1 -1
  18. package/dist/domain/result.d.ts +4 -0
  19. package/dist/domain/result.d.ts.map +1 -1
  20. package/dist/domain/result.js +2 -0
  21. package/dist/domain/result.js.map +1 -1
  22. package/dist/domain/submission.d.ts +106 -0
  23. package/dist/domain/submission.d.ts.map +1 -0
  24. package/dist/domain/submission.js +39 -0
  25. package/dist/domain/submission.js.map +1 -0
  26. package/dist/domain/venue-name.d.ts +38 -0
  27. package/dist/domain/venue-name.d.ts.map +1 -0
  28. package/dist/domain/venue-name.js +71 -0
  29. package/dist/domain/venue-name.js.map +1 -0
  30. package/dist/folder-venue-rule.d.ts +51 -0
  31. package/dist/folder-venue-rule.d.ts.map +1 -0
  32. package/dist/folder-venue-rule.js +96 -0
  33. package/dist/folder-venue-rule.js.map +1 -0
  34. package/dist/init.d.ts +6 -0
  35. package/dist/init.d.ts.map +1 -1
  36. package/dist/init.js +3 -3
  37. package/dist/init.js.map +1 -1
  38. package/dist/lib/paper-config.d.ts.map +1 -1
  39. package/dist/lib/paper-config.js +5 -1
  40. package/dist/lib/paper-config.js.map +1 -1
  41. package/dist/new-paper.d.ts +19 -5
  42. package/dist/new-paper.d.ts.map +1 -1
  43. package/dist/new-paper.js +23 -4
  44. package/dist/new-paper.js.map +1 -1
  45. package/dist/paper-settings.d.ts +3 -0
  46. package/dist/paper-settings.d.ts.map +1 -1
  47. package/dist/paper-settings.js +20 -4
  48. package/dist/paper-settings.js.map +1 -1
  49. package/dist/ports/submission-portal.d.ts +19 -0
  50. package/dist/ports/submission-portal.d.ts.map +1 -0
  51. package/dist/ports/submission-portal.js +2 -0
  52. package/dist/ports/submission-portal.js.map +1 -0
  53. package/dist/presets.d.ts +24 -0
  54. package/dist/presets.d.ts.map +1 -1
  55. package/dist/presets.js +35 -3
  56. package/dist/presets.js.map +1 -1
  57. package/dist/rules-config.js +1 -1
  58. package/dist/submission.d.ts +61 -0
  59. package/dist/submission.d.ts.map +1 -0
  60. package/dist/submission.js +211 -0
  61. package/dist/submission.js.map +1 -0
  62. package/dist/tex-requirements.d.ts +3 -0
  63. package/dist/tex-requirements.d.ts.map +1 -1
  64. package/dist/tex-requirements.js +3 -0
  65. package/dist/tex-requirements.js.map +1 -1
  66. package/dist/tex-venue-rules.d.ts +6 -7
  67. package/dist/tex-venue-rules.d.ts.map +1 -1
  68. package/dist/tex-venue-rules.js +10 -14
  69. package/dist/tex-venue-rules.js.map +1 -1
  70. package/dist/types.d.ts +13 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/docs/configuration.md +17 -10
  73. package/docs/rules/paper/folder-venue-leftover.md +104 -0
  74. package/docs/rules/talk/captions-cover.md +1 -1
  75. package/docs/rules/talk/duration-floor.md +1 -1
  76. package/docs/rules.md +44 -42
  77. package/docs/skills.md +5 -4
  78. package/docs/submission.md +64 -0
  79. package/docs/talk.md +1 -1
  80. package/lib/paper-config.ts +6 -1
  81. package/lib/skill-checks.d.mts +7 -0
  82. package/lib/skill-trigger-cases.mjs +11 -0
  83. package/package.json +2 -2
  84. package/presets/aidc.jsonc +6 -0
  85. package/presets/venue-profile.schema.json +17 -1
  86. package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
  87. package/skills/submission-portal/SKILL.md +69 -0
  88. package/skills/submission-portal/SKILL.md.spec.ts +71 -0
  89. package/skills/submission-portal/submission-portal.eval.mjs +15 -0
  90. package/skills/submission-portal/submission-portal.test.ts +81 -0
  91. package/skills/submit-paper/SKILL.md +2 -1
  92. package/skills/submit-paper/SKILL.md.spec.ts +1 -0
  93. package/skills/verify-citations/SKILL.md +8 -4
  94. package/skills/verify-citations/SKILL.md.spec.ts +7 -3
  95. package/skills/verify-citations/scripts/verify-cites.mjs +23 -5
  96. package/skills/verify-citations/scripts/verify-cites.test.mjs +76 -5
  97. package/src/adapters/hotcrp/cassette/README.md +24 -0
  98. package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
  99. package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
  100. package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
  101. package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
  102. package/src/adapters/hotcrp/index.ts +11 -0
  103. package/src/adapters/hotcrp/portal.io.ts +130 -0
  104. package/src/adapters/hotcrp/portal.test.ts +503 -0
  105. package/src/adapters/hotcrp/response.ts +181 -0
  106. package/src/cli.test.ts +109 -0
  107. package/src/cli.ts +140 -21
  108. package/src/domain/result.test.ts +9 -1
  109. package/src/domain/result.ts +5 -0
  110. package/src/domain/submission.test.ts +45 -0
  111. package/src/domain/submission.ts +141 -0
  112. package/src/domain/venue-name.test.ts +115 -0
  113. package/src/domain/venue-name.ts +129 -0
  114. package/src/folder-venue-rule.test.ts +182 -0
  115. package/src/folder-venue-rule.ts +141 -0
  116. package/src/init.test.ts +22 -0
  117. package/src/init.ts +9 -2
  118. package/src/new-paper.harness.mjs +11 -9
  119. package/src/new-paper.test.ts +129 -8
  120. package/src/new-paper.ts +38 -7
  121. package/src/paper-settings.test.ts +12 -1
  122. package/src/paper-settings.ts +25 -5
  123. package/src/parse-args.test.ts +19 -0
  124. package/src/ports/submission-portal.ts +26 -0
  125. package/src/presets.test.ts +22 -0
  126. package/src/presets.ts +46 -3
  127. package/src/rule-cases.test.ts +19 -0
  128. package/src/rules-config.ts +1 -1
  129. package/src/submission.test.ts +496 -0
  130. package/src/submission.ts +311 -0
  131. package/src/tex-requirements.ts +8 -0
  132. package/src/tex-venue-rules.test.ts +1 -1
  133. package/src/tex-venue-rules.ts +12 -25
  134. package/src/types.ts +13 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * HotCRP's answers, parsed at the boundary into paperlint's submission values. Pure.
3
+ *
4
+ * Only the fields the command uses are read; every other key HotCRP sends is dropped here. The field
5
+ * names are HotCRP's (`devel/openapi.json` and `devel/apidoc/submissions.md` in kohler/hotcrp):
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
8
+ * `sha2-<64 hex>` when HotCRP stored a sha256; timestamps are Unix seconds.
9
+ */
10
+ import { z } from "zod";
11
+ import { err, ok, type Result } from "../../domain/result.ts";
12
+ import { parseSha256, type Sha256 } from "../../domain/sha256.ts";
13
+ import type {
14
+ PortalDocument,
15
+ PortalFailure,
16
+ PortalMessage,
17
+ SubmissionView,
18
+ UpdateOutcome,
19
+ } from "../../domain/submission.ts";
20
+
21
+ const Message = z.object({
22
+ message: z.string().optional(),
23
+ field: z.string().optional(),
24
+ status: z.number().optional(),
25
+ });
26
+ const Messages = z.array(Message).optional();
27
+
28
+ const Document = z.object({
29
+ hash: z.string().optional(),
30
+ size: z.number().optional(),
31
+ mimetype: z.string().optional(),
32
+ timestamp: z.number().optional(),
33
+ });
34
+
35
+ const Paper = z.object({
36
+ pid: z.number(),
37
+ status: z.string(),
38
+ title: z.string().optional(),
39
+ abstract: z.string().optional(),
40
+ paper_type: z.unknown().optional(),
41
+ topics: z.array(z.string()).optional(),
42
+ submission: Document.nullable().optional(),
43
+ submitted_at: z.number().optional(),
44
+ modified_at: z.number().optional(),
45
+ });
46
+
47
+ const ShowBody = z.object({
48
+ ok: z.literal(true),
49
+ paper: Paper,
50
+ message_list: Messages,
51
+ });
52
+
53
+ const UpdateBody = z.object({
54
+ valid: z.boolean(),
55
+ change_list: z.array(z.string()).optional(),
56
+ message_list: Messages,
57
+ dry_run: z.boolean().optional(),
58
+ });
59
+
60
+ const Envelope = z.object({ message_list: Messages });
61
+
62
+ const SHA2 = /^sha2-([0-9a-f]{64})$/;
63
+
64
+ /** HotCRP's `sha2-<hex>` → the sha256; null for any other hash it may name. */
65
+ export function sha256OfHash(hash: string): Sha256 | null {
66
+ const hex = SHA2.exec(hash)?.[1];
67
+ return hex === undefined ? null : parseSha256(hex);
68
+ }
69
+
70
+ /** HotCRP prefixes a message with its format (`<0>` plain text, `<5>` HTML); the text is the rest. */
71
+ const textOf = (message: string | undefined): string =>
72
+ (message ?? "").replace(/^<\d+>/, "").trim();
73
+
74
+ /** The messages that say something: HotCRP also sends entries with no text, which print as blanks. */
75
+ const messagesOf = (
76
+ list: Readonly<z.infer<typeof Messages>>,
77
+ ): readonly PortalMessage[] =>
78
+ (list ?? [])
79
+ .map((m) => ({
80
+ message: textOf(m.message),
81
+ field: m.field ?? null,
82
+ status: m.status ?? null,
83
+ }))
84
+ .filter((m) => m.message !== "");
85
+
86
+ const documentOf = (
87
+ d: Readonly<z.infer<typeof Document>> | null | undefined,
88
+ ): PortalDocument | null =>
89
+ d?.hash === undefined
90
+ ? null
91
+ : {
92
+ sha256: sha256OfHash(d.hash),
93
+ hash: d.hash,
94
+ size: d.size ?? null,
95
+ mimetype: d.mimetype ?? null,
96
+ uploadedAt: d.timestamp ?? null,
97
+ };
98
+
99
+ /** A paper type as text: HotCRP sends the option's name, or a value of another shape. */
100
+ const typeText = (v: unknown): string | null =>
101
+ v === undefined || v === null
102
+ ? null
103
+ : typeof v === "string"
104
+ ? v
105
+ : JSON.stringify(v);
106
+
107
+ function viewOf(b: Readonly<z.infer<typeof ShowBody>>): SubmissionView {
108
+ const p = b.paper;
109
+ return {
110
+ id: p.pid,
111
+ status: p.status,
112
+ title: p.title ?? null,
113
+ paperType: typeText(p.paper_type),
114
+ topics: p.topics ?? [],
115
+ abstract: p.abstract ?? null,
116
+ pdf: documentOf(p.submission),
117
+ submittedAt: p.submitted_at ?? null,
118
+ modifiedAt: p.modified_at ?? null,
119
+ messages: messagesOf(b.message_list),
120
+ };
121
+ }
122
+
123
+ const is2xx = (status: number): boolean => status >= 200 && status < 300;
124
+
125
+ const StatusCode = z.object({ status_code: z.number() });
126
+
127
+ /**
128
+ * The status HotCRP meant. Its error envelope repeats the status in the body (`status_code: 401`
129
+ * beside `ok: false`, recorded in `cassette/get-noauth.json`); when something between us and HotCRP
130
+ * turns the transport status into a 2xx, the body's code still decides.
131
+ */
132
+ function statusOf(transport: number, body: unknown): number {
133
+ const c = StatusCode.safeParse(body);
134
+ return is2xx(transport) && c.success ? c.data.status_code : transport;
135
+ }
136
+
137
+ /** A body that is no answer of HotCRP's: the portal's messages when it sent any, else "malformed". */
138
+ function refusal(
139
+ httpStatus: number,
140
+ body: unknown,
141
+ what: string,
142
+ ): PortalFailure {
143
+ const e = Envelope.safeParse(body);
144
+ const messages = e.success ? messagesOf(e.data.message_list) : [];
145
+ return messages.length > 0 || !is2xx(httpStatus)
146
+ ? { kind: "refused", httpStatus, messages }
147
+ : { kind: "malformed", httpStatus, detail: what };
148
+ }
149
+
150
+ /** `GET /api/paper` → the submission, or why there is none. */
151
+ export function parseShow(
152
+ httpStatus: number,
153
+ body: unknown,
154
+ ): Result<SubmissionView, PortalFailure> {
155
+ const status = statusOf(httpStatus, body);
156
+ const b = ShowBody.safeParse(body);
157
+ return b.success && is2xx(status)
158
+ ? ok(viewOf(b.data))
159
+ : err(refusal(status, body, "the answer has no submission in `paper`"));
160
+ }
161
+
162
+ /**
163
+ * `POST /api/paper` → the outcome, valid or not. A body carrying `valid` is HotCRP's verdict on
164
+ * the change whatever the HTTP status; the caller decides the exit from both.
165
+ */
166
+ export function parseUpdate(
167
+ httpStatus: number,
168
+ body: unknown,
169
+ ): Result<UpdateOutcome, PortalFailure> {
170
+ const status = statusOf(httpStatus, body);
171
+ const b = UpdateBody.safeParse(body);
172
+ if (!b.success)
173
+ return err(refusal(status, body, "the answer has no `valid`"));
174
+ return ok({
175
+ httpStatus: status,
176
+ valid: b.data.valid,
177
+ changes: b.data.change_list ?? [],
178
+ messages: messagesOf(b.data.message_list),
179
+ dryRun: b.data.dry_run ?? false,
180
+ });
181
+ }
package/src/cli.test.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import assert from "node:assert/strict";
8
8
  import { spawn } from "node:child_process";
9
9
  import { existsSync } from "node:fs";
10
+ import { createServer } from "node:http";
10
11
  import { dirname, join } from "node:path";
11
12
  import { test } from "vitest";
12
13
  import { runNode, useTempDir, writeTree } from "../test/support.ts";
@@ -82,6 +83,14 @@ test("parseSettings refuses `talk` in the root file: it is one paper's setting",
82
83
  );
83
84
  });
84
85
 
86
+ test("parseSettings refuses `submission` in the root file: a submission is one paper's", () => {
87
+ const r = parseSettings({ submission: { id: 7 } }, "paperlint.json", "/r");
88
+ assert.equal(
89
+ r.ok ? null : r.error,
90
+ "paperlint.json: \"submission\" is a paper's setting — set it in that paper's paperlint.json",
91
+ );
92
+ });
93
+
85
94
  test("parseSettings refuses a non-object, names one unknown key or several, and a mistyped default", () => {
86
95
  const error = (json: unknown) => {
87
96
  const r = parseSettings(json, "paperlint.json", "/r");
@@ -603,3 +612,103 @@ test("parseSettings refuses a root `identity` that is not a list of words, and t
603
612
  true,
604
613
  );
605
614
  });
615
+
616
+ test("parseArgs: submission's flags — two values and two switches", () => {
617
+ assert.deepEqual(
618
+ parseArgs([
619
+ "submission",
620
+ "update",
621
+ "papers/p",
622
+ "--pdf=build/p.pdf",
623
+ "--abstract",
624
+ "abs.txt",
625
+ "--submit",
626
+ "--save",
627
+ ]),
628
+ {
629
+ ...parseArgs([]),
630
+ cmd: "submission",
631
+ paths: ["update", "papers/p"],
632
+ pdf: "build/p.pdf",
633
+ abstract: "abs.txt",
634
+ submit: true,
635
+ save: true,
636
+ },
637
+ );
638
+ assert.equal(
639
+ parseArgs(["submission", "update", "--pdf"]).missingValue,
640
+ "--pdf",
641
+ );
642
+ });
643
+
644
+ /** A paper whose venue's HotCRP portal is `site`, submission 7, with a paper.pdf. */
645
+ const submissionPaper = (dir: string, site: string): void => {
646
+ writeTree(dir, {
647
+ "package.json": "{}",
648
+ "venue.jsonc": JSON.stringify({
649
+ extends: "paperlint:aidc",
650
+ portal: { kind: "hotcrp", url: site },
651
+ }),
652
+ "papers/p/paperlint.json": JSON.stringify({
653
+ extends: "../../venue.jsonc",
654
+ submission: { id: 7 },
655
+ }),
656
+ "papers/p/paper.pdf": "%PDF fake",
657
+ });
658
+ };
659
+
660
+ test("🔴 submission: without HOTCRP_TOKEN it names the variable and asks the portal nothing", async () => {
661
+ const dir = join(root, "submission-no-token");
662
+ submissionPaper(dir, "http://127.0.0.1:9");
663
+ const saved = process.env["HOTCRP_TOKEN"];
664
+ delete process.env["HOTCRP_TOKEN"];
665
+ try {
666
+ const r = await cli(["submission", "show", "papers/p"], dir);
667
+ assert.equal(r.code, 2);
668
+ assert.match(r.err, /^HOTCRP_TOKEN is not set — /);
669
+ } finally {
670
+ if (saved !== undefined) process.env["HOTCRP_TOKEN"] = saved;
671
+ }
672
+ });
673
+
674
+ test("submission show: through the HotCRP adapter to a local portal, the token in the header only", async () => {
675
+ const seen: { url: string; auth: string }[] = [];
676
+ const server = createServer((req, res) => {
677
+ seen.push({ url: req.url ?? "", auth: req.headers.authorization ?? "" });
678
+ res.writeHead(200, { "content-type": "application/json" });
679
+ res.end(
680
+ JSON.stringify({
681
+ ok: true,
682
+ paper: { object: "paper", pid: 7, status: "submitted", title: "T" },
683
+ }),
684
+ );
685
+ });
686
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
687
+ const address = server.address();
688
+ assert.ok(address !== null && typeof address === "object");
689
+ const dir = join(root, "submission-show");
690
+ submissionPaper(dir, `http://127.0.0.1:${String(address.port)}`);
691
+ const saved = process.env["HOTCRP_TOKEN"];
692
+ process.env["HOTCRP_TOKEN"] = "hct_cli_test";
693
+ try {
694
+ const r = await cli(["submission", "show"], join(dir, "papers/p"));
695
+ assert.equal(r.code, 0, r.err);
696
+ assert.deepEqual(seen, [
697
+ { url: "/api/paper?p=7&word_limit=hard", auth: "bearer hct_cli_test" },
698
+ ]);
699
+ assert.match(
700
+ r.out,
701
+ /^submission 7 on http:\/\/127\.0\.0\.1:\d+ \(hotcrp\)$/m,
702
+ );
703
+ assert.match(r.out, /^match {9}NO — the portal holds no PDF$/m);
704
+ assert.doesNotMatch(r.out + r.err, /hct_cli_test/);
705
+ } finally {
706
+ if (saved === undefined) delete process.env["HOTCRP_TOKEN"];
707
+ else process.env["HOTCRP_TOKEN"] = saved;
708
+ await new Promise<void>((r) => {
709
+ server.close(() => {
710
+ r();
711
+ });
712
+ });
713
+ }
714
+ });
package/src/cli.ts CHANGED
@@ -86,6 +86,10 @@ import {
86
86
  } from "./paper-settings.ts";
87
87
  import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
88
88
  import { onlineReferences } from "./adapters/references/index.ts";
89
+ import { hotcrpPortal } from "./adapters/hotcrp/index.ts";
90
+ import { runSubmission } from "./submission.ts";
91
+ import type { PortalKind, SupportedPortal } from "./domain/submission.ts";
92
+ import type { SubmissionPortal } from "./ports/submission-portal.ts";
89
93
  import type { CheckReferences } from "./ports/check-references.ts";
90
94
  import {
91
95
  narrowToOwners,
@@ -99,6 +103,8 @@ import {
99
103
  presetProblemText,
100
104
  resolvePreset,
101
105
  shippedPresets,
106
+ shippedSpec,
107
+ shippedVenueNames,
102
108
  SHIPPED_PREFIX,
103
109
  type PaperPreset,
104
110
  type PresetTemplate,
@@ -152,6 +158,10 @@ export { init };
152
158
  export { nextSteps } from "./init.ts";
153
159
 
154
160
  import paperStages from "#eslint-rules/paper-stages";
161
+ import {
162
+ FOLDER_VENUE_RULE_LEVELS,
163
+ folderVenueRules,
164
+ } from "./folder-venue-rule.ts";
155
165
  import typography from "#eslint-rules/paper-typography";
156
166
  import texBuild from "#eslint-rules/tex-build";
157
167
  import bibReachable from "#eslint-rules/bib-reachable-entry";
@@ -171,7 +181,9 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
171
181
  .claude/settings.json, offer the CI step, report what is missing
172
182
  npx paperlint new <name> [--venue <preset>] [--kind <kind>] [--format tex|md]
173
183
  create <papers>/<name>/ from the template; never overwrites,
174
- on an existing folder adds only the missing files, then lints it
184
+ on an existing folder adds only the missing files, then lints it.
185
+ A <name> that names a venue (aisec-2026) is refused: name the
186
+ paper after the work
175
187
  npx paperlint lint [paths…] run every rule over your papers
176
188
  npx paperlint build <paper> | --all
177
189
  compile paper.tex to paper.pdf: pdflatex and bibtex, rerun until
@@ -187,6 +199,14 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
187
199
  into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
188
200
  run does nothing. --check: report what is missing, change nothing
189
201
  npx paperlint doctor say what is actually wired — and what only LOOKS wired
202
+ npx paperlint submission show [paper]
203
+ the paper's submission on the venue's portal: status, title,
204
+ type, topics, abstract length, the PDF the portal holds, and
205
+ whether it is the local paper.pdf (sha256)
206
+ npx paperlint submission update [paper] [--pdf <file>] [--abstract <file>] [--submit] [--save]
207
+ send the PDF (default: the paper's own), an abstract, or
208
+ "submitted". A DRY RUN unless --save: the portal checks the
209
+ change and keeps nothing
190
210
  npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
191
211
  npx paperlint --help
192
212
 
@@ -205,6 +225,19 @@ new:
205
225
  --kind <kind> the paper's kind at that venue (its page limit), e.g. short — one of the
206
226
  preset's kinds; needs --venue
207
227
  --format tex|md the paper's source format; default tex
228
+ --allow-venue-name create the folder even though its name names a venue (a whole word such as
229
+ "realm" can be the work's own). Without it such a name is refused: a rejected
230
+ paper moves to another venue and keeps its folder
231
+
232
+ submission:
233
+ The venue preset declares the portal — "portal": { "kind": "hotcrp", "url": "<site>" } — and the
234
+ paper's paperlint.json its submission there — "submission": { "id": <number> }. The token comes
235
+ only from the environment: HOTCRP_TOKEN (HotCRP: Account settings → Developer).
236
+ [paper] the paper's folder; default the current directory
237
+ --pdf <file> the PDF to compare (show) or send (update) instead of the paper's own
238
+ --abstract <file> update: a plain-text file whose text replaces the abstract
239
+ --submit update: mark the submission submitted
240
+ --save update: really change the submission. Without it, nothing is saved
208
241
 
209
242
  lint:
210
243
  npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
@@ -313,11 +346,22 @@ export function buildConfig(
313
346
  },
314
347
  {
315
348
  files: ["**/PIPELINE-STATUS.md"],
316
- plugins: { markdown, paper: paperStages },
349
+ // Every paper folder has one, whatever its source format: the rules about the folder itself
350
+ // (its stages, its frozen sources, its name) are judged here, once per paper.
351
+ plugins: {
352
+ markdown,
353
+ paper: {
354
+ rules: {
355
+ ...paperStages.rules,
356
+ ...folderVenueRules({ files: nodeFiles, venuesDir: presetsDir() }),
357
+ },
358
+ },
359
+ },
317
360
  ...md,
318
361
  rules: {
319
362
  "paper/stages": "error",
320
363
  "paper/source": "error",
364
+ ...FOLDER_VENUE_RULE_LEVELS,
321
365
  },
322
366
  },
323
367
  {
@@ -781,10 +825,15 @@ export function parseArgs(argv: readonly string[]): Args {
781
825
  check: false,
782
826
  yes: false,
783
827
  noHooks: false,
828
+ allowVenueName: false,
784
829
  paper: null,
785
830
  format: null,
786
831
  venue: null,
787
832
  kind: null,
833
+ pdf: null,
834
+ abstract: null,
835
+ submit: false,
836
+ save: false,
788
837
  hooksMode: null,
789
838
  // -1 = warnings NEVER fail the run. In this set most findings are advisory by design, and a
790
839
  // gate that fails on advice gets muted entirely.
@@ -824,19 +873,14 @@ export function parseArgs(argv: readonly string[]): Args {
824
873
  if (inline === "") out.missingValue = a;
825
874
  return inline === "" ? undefined : inline;
826
875
  };
827
- if (a === "--json") out.json = true;
828
- else if (a === "--fix") out.fix = true;
829
- else if (a === "--all") out.all = true;
830
- else if (a === "--dry-run") out.dryRun = true;
831
- else if (a === "--check") out.check = true;
832
- else if (a === "--yes" || a === "-y") out.yes = true;
833
- else if (a === "--no-hooks") out.noHooks = true;
876
+ const on = SWITCHES.get(a);
877
+ if (on !== undefined) out[on] = true;
834
878
  else if (a.startsWith("--hooks="))
835
879
  out.hooksMode = a.slice("--hooks=".length);
836
- else if (a === "--paper") out.paper = take() ?? null;
837
- else if (a === "--format") out.format = take() ?? null;
838
- else if (a === "--venue") out.venue = take() ?? null;
839
- else if (a === "--kind") out.kind = take() ?? null;
880
+ else if (STRING_FLAGS.has(a)) {
881
+ const field = STRING_FLAGS.get(a);
882
+ if (field !== undefined) out[field] = take() ?? null;
883
+ }
840
884
  // `--options` was the first spelling and is kept working. It named the wrong thing — every
841
885
  // other tool in the stack calls this file its config — but a flag in someone's CI is not
842
886
  // ours to break.
@@ -852,12 +896,49 @@ export function parseArgs(argv: readonly string[]): Args {
852
896
  return out;
853
897
  }
854
898
 
899
+ /** The flags that take no value: each turns one field of `Args` on. */
900
+ const SWITCHES: ReadonlyMap<
901
+ string,
902
+ | "json"
903
+ | "fix"
904
+ | "all"
905
+ | "dryRun"
906
+ | "check"
907
+ | "yes"
908
+ | "noHooks"
909
+ | "allowVenueName"
910
+ | "submit"
911
+ | "save"
912
+ > = new Map([
913
+ ["--json", "json"],
914
+ ["--fix", "fix"],
915
+ ["--all", "all"],
916
+ ["--dry-run", "dryRun"],
917
+ ["--check", "check"],
918
+ ["--yes", "yes"],
919
+ ["-y", "yes"],
920
+ ["--no-hooks", "noHooks"],
921
+ ["--allow-venue-name", "allowVenueName"],
922
+ ["--submit", "submit"],
923
+ ["--save", "save"],
924
+ ]);
925
+
926
+ /** The flags whose value is one string field of `Args`. */
927
+ const STRING_FLAGS: ReadonlyMap<
928
+ string,
929
+ "paper" | "format" | "venue" | "kind" | "pdf" | "abstract"
930
+ > = new Map([
931
+ ["--paper", "paper"],
932
+ ["--format", "format"],
933
+ ["--venue", "venue"],
934
+ ["--kind", "kind"],
935
+ ["--pdf", "pdf"],
936
+ ["--abstract", "abstract"],
937
+ ]);
938
+
855
939
  /** The flags that take a value, in either spelling: `--flag value` or `--flag=value`. */
856
940
  const VALUE_FLAGS: ReadonlySet<string> = new Set([
857
- "--paper",
858
- "--format",
859
- "--venue",
860
- "--kind",
941
+ ...STRING_FLAGS.keys(),
861
942
  "--config",
862
943
  "--options",
863
944
  "--max-warnings",
@@ -1048,15 +1129,21 @@ export async function createPaperAt(
1048
1129
  err,
1049
1130
  cwd,
1050
1131
  venue = null,
1132
+ allowVenueName = false,
1051
1133
  }: {
1052
1134
  log: typeof console.log;
1053
1135
  err: typeof console.error;
1054
1136
  cwd: string;
1055
1137
  /** What `--venue` chose; null writes the template's `paperlint.json` as it is. */
1056
1138
  venue?: VenueChoice | null;
1139
+ /** `--allow-venue-name`: a name that names a venue is created, not refused. */
1140
+ allowVenueName?: boolean;
1057
1141
  },
1058
1142
  ): Promise<number> {
1059
- const result = newPaper(papersRoot, name, format, { venue });
1143
+ const result = newPaper(papersRoot, name, format, {
1144
+ venue,
1145
+ venues: allowVenueName ? [] : shippedVenueNames(PRESET_DEPS),
1146
+ });
1060
1147
  const here = (p: string): string => shown(cwd, p);
1061
1148
  for (const line of reportNewPaper(result, here)) log(line);
1062
1149
  if (!result.ok) return 2;
@@ -1194,7 +1281,7 @@ function venueSpec(
1194
1281
  return bad(
1195
1282
  `--venue ${venue}: no such venue preset. Shipped: ${shipped.join(", ")} — or a path to your own preset, starting with ./ or ../`,
1196
1283
  );
1197
- return { ok: true, value: `${SHIPPED_PREFIX}${name}` };
1284
+ return { ok: true, value: shippedSpec(name) };
1198
1285
  }
1199
1286
 
1200
1287
  /**
@@ -1274,6 +1361,7 @@ async function runNew(
1274
1361
  err,
1275
1362
  cwd,
1276
1363
  venue: venue.value,
1364
+ allowVenueName: a.allowVenueName,
1277
1365
  });
1278
1366
  }
1279
1367
 
@@ -1420,7 +1508,37 @@ const SIMPLE: Readonly<
1420
1508
  banal: hostBanalInstaller(),
1421
1509
  tex: toolchainTex(cwd),
1422
1510
  }),
1511
+ submission: (a, { log, err, cwd }) =>
1512
+ runSubmission(
1513
+ {
1514
+ sub: a.paths[0],
1515
+ paperDir: a.paths[1] ?? ".",
1516
+ extra: a.paths.slice(2),
1517
+ pdf: a.pdf,
1518
+ abstract: a.abstract,
1519
+ submit: a.submit,
1520
+ save: a.save,
1521
+ },
1522
+ {
1523
+ files: nodeFiles,
1524
+ presets: PRESET_DEPS,
1525
+ cwd,
1526
+ env: (name) => process.env[name],
1527
+ portalFor,
1528
+ log,
1529
+ err,
1530
+ },
1531
+ ),
1532
+ };
1533
+
1534
+ /** The adapter of each portal kind — one per kind, chosen here at the root. */
1535
+ const PORTALS: Readonly<
1536
+ Record<PortalKind, (url: string, token: string) => SubmissionPortal>
1537
+ > = {
1538
+ hotcrp: (url, token) => hotcrpPortal({ url, token }),
1423
1539
  };
1540
+ const portalFor = (p: SupportedPortal, token: string): SubmissionPortal =>
1541
+ PORTALS[p.kind](p.url, token);
1424
1542
 
1425
1543
  /** banal's installer, wired from this process's environment: the composition root's work. */
1426
1544
  function hostBanalInstaller(): ToolInstaller {
@@ -1503,6 +1621,7 @@ async function runInit(
1503
1621
  format: isFormat(a.format) ? a.format : null,
1504
1622
  createPaper: (papersRoot, name, format) =>
1505
1623
  createPaperAt(papersRoot, name, format, { log, err, cwd }),
1624
+ venues: shippedVenueNames(PRESET_DEPS),
1506
1625
  tex: initTexLive(a, { log, err, cwd }),
1507
1626
  resolveCliPapers: (root: string): string | null =>
1508
1627
  cliPapers({ ...a, config: null }, root),
@@ -1584,8 +1703,8 @@ export async function run(
1584
1703
  // editing a file.
1585
1704
  //
1586
1705
  // 🔴 A path FROM THE CONFIG is resolved relative to the PROJECT ROOT, not the current directory.
1587
- // Otherwise walking up is pointless: from `papers/aisec-2026` the root would be found, but
1588
- // `"papersDir": "papers"` would point at `papers/aisec-2026/papers`. A command-line argument stays
1706
+ // Otherwise walking up is pointless: from `papers/rule-drift` the root would be found, but
1707
+ // `"papersDir": "papers"` would point at `papers/rule-drift/papers`. A command-line argument stays
1589
1708
  // relative to the current directory: it was typed here and now.
1590
1709
  //
1591
1710
  // Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "vitest";
3
- import { andThen, err, map, match, ok, type Result } from "./result.ts";
3
+ import { andThen, err, isOk, map, match, ok, type Result } from "./result.ts";
4
4
 
5
5
  const half = (n: number): Result<number, string> =>
6
6
  n % 2 === 0 ? ok(n / 2) : err(`${String(n)} is odd`);
@@ -28,3 +28,11 @@ test("match picks the branch by `ok`", () => {
28
28
  assert.equal(say(half(4)), "got 2");
29
29
  assert.equal(say(half(5)), "no: 5 is odd");
30
30
  });
31
+
32
+ test("isOk: filter keeps the successes, and their values are read without a cast", () => {
33
+ const rs = [2, 3, 4].map(half);
34
+ assert.deepEqual(
35
+ rs.filter(isOk).map((r) => r.value),
36
+ [1, 2],
37
+ );
38
+ });
@@ -10,6 +10,11 @@ export type Result<T, E> =
10
10
  export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
11
11
  export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
12
12
 
13
+ /** Whether `r` succeeded — a type guard, so `filter(isOk)` keeps the successes as successes. */
14
+ export const isOk = <T, E>(
15
+ r: Result<T, E>,
16
+ ): r is Extract<Result<T, E>, { readonly ok: true }> => r.ok;
17
+
13
18
  export const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
14
19
  r.ok ? ok(f(r.value)) : r;
15
20
 
@@ -0,0 +1,45 @@
1
+ /** The submission values: a paper's `submission` key, the portal kinds an adapter exists for, words. */
2
+ import assert from "node:assert/strict";
3
+ import { test } from "vitest";
4
+ import {
5
+ parsePaperSubmission,
6
+ supportedPortal,
7
+ wordCount,
8
+ } from "./submission.ts";
9
+
10
+ test("parsePaperSubmission: absent is null; { id } with a positive integer is the value", () => {
11
+ assert.deepEqual(parsePaperSubmission(undefined), { ok: true, value: null });
12
+ assert.deepEqual(parsePaperSubmission({ id: 7 }), {
13
+ ok: true,
14
+ value: { id: 7 },
15
+ });
16
+ });
17
+
18
+ test("🔴 parsePaperSubmission: a string id, zero, a fraction, a list, an unknown key — each refused", () => {
19
+ const shape = `"submission" must be { "id": <the submission number on the venue's portal> }`;
20
+ for (const bad of [{ id: "7" }, { id: 0 }, { id: 1.5 }, [7], 7, null, {}])
21
+ assert.deepEqual(parsePaperSubmission(bad), { ok: false, error: shape });
22
+ assert.deepEqual(parsePaperSubmission({ id: 7, pid: 7 }), {
23
+ ok: false,
24
+ error: `"submission" has an unknown key "pid" — known: id`,
25
+ });
26
+ });
27
+
28
+ test("supportedPortal: hotcrp is, with the trailing slash dropped; any other kind is refused by name", () => {
29
+ assert.deepEqual(
30
+ supportedPortal({ kind: "hotcrp", url: "https://example.test//" }),
31
+ { ok: true, value: { kind: "hotcrp", url: "https://example.test" } },
32
+ );
33
+ assert.deepEqual(
34
+ supportedPortal({ kind: "openreview", url: "https://example.test" }),
35
+ {
36
+ ok: false,
37
+ error: `portal kind "openreview" is not supported; upload by hand`,
38
+ },
39
+ );
40
+ });
41
+
42
+ test("wordCount: runs of non-space, any whitespace between", () => {
43
+ assert.equal(wordCount(""), 0);
44
+ assert.equal(wordCount(" one\ttwo\n\nthree "), 3);
45
+ });