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,141 @@
1
+ /**
2
+ * `paper/folder-venue-leftover` — a paper folder named after a venue the paper no longer targets.
3
+ *
4
+ * A folder named `aisec-2026` reads as "the AISec paper" for as long as it exists; once the paper is
5
+ * rejected and goes to another venue, the name is wrong and nothing says so. paperlint already knows
6
+ * the venue a paper targets — its `paperlint.json` `extends` — and the names of every venue it ships
7
+ * a preset for, so the stale case costs one comparison. The sibling of `tex/venue-leftover`, which
8
+ * does the same for the text of the paper.
9
+ *
10
+ * ── WHAT IT READS ────────────────────────────────────────────────────────────────
11
+ * The folder's name, as the path module gives it (the directory of the linted file), its words
12
+ * against each shipped venue's label and `aliases` (`src/domain/venue-name.ts`). The venues are
13
+ * `otherVenues` — the presets not on this paper's own chain, the same list `tex/venue-leftover`
14
+ * reads — and the paper's own label and aliases are never reported.
15
+ *
16
+ * ── WHICH FILE IT REPORTS ON ─────────────────────────────────────────────────────
17
+ * The folder itself is not a file ESLint lints. `PIPELINE-STATUS.md` is: every paper folder has one
18
+ * (`paperlint lint` requires it, `paperlint new` writes it), whatever the paper's source format, so
19
+ * the finding is made once per paper, on that file's first line, where a disable directive can
20
+ * also sit.
21
+ *
22
+ * ── WHO SPEAKS WHEN THERE IS NOTHING TO JUDGE ───────────────────────────────────
23
+ * No `paperlint.json`, no `extends`, or one that does not resolve: silent. With no venue chosen
24
+ * there is no "other" venue, and `pdf/measured` and `pdf/profile` already speak about the rest. An
25
+ * `extends` that names only a template family is silent too (`isFamily`).
26
+ */
27
+ import { basename, dirname } from "node:path";
28
+ import { venuesNamedBy, type NamedIn } from "./domain/venue-name.ts";
29
+ import { paperPreset, type Preset } from "./presets.ts";
30
+ import { otherVenues, type OtherVenue } from "./tex-venue-rules.ts";
31
+ import type { Finding, VenueRuleDeps } from "./venue-rules.ts";
32
+
33
+ /** The file the rule reports on: one per paper folder. */
34
+ const STATUS_FILE = "PIPELINE-STATUS.md";
35
+
36
+ /** The slice of ESLint's rule context this rule uses: the file, its first line, and `report`. */
37
+ export interface FolderRuleContext {
38
+ readonly filename: string;
39
+ readonly sourceCode: {
40
+ getLocFromIndex(index: number): Readonly<{ line: number; column: number }>;
41
+ };
42
+ report(d: {
43
+ readonly loc: {
44
+ readonly start: { line: number; column: number };
45
+ readonly end: { line: number; column: number };
46
+ };
47
+ readonly messageId: string;
48
+ readonly data: Readonly<Record<string, string | number>>;
49
+ }): void;
50
+ }
51
+
52
+ export interface FolderRuleModule {
53
+ readonly meta: {
54
+ readonly type: "suggestion";
55
+ readonly docs: { readonly description: string; readonly url: string };
56
+ readonly schema: readonly object[];
57
+ readonly messages: Readonly<Record<string, string>>;
58
+ };
59
+ create(context: FolderRuleContext): { root?: () => void };
60
+ }
61
+
62
+ /** A venue as the message names it: its label, and the alias when the folder used another name. */
63
+ const shown = (n: NamedIn): string =>
64
+ n.name.toLowerCase() === n.venue.toLowerCase()
65
+ ? n.venue
66
+ : `${n.venue} («${n.name}»)`;
67
+
68
+ /**
69
+ * Each other venue the folder name names, as a finding. Pure: the folder's name, the paper's
70
+ * resolved preset, and the other shipped venues.
71
+ */
72
+ export function judgeFolder(
73
+ folder: string,
74
+ preset: Pick<Preset, "label" | "aliases">,
75
+ others: readonly OtherVenue[],
76
+ ): readonly Finding[] {
77
+ return venuesNamedBy(folder, others, [preset.label, ...preset.aliases]).map(
78
+ (n) => ({
79
+ messageId: "leftover",
80
+ data: { folder, other: shown(n), venue: preset.label },
81
+ }),
82
+ );
83
+ }
84
+
85
+ /**
86
+ * A template family (`acm-sigconf`, `ieee-conference`): a preset with no kinds, which a paper for a
87
+ * venue paperlint has no preset for extends directly (`src/venue-rules.ts` reads it the same way).
88
+ * Such a paper declares a format, not a venue, so a venue in its folder name may be its own: the
89
+ * accepted ACSAC papers in `fixtures/accepted-papers/` extend `ieee-conference` from folders named
90
+ * `…-acsac24`, and ACSAC is the parent conference `aidc` names as an alias.
91
+ */
92
+ const isFamily = (preset: Preset): boolean => preset.format.kinds.size === 0;
93
+
94
+ /** The findings for the paper folder `dir`; none when it has no resolved venue, or only a family. */
95
+ function findingsFor(dir: string, deps: VenueRuleDeps): readonly Finding[] {
96
+ const p = paperPreset(dir, deps);
97
+ if (p.kind !== "resolved" || isFamily(p.preset)) return [];
98
+ const others = otherVenues(p.preset, deps);
99
+ return judgeFolder(basename(dir), p.preset, others);
100
+ }
101
+
102
+ const META: FolderRuleModule["meta"] = {
103
+ type: "suggestion",
104
+ docs: {
105
+ description:
106
+ "the paper folder's name names a shipped venue other than the one the paper extends — a name left from an earlier submission",
107
+ url: "https://github.com/zernie/paperlint/blob/main/docs/rules/paper/folder-venue-leftover.md",
108
+ },
109
+ schema: [],
110
+ messages: {
111
+ leftover:
112
+ "the folder name «{{folder}}» names the venue {{other}}, and this paper extends {{venue}} — the name went stale when the venue changed, and venues change on every resubmission. Name the folder after the work (what the paper shows), not the venue",
113
+ },
114
+ };
115
+
116
+ /** The rule, reading the paper's settings and the shipped presets through `deps`. */
117
+ export function folderVenueRules(
118
+ deps: VenueRuleDeps,
119
+ ): Readonly<Record<"folder-venue-leftover", FolderRuleModule>> {
120
+ return {
121
+ "folder-venue-leftover": {
122
+ meta: META,
123
+ create(context) {
124
+ if (basename(context.filename) !== STATUS_FILE) return {};
125
+ return {
126
+ root() {
127
+ const at = context.sourceCode.getLocFromIndex(0);
128
+ findingsFor(dirname(context.filename), deps).forEach((f) => {
129
+ context.report({ loc: { start: at, end: at }, ...f });
130
+ });
131
+ },
132
+ };
133
+ },
134
+ },
135
+ };
136
+ }
137
+
138
+ /** The level it is on at in paperlint's own config, for every paper's `PIPELINE-STATUS.md`. */
139
+ export const FOLDER_VENUE_RULE_LEVELS: Readonly<
140
+ Record<"paper/folder-venue-leftover", "warn">
141
+ > = { "paper/folder-venue-leftover": "warn" };
package/src/init.test.ts CHANGED
@@ -37,6 +37,9 @@ import {
37
37
  workflowYaml,
38
38
  } from "./init.ts";
39
39
  import { runNode } from "../test/support.ts";
40
+ import { nodeFiles } from "./adapters/node/index.ts";
41
+ import { presetsDir } from "./package-dirs.ts";
42
+ import { shippedVenueNames } from "./presets.ts";
40
43
 
41
44
  const dirs: string[] = [];
42
45
  afterEach(() => {
@@ -515,6 +518,25 @@ describe("paperlint init — a first paper", () => {
515
518
  });
516
519
  });
517
520
 
521
+ describe("paperlint init — a first paper named after a venue", () => {
522
+ it("--paper aisec-2026 is refused before anything is asked, and createPaper is not called", async () => {
523
+ const made: string[] = [];
524
+ const asked: string[] = [];
525
+ const r = await initRun(tree({}), {
526
+ interactive: true,
527
+ paper: "aisec-2026",
528
+ venues: shippedVenueNames({ files: nodeFiles, venuesDir: presetsDir() }),
529
+ ask: (q) => Promise.resolve((asked.push(q), "")),
530
+ createPaper: (_root, name) => Promise.resolve((made.push(name), 0)),
531
+ });
532
+ expect(made).toEqual([]);
533
+ expect(asked.filter((q) => /format/.test(q))).toEqual([]);
534
+ expect(r.out[r.out.indexOf("first paper") + 1]).toBe(
535
+ ' ✗ no paper created — `aisec-2026` — the paper folder names a venue ("aisec"); venues change on resubmission, so name it after the work\n if the venue really belongs in the name, rerun `paperlint new` with --allow-venue-name',
536
+ );
537
+ });
538
+ });
539
+
518
540
  describe("paperlint init — paths are shown relative to where it was run", () => {
519
541
  it("a path that IS the working directory is shown whole, not as an empty string", async () => {
520
542
  const root = tree({ "papers/a/paper.md": "# a\n" });
package/src/init.ts CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  } from "#lib/paper-config";
80
80
  import { messageOf } from "./domain/text.ts";
81
81
  import { isRecord } from "./domain/record.ts";
82
+ import type { NamedVenue } from "./domain/venue-name.ts";
82
83
 
83
84
  /** How the papers directory was arrived at. Printed, because a guess must not read as a fact. */
84
85
  export type PapersHow =
@@ -582,6 +583,11 @@ export interface InitOptions {
582
583
  name: string,
583
584
  format: PaperFormat,
584
585
  ) => Promise<number>;
586
+ /**
587
+ * The venues a paper name may not name (`shippedVenueNames`), so `--paper aisec-2026` is refused
588
+ * before anything is asked. `createPaper` refuses it again whatever this holds.
589
+ */
590
+ venues?: readonly NamedVenue[];
585
591
  run?: ProgramProbe;
586
592
  /**
587
593
  * What `paperlint lint` would resolve from the declaration, asked of the CLI's OWN reader. A second
@@ -713,6 +719,7 @@ export async function init(
713
719
  paper = null,
714
720
  format = null,
715
721
  createPaper,
722
+ venues = [],
716
723
  run = spawnSync,
717
724
  resolveCliPapers,
718
725
  link = (r: string) => linkSkills(r),
@@ -816,7 +823,7 @@ export async function init(
816
823
  wanted = answer ? answer : null;
817
824
  }
818
825
  if (wanted !== null && createPaper) {
819
- const problem = nameProblem(wanted);
826
+ const problem = nameProblem(wanted, venues);
820
827
  let fmt: PaperFormat = format ?? DEFAULT_FORMAT;
821
828
  if (!problem && format === null && interactive) {
822
829
  const f = (
@@ -825,7 +832,7 @@ export async function init(
825
832
  if (isFormat(f)) fmt = f;
826
833
  }
827
834
  if (problem) {
828
- log(` ✗ ${problem} — no paper created`);
835
+ log(` ✗ no paper created — ${problem}`);
829
836
  paperCode = 2;
830
837
  } else {
831
838
  const code = await createPaper(papersAbs, wanted, fmt);
@@ -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", () => {