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
@@ -75,22 +75,23 @@ try {
75
75
 
76
76
  // ── names ───────────────────────────────────────────────────────────────────────────
77
77
  for (const ok of ["demo", "aisec-2026", "v1.2_final"])
78
- check(`\`${ok}\` is a valid name`, nameProblem(ok) === null);
78
+ check(`\`${ok}\` is a valid name`, nameProblem(ok, []) === null);
79
79
  for (const bad of ["Demo", "my paper", "a/b", "../x", "", "ÿ"])
80
- check(`\`${bad}\` is refused`, typeof nameProblem(bad) === "string");
80
+ check(`\`${bad}\` is refused`, typeof nameProblem(bad, []) === "string");
81
81
  for (const dot of [".template", ".x", ".."])
82
82
  check(
83
83
  `\`${dot}\` is refused — discovery skips dot-directories, so it would never be linted`,
84
- /starting with a dot/.test(nameProblem(dot) ?? ""),
84
+ /starting with a dot/.test(nameProblem(dot, []) ?? ""),
85
85
  );
86
86
  check(
87
87
  "a refused name creates nothing",
88
- newPaper(work, "Bad", "tex").ok === false && !existsSync(join(work, "Bad")),
88
+ newPaper(work, "Bad", "tex", { venues: [] }).ok === false &&
89
+ !existsSync(join(work, "Bad")),
89
90
  );
90
91
 
91
92
  // ── a fresh LaTeX paper ─────────────────────────────────────────────────────────────
92
93
  const papers = join(work, "papers");
93
- const r = newPaper(papers, "demo", "tex");
94
+ const r = newPaper(papers, "demo", "tex", { venues: [] });
94
95
  check(
95
96
  "a fresh folder gets the scorecard, a .tex source and paperlint.json, all from the package",
96
97
  r.ok &&
@@ -128,7 +129,7 @@ try {
128
129
 
129
130
  // ── never overwrite; on an existing folder only the missing files ─────────────────────
130
131
  writeFileSync(join(papers, "demo", STATUS_FILE), "mine\n");
131
- const again = newPaper(papers, "demo", "md");
132
+ const again = newPaper(papers, "demo", "md", { venues: [] });
132
133
  check(
133
134
  "🔴 a second run overwrites nothing and adds no second source",
134
135
  again.ok &&
@@ -139,7 +140,7 @@ try {
139
140
  );
140
141
  mkdirSync(join(papers, "old"), { recursive: true });
141
142
  writeFileSync(join(papers, "old", "paper.md"), "# Old\n");
142
- const migrated = newPaper(papers, "old", "tex");
143
+ const migrated = newPaper(papers, "old", "tex", { venues: [] });
143
144
  check(
144
145
  "an old folder with only paper.md gets ONLY the scorecard — that is the migration",
145
146
  migrated.ok &&
@@ -157,7 +158,7 @@ try {
157
158
  writeFileSync(join(papers, "afile"), "x");
158
159
  check(
159
160
  "a name taken by a FILE is refused, not written through",
160
- newPaper(papers, "afile", "tex").ok === false,
161
+ newPaper(papers, "afile", "tex", { venues: [] }).ok === false,
161
162
  );
162
163
 
163
164
  // ── the project's template wins, file by file ─────────────────────────────────────────
@@ -166,7 +167,7 @@ try {
166
167
  join(papers, OVERRIDE_DIR, STATUS_FILE),
167
168
  "---\n---\n# House scorecard for {{name}}\n",
168
169
  );
169
- const own = newPaper(papers, "house", "md");
170
+ const own = newPaper(papers, "house", "md", { venues: [] });
170
171
  // Guards: the override slot — `fromTemplate` in new-paper.ts reads `<papers>/.template/<file>`
171
172
  // before the package's copy. Replace that lookup with the package's alone and this goes red.
172
173
  check(
@@ -180,6 +181,7 @@ try {
180
181
 
181
182
  // ── a missing template is a broken install, not an empty file ─────────────────────────
182
183
  const broken = newPaper(join(work, "b"), "x", "tex", {
184
+ venues: [],
183
185
  packageTemplates: join(work, "no-such-dir"),
184
186
  });
185
187
  check(
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  mkdirSync,
8
8
  mkdtempSync,
9
+ readdirSync,
9
10
  readFileSync,
10
11
  realpathSync,
11
12
  rmSync,
@@ -40,7 +41,7 @@ const settingsOf = (dir: string) =>
40
41
  describe("paperlint new — paperlint.json", () => {
41
42
  it("is always written, from the package template: no venue chosen yet, and valid", () => {
42
43
  const papers = join(tmp(), "papers");
43
- const r = newPaper(papers, "demo", "tex");
44
+ const r = newPaper(papers, "demo", "tex", { venues: [] });
44
45
  expect(r.ok && r.files.find((f) => f.file === "paperlint.json")).toEqual({
45
46
  file: "paperlint.json",
46
47
  status: "created",
@@ -58,6 +59,7 @@ describe("paperlint new — paperlint.json", () => {
58
59
  rules: null,
59
60
  identity: null,
60
61
  talk: null,
62
+ submission: null,
61
63
  },
62
64
  });
63
65
  });
@@ -69,7 +71,7 @@ describe("paperlint new — paperlint.json", () => {
69
71
  join(papers, OVERRIDE_DIR, "paperlint.json"),
70
72
  '{ "extends": "paperlint:aisec", "kind": "research" }\n',
71
73
  );
72
- const r = newPaper(papers, "house", "tex");
74
+ const r = newPaper(papers, "house", "tex", { venues: [] });
73
75
  expect(r.ok && r.files.find((f) => f.file === "paperlint.json")?.from).toBe(
74
76
  "project",
75
77
  );
@@ -86,7 +88,7 @@ describe("paperlint new — paperlint.json", () => {
86
88
  join(papers, "p", "paperlint.json"),
87
89
  '{"extends":"paperlint:aisec"}',
88
90
  );
89
- const r = newPaper(papers, "p", "tex");
91
+ const r = newPaper(papers, "p", "tex", { venues: [] });
90
92
  expect(
91
93
  r.ok && r.files.find((f) => f.file === "paperlint.json")?.status,
92
94
  ).toBe("kept");
@@ -103,7 +105,7 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
103
105
  join(root, "package.json"),
104
106
  JSON.stringify({ name: "c", private: true }),
105
107
  );
106
- newPaper(join(root, "papers"), "demo", "tex");
108
+ newPaper(join(root, "papers"), "demo", "tex", { venues: [] });
107
109
  if (extendsValue !== null)
108
110
  writeFileSync(
109
111
  join(root, "papers", "demo", "paperlint.json"),
@@ -140,7 +142,11 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
140
142
  });
141
143
 
142
144
  /** `paperlint new <args>` in a fresh project (not a terminal: vitest's stdin is not a TTY). */
143
- async function newIn(args: string[], files: Record<string, string> = {}) {
145
+ async function newIn(
146
+ args: string[],
147
+ files: Record<string, string> = {},
148
+ name = "demo",
149
+ ) {
144
150
  const root = tmp();
145
151
  const all = {
146
152
  "package.json": JSON.stringify({ name: "c", private: true }),
@@ -152,12 +158,12 @@ async function newIn(args: string[], files: Record<string, string> = {}) {
152
158
  }
153
159
  const out: string[] = [];
154
160
  const err: string[] = [];
155
- const code = await run(["new", "demo", ...args], {
161
+ const code = await run(["new", name, ...args], {
156
162
  cwd: root,
157
163
  log: (s: string) => out.push(s),
158
164
  err: (s: string) => err.push(s),
159
165
  });
160
- const dir = join(root, "papers", "demo");
166
+ const dir = join(root, "papers", name);
161
167
  return {
162
168
  code,
163
169
  out: out.join("\n"),
@@ -286,6 +292,117 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
286
292
  });
287
293
  });
288
294
 
295
+ describe("paperlint new — a name that names a venue is refused", () => {
296
+ it.each<[string, string, string[], string]>([
297
+ [
298
+ "a venue's label, with the work's name left to suggest",
299
+ "aisec-agent-drift",
300
+ [],
301
+ '`aisec-agent-drift` — the paper folder names a venue ("aisec"); venues change on resubmission, so name it after the work (e.g. "agent-drift")',
302
+ ],
303
+ [
304
+ "nothing but a year left: no example",
305
+ "aisec-2026",
306
+ [],
307
+ '`aisec-2026` — the paper folder names a venue ("aisec"); venues change on resubmission, so name it after the work',
308
+ ],
309
+ [
310
+ "its own venue too: right today, stale on the next resubmission",
311
+ "aidc-2026",
312
+ ["--venue", "aidc"],
313
+ '`aidc-2026` — the paper folder names a venue ("aidc"); venues change on resubmission, so name it after the work',
314
+ ],
315
+ [
316
+ "by an alias, glued to a year",
317
+ "acsac2026-rules",
318
+ [],
319
+ '`acsac2026-rules` — the paper folder names a venue ("ACSAC"); venues change on resubmission, so name it after the work',
320
+ ],
321
+ [
322
+ "two venues",
323
+ "realm-aisec-rules",
324
+ [],
325
+ '`realm-aisec-rules` — the paper folder names a venue ("aisec", "realm"); venues change on resubmission, so name it after the work (e.g. "rules")',
326
+ ],
327
+ ])("%s: exit 2, nothing written", async (_, name, args, reason) => {
328
+ const r = await newIn(args, {}, name);
329
+ expect(r.code).toBe(2);
330
+ expect(r.out).toBe(
331
+ ` ✗ ${reason}\n if the venue really belongs in the name, rerun \`paperlint new\` with --allow-venue-name`,
332
+ );
333
+ expect(existsSync(r.dir)).toBe(false);
334
+ });
335
+
336
+ it("an existing folder that names a venue gets no missing files either", async () => {
337
+ const r = await newIn(
338
+ [],
339
+ { "papers/aisec-2026/paper.tex": "x" },
340
+ "aisec-2026",
341
+ );
342
+ expect(r.code).toBe(2);
343
+ expect(readdirSync(r.dir)).toEqual(["paper.tex"]);
344
+ });
345
+
346
+ it.each(["agent-rule-drift", "overrealm-realms"])(
347
+ "%s names no venue: created, as before",
348
+ async (name) => {
349
+ const r = await newIn([], {}, name);
350
+ expect(r.out).not.toMatch(/names a venue/);
351
+ expect(existsSync(join(r.dir, "PIPELINE-STATUS.md"))).toBe(true);
352
+ expect(r.code).toBe(0);
353
+ },
354
+ );
355
+ });
356
+
357
+ describe("paperlint new --allow-venue-name", () => {
358
+ it("the folder is created as any other, and nothing is said about the venue", async () => {
359
+ const r = await newIn(["--allow-venue-name"], {}, "realm-of-agents");
360
+ expect(r.code).toBe(0);
361
+ expect(r.out).not.toMatch(/venue really belongs|names a venue/);
362
+ expect(readdirSync(r.dir).sort()).toEqual([
363
+ "PIPELINE-STATUS.md",
364
+ "paper.tex",
365
+ "paperlint.json",
366
+ ]);
367
+ });
368
+
369
+ it.each<{
370
+ readonly what: string;
371
+ readonly name: string;
372
+ readonly args: readonly string[];
373
+ readonly files: Record<string, string>;
374
+ readonly says: RegExp;
375
+ }>([
376
+ {
377
+ what: "an invalid name",
378
+ name: "Realm",
379
+ args: [],
380
+ files: {},
381
+ says: /may hold only a-z/,
382
+ },
383
+ {
384
+ what: "an unknown preset",
385
+ name: "realm-of-agents",
386
+ args: ["--venue", "nope"],
387
+ files: {},
388
+ says: /no such venue preset/,
389
+ },
390
+ {
391
+ what: "an existing paperlint.json with --venue",
392
+ name: "realm-of-agents",
393
+ args: ["--venue", "aisec"],
394
+ files: {
395
+ "papers/realm-of-agents/paperlint.json": '{ "extends": null }\n',
396
+ },
397
+ says: /already exists and is never overwritten/,
398
+ },
399
+ ])("skips no other refusal: $what", async ({ name, args, files, says }) => {
400
+ const r = await newIn(["--allow-venue-name", ...args], files, name);
401
+ expect(r.code).toBe(2);
402
+ expect(`${r.out}\n${r.err}`).toMatch(says);
403
+ });
404
+ });
405
+
289
406
  describe("chooseVenue — on a terminal", () => {
290
407
  const at = (answers: string[]) => {
291
408
  const asked: string[] = [];
@@ -340,6 +457,7 @@ describe("a project's own template", () => {
340
457
  const src = join(papers, OVERRIDE_DIR, "paperlint.json");
341
458
  writeFileSync(src, '{\n // our house venue\n "extends": null\n}\n');
342
459
  const r = newPaper(papers, "p", "tex", {
460
+ venues: [],
343
461
  venue: { extends: "paperlint:aisec", kind: null },
344
462
  });
345
463
  expect(r).toEqual({
@@ -353,7 +471,10 @@ describe("a project's own template", () => {
353
471
  const papers = join(tmp(), "papers");
354
472
  mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
355
473
  writeFileSync(join(papers, OVERRIDE_DIR, "paperlint.json"), "{}\n");
356
- const lines = reportNewPaper(newPaper(papers, "p", "tex"), (p) => p);
474
+ const lines = reportNewPaper(
475
+ newPaper(papers, "p", "tex", { venues: [] }),
476
+ (p) => p,
477
+ );
357
478
  expect(lines).toContain(
358
479
  " + paperlint.json (from the project's .template/ template)",
359
480
  );
package/src/new-paper.ts CHANGED
@@ -27,6 +27,11 @@ import { join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { CONFIG_FILE } from "#lib/paper-config";
29
29
  import { isRecord } from "./domain/record.ts";
30
+ import {
31
+ venuesNamedBy,
32
+ withoutVenues,
33
+ type NamedVenue,
34
+ } from "./domain/venue-name.ts";
30
35
 
31
36
  export type PaperFormat = "tex" | "md";
32
37
  export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
@@ -50,6 +55,9 @@ export const SOURCE_FILE: Readonly<Record<PaperFormat, string>> = {
50
55
  md: "paper.md",
51
56
  };
52
57
 
58
+ /** The flag that lets `paperlint new` create a folder whose name names a venue. */
59
+ export const ALLOW_VENUE_NAME = "--allow-venue-name";
60
+
53
61
  export const isFormat = (v: unknown): v is PaperFormat =>
54
62
  FORMATS.some((f) => f === v);
55
63
 
@@ -57,13 +65,30 @@ export const isFormat = (v: unknown): v is PaperFormat =>
57
65
  * Why a name is refused, or null. `[a-z0-9._-]+` is the charset; a LEADING dot is refused on top
58
66
  * of it, because discovery skips dot-directories — a paper named `.x` would be created and then
59
67
  * never linted, which is worse than a refusal.
68
+ *
69
+ * A name that names a venue — one of `venues`' names as whole words (`aisec-2026`) — is refused
70
+ * too, the paper's own venue included: a paper that is rejected goes to another venue and keeps its
71
+ * folder, so the name goes stale, and nothing at creation time knows the paper will not move. A
72
+ * folder made by hand, and one made with `--allow-venue-name` (a whole word can be the work's own:
73
+ * a paper about realms), is what `paper/folder-venue-leftover` judges once the venue changes. The
74
+ * flag passes no `venues`, so it skips this refusal and no other.
60
75
  */
61
- export function nameProblem(name: string): string | null {
76
+ export function nameProblem(
77
+ name: string,
78
+ venues: readonly NamedVenue[],
79
+ ): string | null {
62
80
  if (!/^[a-z0-9._-]+$/.test(name))
63
81
  return `\`${name}\` — a paper name may hold only a-z, 0-9, dot, underscore and hyphen (it becomes a path and a shell argument)`;
64
82
  if (name.startsWith("."))
65
83
  return `\`${name}\` — a name starting with a dot is skipped by paper discovery, so it would never be linted`;
66
- return null;
84
+ const named = venuesNamedBy(name, venues);
85
+ if (named.length === 0) return null;
86
+ const which = named.map((n) => `"${n.name}"`).join(", ");
87
+ const instead = withoutVenues(name, venues);
88
+ return [
89
+ `\`${name}\` — the paper folder names a venue (${which}); venues change on resubmission, so name it after the work${instead === null ? "" : ` (e.g. "${instead}")`}`,
90
+ ` if the venue really belongs in the name, rerun \`paperlint new\` with ${ALLOW_VENUE_NAME}`,
91
+ ].join("\n");
67
92
  }
68
93
 
69
94
  export interface FileOutcome {
@@ -180,17 +205,23 @@ function fromTemplate(
180
205
  : edited;
181
206
  }
182
207
 
208
+ /** What `newPaper` is told besides the name: the venues a name may not name, and what to write. */
209
+ export interface NewPaperOptions {
210
+ readonly packageTemplates?: string;
211
+ readonly venue?: VenueSetting | null;
212
+ /** Every venue paperlint knows (`shippedVenueNames`): a name naming one is refused. */
213
+ readonly venues: readonly NamedVenue[];
214
+ }
215
+
183
216
  // Documented in README.md#getting-started — update it when this changes.
184
217
  export function newPaper(
185
218
  papersRoot: string,
186
219
  name: string,
187
220
  format: PaperFormat,
188
- {
189
- packageTemplates = PACKAGE_TEMPLATES,
190
- venue = null,
191
- }: { packageTemplates?: string; venue?: VenueSetting | null } = {},
221
+ opts: NewPaperOptions,
192
222
  ): NewPaperResult {
193
- const problem = nameProblem(name);
223
+ const { packageTemplates = PACKAGE_TEMPLATES, venue = null } = opts;
224
+ const problem = nameProblem(name, opts.venues);
194
225
  if (problem) return { ok: false, reason: problem };
195
226
  const dir = join(papersRoot, name);
196
227
  const fresh = !existsSync(dir);
@@ -32,6 +32,7 @@ describe("parsePaperSettings", () => {
32
32
  rules: null,
33
33
  identity: null,
34
34
  talk: null,
35
+ submission: null,
35
36
  },
36
37
  });
37
38
  expect(parsePaperSettings({})).toEqual({
@@ -43,6 +44,7 @@ describe("parsePaperSettings", () => {
43
44
  rules: null,
44
45
  identity: null,
45
46
  talk: null,
47
+ submission: null,
46
48
  },
47
49
  });
48
50
  });
@@ -57,6 +59,7 @@ describe("parsePaperSettings", () => {
57
59
  rules: null,
58
60
  identity: null,
59
61
  talk: null,
62
+ submission: null,
60
63
  },
61
64
  });
62
65
  });
@@ -155,7 +158,13 @@ describe("readPaperSettings — a root paperlint.json that cannot be used", () =
155
158
  });
156
159
 
157
160
  /** The fields a case does not set. */
158
- const UNSET = { pdf: null, rules: null, identity: null, talk: null };
161
+ const UNSET = {
162
+ pdf: null,
163
+ rules: null,
164
+ identity: null,
165
+ talk: null,
166
+ submission: null,
167
+ };
159
168
 
160
169
  describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
161
170
  const ROOT = "/work";
@@ -210,6 +219,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
210
219
  rules: { "pdf/fonts": "off" },
211
220
  identity: null,
212
221
  talk: null,
222
+ submission: null,
213
223
  });
214
224
  });
215
225
 
@@ -253,6 +263,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
253
263
  rules,
254
264
  identity: null,
255
265
  talk: null,
266
+ submission: null,
256
267
  });
257
268
 
258
269
  it("no rules: none", () => {
@@ -4,7 +4,7 @@
4
4
  * One file name at two levels, one schema (lib/paper-config.mjs → SETTINGS_KEYS):
5
5
  *
6
6
  * paperlint.json the project (optional): papersDir, rules, defaults
7
- * <paper>/paperlint.json this paper: { extends, kind, pdf, rules }
7
+ * <paper>/paperlint.json this paper: { extends, kind, pdf, rules, identity, talk, submission }
8
8
  * a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
9
9
  *
10
10
  * The paper's `extends`, `kind` and `pdf` win over the root's; with no own value, the root's is
@@ -32,6 +32,10 @@ import {
32
32
  } from "./rules-config.ts";
33
33
  import { messageOf } from "./domain/text.ts";
34
34
  import { parsePaperTalk, type PaperTalk } from "./domain/talk.ts";
35
+ import {
36
+ parsePaperSubmission,
37
+ type PaperSubmission,
38
+ } from "./domain/submission.ts";
35
39
 
36
40
  /** What one paper declares. Every absent field is null. */
37
41
  export interface PaperSettings {
@@ -54,6 +58,8 @@ export interface PaperSettings {
54
58
  readonly identity: readonly string[] | null;
55
59
  /** How this paper is presented (`talk/*`); null when it declares no talk. Never the root's. */
56
60
  readonly talk: PaperTalk | null;
61
+ /** Which submission on the venue's portal is this paper's; null when it says none. Never the root's. */
62
+ readonly submission: PaperSubmission | null;
57
63
  }
58
64
 
59
65
  /** Why a paper's settings cannot be read. */
@@ -112,17 +118,25 @@ export function identityField(
112
118
  );
113
119
  }
114
120
 
115
- /** `rules` (checked for shape only) and `talk`, each null when absent. */
121
+ /** `rules` (checked for shape only), `talk` and `submission`, each null when absent. */
116
122
  function rulesAndTalk(
117
123
  json: Readonly<Record<string, unknown>>,
118
- ): Result<Pick<PaperSettings, "rules" | "talk">, string> {
124
+ ): Result<Pick<PaperSettings, "rules" | "talk" | "submission">, string> {
119
125
  const rules = json["rules"];
120
126
  if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
121
127
  return err(
122
128
  `"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
123
129
  );
124
130
  const talk = parsePaperTalk(json["talk"]);
125
- return talk.ok ? ok({ rules: rules ?? null, talk: talk.value }) : talk;
131
+ if (!talk.ok) return talk;
132
+ const submission = parsePaperSubmission(json["submission"]);
133
+ return submission.ok
134
+ ? ok({
135
+ rules: rules ?? null,
136
+ talk: talk.value,
137
+ submission: submission.value,
138
+ })
139
+ : submission;
126
140
  }
127
141
 
128
142
  /** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
@@ -232,7 +246,12 @@ function merge(
232
246
  paper: PaperSettings | null,
233
247
  ): PaperSettings | null {
234
248
  const r = root ?? NO_DEFAULTS;
235
- const p = paper ?? { ...NO_DEFAULTS, rules: null, talk: null };
249
+ const p = paper ?? {
250
+ ...NO_DEFAULTS,
251
+ rules: null,
252
+ talk: null,
253
+ submission: null,
254
+ };
236
255
  const merged: PaperSettings = {
237
256
  extends: p.extends ?? r.extends,
238
257
  kind: p.kind ?? r.kind,
@@ -240,6 +259,7 @@ function merge(
240
259
  rules: p.rules,
241
260
  identity: joined(r.identity, p.identity),
242
261
  talk: p.talk,
262
+ submission: p.submission,
243
263
  };
244
264
  return paper === null && Object.values(merged).every((v) => v === null)
245
265
  ? null
@@ -35,6 +35,25 @@ describe("parseArgs — `--flag=value` is the same as `--flag value`", () => {
35
35
  });
36
36
  });
37
37
 
38
+ describe("parseArgs — the switches, each turning one field on", () => {
39
+ it.each([
40
+ ["--json", "json"],
41
+ ["--fix", "fix"],
42
+ ["--all", "all"],
43
+ ["--dry-run", "dryRun"],
44
+ ["--check", "check"],
45
+ ["--yes", "yes"],
46
+ ["-y", "yes"],
47
+ ["--no-hooks", "noHooks"],
48
+ ["--allow-venue-name", "allowVenueName"],
49
+ ] as const)("%s → %s", (flag, key) => {
50
+ const off = parseArgs(["new", "x"]);
51
+ const on = parseArgs(["new", "x", flag]);
52
+ expect(off[key]).toBe(false);
53
+ expect(on).toEqual({ ...off, [key]: true });
54
+ });
55
+ });
56
+
38
57
  describe("an unknown flag is refused, not read as a path", () => {
39
58
  it("exits 2 and names the flag", async () => {
40
59
  const err: string[] = [];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A conference portal holding a paper's submission — the port `paperlint submission` talks through.
3
+ * Shaped by what the command needs (read one submission, send one change), not by any portal's API;
4
+ * one adapter per portal kind (`src/adapters/hotcrp/`).
5
+ */
6
+ import type { Result } from "../domain/result.ts";
7
+ import type {
8
+ PortalFailure,
9
+ SubmissionChange,
10
+ SubmissionView,
11
+ UpdateOutcome,
12
+ } from "../domain/submission.ts";
13
+
14
+ export interface SubmissionPortal {
15
+ /** The submission as the portal holds it now. */
16
+ show(id: number): Promise<Result<SubmissionView, PortalFailure>>;
17
+ /**
18
+ * Send a change. Without `save` the portal only checks it (a dry run) and keeps nothing; an
19
+ * answer the portal gave — valid or not — is an outcome, not a failure.
20
+ */
21
+ update(
22
+ id: number,
23
+ change: SubmissionChange,
24
+ o: { readonly save: boolean },
25
+ ): Promise<Result<UpdateOutcome, PortalFailure>>;
26
+ }
@@ -16,6 +16,7 @@ import {
16
16
  MAX_PRESET_DEPTH,
17
17
  SHIPPED_PREFIX,
18
18
  shippedPresets,
19
+ shippedVenueNames,
19
20
  labelOf,
20
21
  presetProblemText,
21
22
  resolvePreset,
@@ -297,3 +298,24 @@ describe("labelOf — display only, never used to resolve", () => {
297
298
  expect(labelOf(spec)).toBe(label);
298
299
  });
299
300
  });
301
+
302
+ describe("shippedVenueNames — what each shipped venue is called", () => {
303
+ it("every shipped preset, by its label and the names along its chain", () => {
304
+ const names = shippedVenueNames(deps());
305
+ expect(names.map((n) => n.label)).toEqual(shippedPresets(VENUES));
306
+ expect(names.find((n) => n.label === "aisec")?.aliases).toEqual(
307
+ expect.arrayContaining(["AISec", "ACM CCS"]),
308
+ );
309
+ });
310
+
311
+ it("a preset that does not resolve is left out", () => {
312
+ const files = memoryFiles(
313
+ Object.fromEntries(
314
+ Object.entries(shipped).filter(([f]) => !f.endsWith("realm.jsonc")),
315
+ ),
316
+ );
317
+ expect(
318
+ shippedVenueNames({ files, venuesDir: VENUES }).map((n) => n.label),
319
+ ).toEqual(shippedPresets(VENUES).filter((n) => n !== "realm"));
320
+ });
321
+ });