paperlint 6.2.3 → 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 (130) 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/src/adapters/hotcrp/cassette/README.md +24 -0
  94. package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
  95. package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
  96. package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
  97. package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
  98. package/src/adapters/hotcrp/index.ts +11 -0
  99. package/src/adapters/hotcrp/portal.io.ts +130 -0
  100. package/src/adapters/hotcrp/portal.test.ts +503 -0
  101. package/src/adapters/hotcrp/response.ts +181 -0
  102. package/src/cli.test.ts +109 -0
  103. package/src/cli.ts +140 -21
  104. package/src/domain/result.test.ts +9 -1
  105. package/src/domain/result.ts +5 -0
  106. package/src/domain/submission.test.ts +45 -0
  107. package/src/domain/submission.ts +141 -0
  108. package/src/domain/venue-name.test.ts +115 -0
  109. package/src/domain/venue-name.ts +129 -0
  110. package/src/folder-venue-rule.test.ts +182 -0
  111. package/src/folder-venue-rule.ts +141 -0
  112. package/src/init.test.ts +22 -0
  113. package/src/init.ts +9 -2
  114. package/src/new-paper.harness.mjs +11 -9
  115. package/src/new-paper.test.ts +129 -8
  116. package/src/new-paper.ts +38 -7
  117. package/src/paper-settings.test.ts +12 -1
  118. package/src/paper-settings.ts +25 -5
  119. package/src/parse-args.test.ts +19 -0
  120. package/src/ports/submission-portal.ts +26 -0
  121. package/src/presets.test.ts +22 -0
  122. package/src/presets.ts +46 -3
  123. package/src/rule-cases.test.ts +19 -0
  124. package/src/rules-config.ts +1 -1
  125. package/src/submission.test.ts +496 -0
  126. package/src/submission.ts +311 -0
  127. package/src/tex-requirements.ts +8 -0
  128. package/src/tex-venue-rules.test.ts +1 -1
  129. package/src/tex-venue-rules.ts +12 -25
  130. package/src/types.ts +13 -0
@@ -0,0 +1,311 @@
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.
4
+ *
5
+ * The command is named by the job, not by the portal: WHERE the submission lives is data. The venue
6
+ * preset declares the portal (`"portal": { "kind": "hotcrp", "url": … }`), the paper's
7
+ * `paperlint.json` declares which submission is its own (`"submission": { "id": 7 }`), and one
8
+ * adapter per portal kind does the talking (`src/adapters/hotcrp/`).
9
+ *
10
+ * show status, id, title, paper type, topics, abstract length, the PDF the portal holds — and
11
+ * whether it is the local `paper.pdf`, by sha256
12
+ * update send the PDF (and, if asked, an abstract, or "submitted"). A DRY RUN unless `--save`:
13
+ * the portal checks the change and keeps nothing
14
+ *
15
+ * 🔴 THE TOKEN IS AN INPUT, AND ONLY AN INPUT. It comes from the environment (the root reads it), is
16
+ * handed to the adapter, and never appears in anything printed here.
17
+ */
18
+ import { basename, join, resolve } from "node:path";
19
+ import { absolutePath } from "./domain/paths.ts";
20
+ import { err, ok, type Result } from "./domain/result.ts";
21
+ import { sha256Hex, type Sha256 } from "./domain/sha256.ts";
22
+ import {
23
+ supportedPortal,
24
+ wordCount,
25
+ type PortalFailure,
26
+ type PortalMessage,
27
+ type SubmissionView,
28
+ type SupportedPortal,
29
+ type UpdateOutcome,
30
+ } from "./domain/submission.ts";
31
+ import type { Files } from "./ports/files.ts";
32
+ import type { SubmissionPortal } from "./ports/submission-portal.ts";
33
+ import { paperPreset, paperPresetProblem, type PresetDeps } from "./presets.ts";
34
+ import { CONFIG_FILE } from "#lib/paper-config";
35
+
36
+ /** The environment variable each portal kind's token is read from. */
37
+ export const TOKEN_ENV: Readonly<Record<SupportedPortal["kind"], string>> = {
38
+ hotcrp: "HOTCRP_TOKEN",
39
+ };
40
+
41
+ /** Where each portal kind lets an author create a token. */
42
+ const TOKEN_HELP: Readonly<Record<SupportedPortal["kind"], string>> = {
43
+ hotcrp:
44
+ "create one on the HotCRP site under Account settings → Developer, with the scopes submeta:read and document:read (show) and submeta:write and document:write (update)",
45
+ };
46
+
47
+ export const SUBCOMMANDS = ["show", "update"] as const;
48
+ export type Subcommand = (typeof SUBCOMMANDS)[number];
49
+ const isSubcommand = (s: string | undefined): s is Subcommand =>
50
+ SUBCOMMANDS.some((x) => x === s);
51
+
52
+ /** What one `submission` run was asked, after the command line. */
53
+ export interface SubmissionArgs {
54
+ readonly sub: string | undefined;
55
+ readonly paperDir: string;
56
+ /** Positional arguments past the paper directory: refused. */
57
+ readonly extra: readonly string[];
58
+ /** `--pdf <file>`, relative to `cwd`; null: the paper's own PDF. */
59
+ readonly pdf: string | null;
60
+ /** `--abstract <file>`, relative to `cwd`; null: leave the abstract as it is. */
61
+ readonly abstract: string | null;
62
+ readonly submit: boolean;
63
+ readonly save: boolean;
64
+ }
65
+
66
+ export interface SubmissionDeps {
67
+ readonly files: Files;
68
+ readonly presets: PresetDeps;
69
+ readonly cwd: string;
70
+ /** The value of an environment variable; the root reads the environment. */
71
+ readonly env: (name: string) => string | undefined;
72
+ readonly portalFor: (p: SupportedPortal, token: string) => SubmissionPortal;
73
+ readonly log: (line: string) => void;
74
+ readonly err: (line: string) => void;
75
+ }
76
+
77
+ /** A paper's portal, its submission there, and the PDF the paper builds. */
78
+ export interface SubmissionTarget {
79
+ readonly portal: SupportedPortal;
80
+ readonly id: number;
81
+ /** The paper's built PDF, absolute. */
82
+ readonly pdf: string;
83
+ }
84
+
85
+ const configOf = (dir: string): string => join(dir, CONFIG_FILE);
86
+
87
+ /** The paper directory → its portal and submission, or the line naming the file and key to set. */
88
+ export function submissionTarget(
89
+ paperDir: string,
90
+ deps: PresetDeps,
91
+ ): Result<SubmissionTarget, string> {
92
+ const p = paperPreset(paperDir, deps);
93
+ const problem = paperPresetProblem(paperDir, p);
94
+ if (problem !== null) return err(problem);
95
+ if (p.kind !== "resolved")
96
+ return err(
97
+ `${configOf(paperDir)}: no "extends" — the venue preset it names declares the portal ("portal": { "kind": …, "url": … })`,
98
+ );
99
+ const declared = p.preset.portal;
100
+ if (declared === null)
101
+ return err(
102
+ `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`,
103
+ );
104
+ const sub = p.settings.submission;
105
+ if (sub === null)
106
+ return err(
107
+ `${configOf(paperDir)}: no "submission" — add "submission": { "id": <the submission number on ${declared.url}> }`,
108
+ );
109
+ const portal = supportedPortal(declared);
110
+ if (!portal.ok) return portal;
111
+ const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
112
+ return ok({ portal: portal.value, id: sub.id, pdf });
113
+ }
114
+
115
+ /** The token from the environment, or the line naming the variable and where to get one. */
116
+ export function tokenFor(
117
+ portal: SupportedPortal,
118
+ env: (name: string) => string | undefined,
119
+ ): Result<string, string> {
120
+ const name = TOKEN_ENV[portal.kind];
121
+ const v = env(name)?.trim();
122
+ return v
123
+ ? ok(v)
124
+ : err(
125
+ `${name} is not set — the ${portal.kind} API needs a token: ${TOKEN_HELP[portal.kind]}. Then: export ${name}=…`,
126
+ );
127
+ }
128
+
129
+ const iso = (seconds: number | null): string =>
130
+ seconds === null ? "—" : new Date(seconds * 1000).toISOString();
131
+
132
+ const messageLine = (m: PortalMessage): string =>
133
+ ` portal says: ${m.field === null ? "" : `${m.field}: `}${m.message}`;
134
+
135
+ /** A failure in words: HTTP status and the portal's own messages. Never the token. */
136
+ export function failureText(f: PortalFailure): string {
137
+ switch (f.kind) {
138
+ case "refused": {
139
+ const said = f.messages.map((m) => m.message).filter(Boolean);
140
+ return `the portal refused (HTTP ${String(f.httpStatus)})${said.length ? `: ${said.join("; ")}` : ""}`;
141
+ }
142
+ case "unreachable":
143
+ return `the portal cannot be reached: ${f.detail}`;
144
+ case "malformed":
145
+ return `the portal answered HTTP ${String(f.httpStatus)}, but not as its API does: ${f.detail}`;
146
+ }
147
+ }
148
+
149
+ /** Whether the portal holds the local build, in one line. */
150
+ function matchLine(view: SubmissionView, local: Sha256 | null): string {
151
+ if (local === null) return "match unknown — no local PDF to compare";
152
+ if (view.pdf === null) return "match NO — the portal holds no PDF";
153
+ if (view.pdf.sha256 === null)
154
+ return `match unknown — the portal names a ${view.pdf.hash.replace(/-.*$/s, "")} hash, not a sha256`;
155
+ return view.pdf.sha256 === local
156
+ ? "match yes — the portal holds this build"
157
+ : "match NO — the portal holds a different PDF than the local one";
158
+ }
159
+
160
+ const pdfLine = (view: SubmissionView): string =>
161
+ view.pdf === null
162
+ ? "portal PDF none"
163
+ : `portal PDF ${view.pdf.hash}, ${view.pdf.size === null ? "?" : String(view.pdf.size)} bytes, uploaded ${iso(view.pdf.uploadedAt)}`;
164
+
165
+ /** `show`'s report. Pure. */
166
+ export function showLines(
167
+ t: SubmissionTarget,
168
+ view: SubmissionView,
169
+ local: { readonly shown: string; readonly sha256: Sha256 | null },
170
+ ): readonly string[] {
171
+ return [
172
+ `submission ${String(view.id)} on ${t.portal.url} (${t.portal.kind})`,
173
+ `status ${view.status}${view.submittedAt === null ? "" : `, submitted ${iso(view.submittedAt)}`}`,
174
+ `title ${view.title ?? "—"}`,
175
+ `paper type ${view.paperType ?? "—"}`,
176
+ `topics ${view.topics.length ? view.topics.join("; ") : "—"}`,
177
+ `abstract ${view.abstract === null ? "—" : `${String(wordCount(view.abstract))} words`}`,
178
+ pdfLine(view),
179
+ `local PDF ${local.shown} ${local.sha256 === null ? "(missing)" : `sha2-${local.sha256}`}`,
180
+ matchLine(view, local.sha256),
181
+ ...view.messages.map(messageLine),
182
+ ];
183
+ }
184
+
185
+ /** `update`'s report. Pure. */
186
+ export function updateLines(
187
+ o: UpdateOutcome,
188
+ local: { readonly shown: string; readonly sha256: Sha256 },
189
+ save: boolean,
190
+ ): readonly string[] {
191
+ const mode = save
192
+ ? o.dryRun
193
+ ? "SAVE WITHHELD by the portal — nothing changed"
194
+ : "saved"
195
+ : "dry run — the portal checked the change and kept nothing; --save sends it for real";
196
+ return [
197
+ mode,
198
+ `HTTP ${String(o.httpStatus)}`,
199
+ `valid ${o.valid ? "yes" : "NO"}`,
200
+ `changes ${o.changes.length ? o.changes.join(", ") : "—"}`,
201
+ `local PDF ${local.shown} sha2-${local.sha256}`,
202
+ ...o.messages.map(messageLine),
203
+ ];
204
+ }
205
+
206
+ const shownPath = (cwd: string, p: string): string =>
207
+ p.startsWith(`${cwd}/`) ? p.slice(cwd.length + 1) : p;
208
+
209
+ /** The file's bytes, or null when there is none. */
210
+ const bytesAt = (files: Files, p: string): Uint8Array | null =>
211
+ files.readBytes(absolutePath(p));
212
+
213
+ /** `show`: read the submission and compare its PDF with the local one. */
214
+ async function runShow(
215
+ t: SubmissionTarget,
216
+ portal: SubmissionPortal,
217
+ pdfPath: string,
218
+ deps: SubmissionDeps,
219
+ ): Promise<number> {
220
+ const view = await portal.show(t.id);
221
+ if (!view.ok) return (deps.err(failureText(view.error)), 1);
222
+ const bytes = bytesAt(deps.files, pdfPath);
223
+ const local = {
224
+ shown: shownPath(deps.cwd, pdfPath),
225
+ sha256: bytes === null ? null : sha256Hex(bytes),
226
+ };
227
+ showLines(t, view.value, local).forEach((l) => {
228
+ deps.log(l);
229
+ });
230
+ return 0;
231
+ }
232
+
233
+ /** The abstract file's text, or the line saying it is missing; null when none was asked for. */
234
+ function abstractOf(
235
+ a: SubmissionArgs,
236
+ deps: SubmissionDeps,
237
+ ): Result<string | null, string> {
238
+ if (a.abstract === null) return ok(null);
239
+ const p = resolve(deps.cwd, a.abstract);
240
+ const bytes = bytesAt(deps.files, p);
241
+ return bytes === null
242
+ ? err(`--abstract: no file at ${shownPath(deps.cwd, p)}`)
243
+ : ok(new TextDecoder().decode(bytes).trim());
244
+ }
245
+
246
+ /** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
247
+ async function runUpdate(
248
+ t: SubmissionTarget,
249
+ portal: SubmissionPortal,
250
+ a: SubmissionArgs & { readonly pdfPath: string },
251
+ deps: SubmissionDeps,
252
+ ): Promise<number> {
253
+ const bytes = bytesAt(deps.files, a.pdfPath);
254
+ const shown = shownPath(deps.cwd, a.pdfPath);
255
+ if (bytes === null)
256
+ return (
257
+ deps.err(`no PDF at ${shown} — build the paper, or name one with --pdf`),
258
+ 2
259
+ );
260
+ const abstract = abstractOf(a, deps);
261
+ if (!abstract.ok) return (deps.err(abstract.error), 2);
262
+ const change = {
263
+ pdf: { name: basename(a.pdfPath), bytes },
264
+ abstract: abstract.value,
265
+ submit: a.submit,
266
+ };
267
+ const r = await portal.update(t.id, change, { save: a.save });
268
+ if (!r.ok) return (deps.err(failureText(r.error)), 1);
269
+ const local = { shown, sha256: sha256Hex(bytes) };
270
+ updateLines(r.value, local, a.save).forEach((l) => {
271
+ deps.log(l);
272
+ });
273
+ const fine =
274
+ r.value.valid && r.value.httpStatus >= 200 && r.value.httpStatus < 300;
275
+ return fine ? 0 : 1;
276
+ }
277
+
278
+ /** Why the command line cannot be run as asked; null when it can. Pure. */
279
+ export function usageProblem(a: SubmissionArgs): string | null {
280
+ if (!isSubcommand(a.sub))
281
+ return `\`submission\` needs a subcommand: ${SUBCOMMANDS.join(" or ")} — got \`${a.sub ?? ""}\``;
282
+ if (a.extra.length > 0)
283
+ return `\`submission ${a.sub}\` takes one paper folder — got also \`${a.extra.join(" ")}\``;
284
+ const updateOnly = [
285
+ a.abstract === null ? null : "--abstract",
286
+ a.submit ? "--submit" : null,
287
+ a.save ? "--save" : null,
288
+ ].filter((f) => f !== null);
289
+ return a.sub === "show" && updateOnly.length > 0
290
+ ? `${updateOnly.join(", ")}: only \`submission update\` changes anything; \`show\` reads`
291
+ : null;
292
+ }
293
+
294
+ /** `paperlint submission <show|update> [paper-dir]` — exit 0, 1 (the portal said no), or 2 (usage). */
295
+ export async function runSubmission(
296
+ a: SubmissionArgs,
297
+ deps: SubmissionDeps,
298
+ ): Promise<number> {
299
+ const usage = usageProblem(a);
300
+ if (usage !== null) return (deps.err(usage), 2);
301
+ const paperDir = resolve(deps.cwd, a.paperDir);
302
+ const t = submissionTarget(paperDir, deps.presets);
303
+ if (!t.ok) return (deps.err(t.error), 2);
304
+ const token = tokenFor(t.value.portal, deps.env);
305
+ if (!token.ok) return (deps.err(token.error), 2);
306
+ const portal = deps.portalFor(t.value.portal, token.value);
307
+ const pdfPath = a.pdf === null ? t.value.pdf : resolve(deps.cwd, a.pdf);
308
+ return a.sub === "show"
309
+ ? runShow(t.value, portal, pdfPath, deps)
310
+ : runUpdate(t.value, portal, { ...a, pdfPath }, deps);
311
+ }
@@ -34,6 +34,7 @@ import {
34
34
  type VenueTalk,
35
35
  type VenueTalkJson,
36
36
  } from "./domain/talk.ts";
37
+ import type { VenuePortal } from "./domain/submission.ts";
37
38
 
38
39
  /** CTAN package name → the names that prove it is installed. */
39
40
  export type PackageProofs = Readonly<Record<string, readonly string[]>>;
@@ -193,6 +194,8 @@ export interface PresetFile {
193
194
  readonly registerAnchors: readonly RegisterAnchor[] | null;
194
195
  /** What the venue asks a presenter to send; null when the file has no `talk` block. */
195
196
  readonly talk: VenueTalk | null;
197
+ /** Where the venue takes submissions (`paperlint submission`); null when the file does not say. */
198
+ readonly portal: VenuePortal | null;
196
199
  }
197
200
 
198
201
  type KindsJson = Readonly<
@@ -237,6 +240,7 @@ interface PresetJson {
237
240
  } & Readonly<Record<RegisterMeasureName, number>>)[];
238
241
  };
239
242
  readonly talk?: VenueTalkJson;
243
+ readonly portal?: VenuePortal;
240
244
  }
241
245
 
242
246
  /** An optional field as the typed preset holds it: absent is null. */
@@ -342,6 +346,10 @@ export function parsePreset(
342
346
  rules: j.rules ?? {},
343
347
  registerAnchors: anchorsOf(j.register),
344
348
  talk: j.talk === undefined ? null : venueTalkOf(j.talk),
349
+ portal:
350
+ j.portal === undefined
351
+ ? null
352
+ : { kind: j.portal.kind, url: j.portal.url },
345
353
  };
346
354
  }
347
355
 
@@ -719,7 +719,7 @@ describe("otherVenues", () => {
719
719
  const aidc = resolvePreset("paperlint:aidc", from, deps);
720
720
  expect(aidc.ok).toBe(true);
721
721
  if (!aidc.ok) return;
722
- expect(otherVenues(aidc.value, from, deps).map((o) => o.label)).toEqual([
722
+ expect(otherVenues(aidc.value, deps).map((o) => o.label)).toEqual([
723
723
  "acm-sigconf",
724
724
  "agenticdev",
725
725
  "aisec",
@@ -27,8 +27,7 @@
27
27
  * `pdf/profile` already say so, once. A preset that names no template: silent — there is nothing to
28
28
  * compare, and a template-family preset is where it is named.
29
29
  */
30
- import { basename, dirname, join } from "node:path";
31
- import { CONFIG_FILE } from "#lib/paper-config";
30
+ import { basename, dirname } from "node:path";
32
31
  import {
33
32
  collapse,
34
33
  documentClassLine,
@@ -46,13 +45,8 @@ import {
46
45
  type TextRun,
47
46
  } from "./domain/tex-document.ts";
48
47
  import type { LatexReader } from "./ports/latex.ts";
49
- import {
50
- paperPreset,
51
- resolvePreset,
52
- SHIPPED_PREFIX,
53
- shippedPresets,
54
- type Preset,
55
- } from "./presets.ts";
48
+ import { paperPreset, shippedVenueNames, type Preset } from "./presets.ts";
49
+ import type { NamedVenue } from "./domain/venue-name.ts";
56
50
  import type { PaperSource } from "./domain/paper-source.ts";
57
51
  import { readPaper, reportInPaper } from "./tex-paper.ts";
58
52
  import type { RequiredSection } from "./tex-requirements.ts";
@@ -255,10 +249,7 @@ export function judgeRequiredSections(
255
249
  }
256
250
 
257
251
  /** Another shipped venue: the word for it, and what it is called in a paper's text. */
258
- export interface OtherVenue {
259
- readonly label: string;
260
- readonly aliases: readonly string[];
261
- }
252
+ export type OtherVenue = NamedVenue;
262
253
 
263
254
  /** `s` as a pattern that matches it literally. */
264
255
  const literal = (s: string): string =>
@@ -440,21 +431,17 @@ export const FORMAT_RULE_LEVELS: Readonly<
440
431
  };
441
432
 
442
433
  /**
443
- * Every shipped venue but the ones on this paper's own chain, each resolved the way a paper
444
- * extending it would be. A preset that does not resolve is skipped: `pdf/profile` owns that.
434
+ * Every shipped venue but the ones on this paper's own chain, from the one source of venue names
435
+ * (`shippedVenueNames`). A preset all of whose files are on this paper's chain is this venue or one
436
+ * it extends.
445
437
  */
446
438
  export function otherVenues(
447
- preset: Preset,
448
- fromFile: string,
439
+ preset: Pick<Preset, "chain">,
449
440
  deps: VenueRuleDeps,
450
441
  ): readonly OtherVenue[] {
451
- return shippedPresets(deps.venuesDir).flatMap((name) => {
452
- const r = resolvePreset(`${SHIPPED_PREFIX}${name}`, fromFile, deps);
453
- // A preset all of whose files are on this paper's chain is this venue or one it extends.
454
- return r.ok && !r.value.chain.every((f) => preset.chain.includes(f))
455
- ? [{ label: r.value.label, aliases: r.value.aliases }]
456
- : [];
457
- });
442
+ return shippedVenueNames(deps)
443
+ .filter((v) => !v.chain.every((f) => preset.chain.includes(f)))
444
+ .map(({ label, aliases }) => ({ label, aliases }));
458
445
  }
459
446
 
460
447
  /** What the venue-conformance rules are built with: the preset store, and the LaTeX reader. */
@@ -476,7 +463,7 @@ function readingOf(
476
463
  const p = paperPreset(dir, deps);
477
464
  if (p.kind !== "resolved") return null;
478
465
  const preset = p.preset;
479
- const others = () => otherVenues(preset, join(dir, CONFIG_FILE), deps);
466
+ const others = () => otherVenues(preset, deps);
480
467
  const paper = readPaper(filename, src, deps);
481
468
  return {
482
469
  reading: { src: paper.text, latex: deps.latex, preset, others },
package/src/types.ts CHANGED
@@ -68,6 +68,19 @@ export interface Args {
68
68
  venue: string | null;
69
69
  /** `--kind <kind>` for `new`: one of the venue preset's kinds. */
70
70
  kind: string | null;
71
+ /** `--pdf <file>` for `submission`: the PDF to compare or send; null is the paper's own. */
72
+ pdf: string | null;
73
+ /** `--abstract <file>` for `submission update`: a plain-text abstract to send. */
74
+ abstract: string | null;
75
+ /** `--submit` for `submission update`: mark the submission submitted. */
76
+ submit: boolean;
77
+ /** `--save` for `submission update`: really change the submission; without it, a dry run. */
78
+ save: boolean;
79
+ /**
80
+ * `--allow-venue-name` for `new`: create the folder even though its name names a venue. Skips
81
+ * that one refusal and no other.
82
+ */
83
+ allowVenueName: boolean;
71
84
  /** `--hooks=<mode>` — parsed only so it can be REFUSED by name rather than read as a path. */
72
85
  hooksMode: string | null;
73
86
  help?: boolean;