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,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cpSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { nodeFiles } from "../adapters/node/index.ts";
6
+ import { presetsDir } from "../package-dirs.ts";
7
+ import { shippedVenueNames } from "../presets.ts";
8
+ import {
9
+ nameTokens,
10
+ venuesNamedBy,
11
+ withoutVenues,
12
+ type NamedVenue,
13
+ } from "./venue-name.ts";
14
+
15
+ describe("nameTokens — a name as the words it is made of", () => {
16
+ it.each<[string, readonly string[]]>([
17
+ ["aisec-2026", ["aisec", "2026"]],
18
+ ["my_paper.v2", ["my", "paper", "v", "2"]],
19
+ ["ACM CCS", ["acm", "ccs"]],
20
+ ["acm-sigconf", ["acm", "sigconf"]],
21
+ // Letters and digits glued together are two words: `aisec2026` names AISec as much as `aisec-2026`.
22
+ ["aisec2026", ["aisec", "2026"]],
23
+ ["--x--", ["x"]],
24
+ ["", []],
25
+ ])("%s", (name, want) => {
26
+ expect(nameTokens(name)).toEqual(want);
27
+ });
28
+ });
29
+
30
+ /**
31
+ * The venues are the SHIPPED presets, read through the one source of venue names
32
+ * (`shippedVenueNames`) — never a list of names written here. A case below depends on what a preset
33
+ * says (`aidc`'s alias ACSAC, `aisec`'s ACM CCS); a preset edit that changes it shows here.
34
+ */
35
+ const VENUES: readonly NamedVenue[] = shippedVenueNames({
36
+ files: nodeFiles,
37
+ venuesDir: presetsDir(),
38
+ });
39
+
40
+ describe("venuesNamedBy — which venues a folder name names", () => {
41
+ it.each<[string, string, readonly { venue: string; name: string }[]]>([
42
+ ["the label", "aisec-2026", [{ venue: "aisec", name: "aisec" }]],
43
+ ["an alias", "acsac-2026", [{ venue: "aidc", name: "ACSAC" }]],
44
+ [
45
+ "a two-word alias, as two tokens in a row",
46
+ "acm-ccs-agents",
47
+ [{ venue: "aisec", name: "ACM CCS" }],
48
+ ],
49
+ ["the two words of an alias apart are not it", "acm-agents-ccs", []],
50
+ ["glued to a year", "realm2026", [{ venue: "realm", name: "realm" }]],
51
+ [
52
+ "two venues, each once, in the order they are listed",
53
+ "realm-aisec-aisec",
54
+ [
55
+ { venue: "aisec", name: "aisec" },
56
+ { venue: "realm", name: "realm" },
57
+ ],
58
+ ],
59
+ // Whole tokens only: a venue's name inside another word is that other word.
60
+ ["inside a word: overrealm", "overrealm-study", []],
61
+ ["inside a word: realms", "realms-of-agents", []],
62
+ ["inside a word: aisecure", "aisecure", []],
63
+ ["no venue at all", "agent-rule-drift", []],
64
+ ])("%s: %s", (_, folder, want) => {
65
+ expect(venuesNamedBy(folder, VENUES)).toEqual(want);
66
+ });
67
+
68
+ it("a name that is also the paper's own is not reported, whichever venue declares it", () => {
69
+ // A paper at a venue whose own chain also says "ACM CCS" (two workshops of one conference).
70
+ expect(venuesNamedBy("ccs-acm-ccs", VENUES, ["ACM CCS"])).toEqual([]);
71
+ expect(venuesNamedBy("aisec-2026", VENUES, ["aisec"])).toEqual([]);
72
+ });
73
+
74
+ it("own names compare by their words, not their spelling", () => {
75
+ expect(venuesNamedBy("acsac-2026", VENUES, ["ACSAC"])).toEqual([]);
76
+ expect(venuesNamedBy("acm-ccs-x", VENUES, ["acm-ccs"])).toEqual([]);
77
+ });
78
+
79
+ it("a preset whose alias has no words names nothing by it", () => {
80
+ // A fixture preset in a copy of the presets directory: alias "—" has no letter or digit, and a
81
+ // name of no words must not stand everywhere.
82
+ const dir = realpathSync(
83
+ mkdtempSync(join(tmpdir(), "paperlint-venue-name-")),
84
+ );
85
+ cpSync(presetsDir(), dir, { recursive: true });
86
+ writeFileSync(
87
+ join(dir, "dash.jsonc"),
88
+ JSON.stringify({ extends: "paperlint:acm-sigconf", aliases: ["—"] }),
89
+ );
90
+ const venues = shippedVenueNames({ files: nodeFiles, venuesDir: dir });
91
+ expect(venues.find((v) => v.label === "dash")?.aliases).toContain("—");
92
+ expect(venuesNamedBy("agent-rule-drift", venues)).toEqual([]);
93
+ expect(venuesNamedBy("dash-2026", venues)).toEqual([
94
+ { venue: "dash", name: "dash" },
95
+ ]);
96
+ });
97
+ });
98
+
99
+ describe("withoutVenues — the name with its venues taken out, when that is a name for the work", () => {
100
+ it.each<[string, string | null]>([
101
+ ["aisec-agent-drift", "agent-drift"],
102
+ ["agent_drift.aisec", "agent-drift"],
103
+ ["acm-ccs-agents", "agents"],
104
+ ["realm-rules-aisec-2026", "rules-2026"],
105
+ ["--aisec--agents", "agents"],
106
+ // Only numbers left: `2026` names no work.
107
+ ["aisec-2026", null],
108
+ ["aisec", null],
109
+ // A venue glued to other words in one part is not cleanly taken out.
110
+ ["aisec2026-agents", null],
111
+ ["agent-rule-drift", "agent-rule-drift"],
112
+ ])("%s → %s", (name, want) => {
113
+ expect(withoutVenues(name, VENUES)).toBe(want);
114
+ });
115
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WHICH VENUE A NAME NAMES — a paper folder's name read as words, against what each venue is
3
+ * called (a preset's label and its `aliases`).
4
+ *
5
+ * A name is compared by its WORDS, never by substring: `overrealm` and `realms` do not name REALM,
6
+ * `aisec-2026` and `aisec2026` name AISec. A word is a run of letters or a run of digits; anything
7
+ * else separates words, and case is ignored. A venue name of several words (`ACM CCS`) names the
8
+ * venue only when those words stand in a row.
9
+ *
10
+ * Read by `paper/folder-venue-leftover` (a folder naming a venue other than the one the paper
11
+ * extends) and by `paperlint new`, which refuses a new folder naming any venue at all.
12
+ */
13
+
14
+ /** A venue as names know it: the word for it in messages, and the other names it goes by. */
15
+ export interface NamedVenue {
16
+ readonly label: string;
17
+ readonly aliases: readonly string[];
18
+ }
19
+
20
+ /** A venue a name names, and which of its names it used, as the venue spells it. */
21
+ export interface NamedIn {
22
+ readonly venue: string;
23
+ readonly name: string;
24
+ }
25
+
26
+ /** A name's words, lowercased: runs of letters and runs of digits; everything else separates. */
27
+ export const nameTokens = (name: string): readonly string[] =>
28
+ name.toLowerCase().match(/\p{L}+|\p{N}+/gu) ?? [];
29
+
30
+ /** Where `words` stand in a row in `tokens`: the index of each first word. None for empty `words`. */
31
+ const placesIn = (
32
+ tokens: readonly string[],
33
+ words: readonly string[],
34
+ ): readonly number[] =>
35
+ words.length === 0
36
+ ? []
37
+ : tokens.flatMap((_, i) =>
38
+ words.every((w, j) => tokens[i + j] === w) ? [i] : [],
39
+ );
40
+
41
+ /** A name's words as one key, to compare two spellings of a name. */
42
+ const keyOf = (name: string): string => nameTokens(name).join(" ");
43
+
44
+ /** Every name of every venue that is not one of `own`, with its words. */
45
+ const candidates = (
46
+ venues: readonly NamedVenue[],
47
+ own: readonly string[],
48
+ ): readonly (NamedIn & { readonly words: readonly string[] })[] => {
49
+ const ownKeys = new Set(own.map(keyOf));
50
+ return venues.flatMap((v) =>
51
+ [v.label, ...v.aliases]
52
+ .filter((n) => !ownKeys.has(keyOf(n)))
53
+ .map((name) => ({ venue: v.label, name, words: nameTokens(name) })),
54
+ );
55
+ };
56
+
57
+ /**
58
+ * Each venue `name` names by one of its names (the label first, then its aliases), once per venue,
59
+ * in the order `venues` lists them. A name that is also one of `own` — the paper's own venue's,
60
+ * compared by words — is never counted: two workshops of one conference share its name.
61
+ */
62
+ export function venuesNamedBy(
63
+ name: string,
64
+ venues: readonly NamedVenue[],
65
+ own: readonly string[] = [],
66
+ ): readonly NamedIn[] {
67
+ const tokens = nameTokens(name);
68
+ const hits = candidates(venues, own).filter(
69
+ (c) => placesIn(tokens, c.words).length > 0,
70
+ );
71
+ return hits
72
+ .filter((c, i) => hits.findIndex((d) => d.venue === c.venue) === i)
73
+ .map(({ venue, name: n }) => ({ venue, name: n }));
74
+ }
75
+
76
+ /** A part of a name between separators, with the indices of its words in the whole name's words. */
77
+ interface Part {
78
+ readonly text: string;
79
+ readonly at: readonly number[];
80
+ }
81
+
82
+ /** A name's hyphen-, underscore- or dot-separated parts, each with where its words stand. */
83
+ const partsOf = (name: string): readonly Part[] =>
84
+ name
85
+ .split(/[^\p{L}\p{N}]+/u)
86
+ .filter((text) => text.length > 0)
87
+ .reduce<{ readonly next: number; readonly out: readonly Part[] }>(
88
+ (acc, text) => {
89
+ const n = nameTokens(text).length;
90
+ const at = Array.from({ length: n }, (_, j) => acc.next + j);
91
+ return { next: acc.next + n, out: [...acc.out, { text, at }] };
92
+ },
93
+ { next: 0, out: [] },
94
+ ).out;
95
+
96
+ /** The indices of the words of `name` that belong to a venue's name. */
97
+ const venueWordsIn = (
98
+ name: string,
99
+ venues: readonly NamedVenue[],
100
+ ): ReadonlySet<number> => {
101
+ const tokens = nameTokens(name);
102
+ return new Set(
103
+ candidates(venues, []).flatMap((c) =>
104
+ placesIn(tokens, c.words).flatMap((at) => c.words.map((_, j) => at + j)),
105
+ ),
106
+ );
107
+ };
108
+
109
+ /**
110
+ * `name` with every venue name taken out, as a name for the work — or null when that is not clean:
111
+ * a venue's words glued to other words in one hyphen-, underscore- or dot-separated part
112
+ * (`aisec2026`), or nothing but numbers left (`aisec-2026` → `2026` names no work). The parts left
113
+ * are joined by `-`.
114
+ */
115
+ export function withoutVenues(
116
+ name: string,
117
+ venues: readonly NamedVenue[],
118
+ ): string | null {
119
+ const venueWord = venueWordsIn(name, venues);
120
+ const inVenue = (p: Part): number =>
121
+ p.at.filter((i) => venueWord.has(i)).length;
122
+ const parts = partsOf(name);
123
+ if (parts.some((p) => inVenue(p) > 0 && inVenue(p) < p.at.length))
124
+ return null;
125
+ const left = parts.filter((p) => inVenue(p) === 0);
126
+ return left.some((p) => /\p{L}/u.test(p.text))
127
+ ? left.map((p) => p.text).join("-")
128
+ : null;
129
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `paper/folder-venue-leftover` (`src/folder-venue-rule.ts`) on papers held in memory beside the
3
+ * SHIPPED presets, so the venue names come from the registry a user gets. Both halves: a folder
4
+ * named after another venue than the paper's `extends` is reported once, on the scorecard's first
5
+ * line; a folder named after the paper's own venue, or after no venue, is not.
6
+ */
7
+ import { readdirSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+ import { memoryFiles } from "./adapters/memory/index.ts";
11
+ import { presetsDir } from "./package-dirs.ts";
12
+ import {
13
+ FOLDER_VENUE_RULE_LEVELS,
14
+ folderVenueRules,
15
+ } from "./folder-venue-rule.ts";
16
+
17
+ const VENUES = presetsDir();
18
+ const PAPERS = "/work/papers";
19
+ const shipped = Object.fromEntries(
20
+ readdirSync(VENUES)
21
+ .filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
22
+ .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f))]),
23
+ );
24
+
25
+ interface Reported {
26
+ readonly messageId: string;
27
+ readonly message: string;
28
+ readonly line: number;
29
+ }
30
+
31
+ /**
32
+ * The rule over one paper folder's file, the way ESLint runs it: `create`, then `root`. `settings`
33
+ * is the paper's paperlint.json (absent: no file); `extra` more files, by absolute path.
34
+ */
35
+ function lint(
36
+ folder: string,
37
+ settings: object | undefined,
38
+ {
39
+ file = "PIPELINE-STATUS.md",
40
+ extra = {},
41
+ }: { file?: string; extra?: Record<string, string> } = {},
42
+ ): Reported[] {
43
+ const dir = `${PAPERS}/${folder}`;
44
+ const files = memoryFiles({
45
+ ...shipped,
46
+ ...(settings === undefined
47
+ ? {}
48
+ : { [`${dir}/paperlint.json`]: JSON.stringify(settings) }),
49
+ ...extra,
50
+ });
51
+ const rule = folderVenueRules({ files, venuesDir: VENUES })[
52
+ "folder-venue-leftover"
53
+ ];
54
+ const out: Reported[] = [];
55
+ const visitor = rule.create({
56
+ filename: `${dir}/${file}`,
57
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
58
+ report: (d) => {
59
+ const template = rule.meta.messages[d.messageId] ?? "";
60
+ const message = Object.entries(d.data).reduce(
61
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
62
+ template,
63
+ );
64
+ out.push({ messageId: d.messageId, message, line: d.loc.start.line });
65
+ },
66
+ });
67
+ visitor.root?.();
68
+ return out;
69
+ }
70
+
71
+ const AIDC = { extends: "paperlint:aidc", kind: "regular" };
72
+
73
+ describe("paper/folder-venue-leftover — the folder names another venue", () => {
74
+ it("a folder named after the venue the paper left is reported, naming both and the cure", () => {
75
+ expect(lint("aisec-2026", AIDC)).toEqual([
76
+ {
77
+ messageId: "leftover",
78
+ line: 1,
79
+ message:
80
+ "the folder name «aisec-2026» names the venue aisec, and this paper extends aidc — 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",
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it("a venue named by an alias says which alias", () => {
86
+ expect(
87
+ lint("acm-ccs-agents", AIDC).map(
88
+ (r) => /names the venue (.*?), and/u.exec(r.message)?.[1],
89
+ ),
90
+ ).toEqual(["aisec («ACM CCS»)"]);
91
+ });
92
+
93
+ it("two other venues are two findings", () => {
94
+ expect(
95
+ lint("realm-then-aisec", AIDC).map(
96
+ (r) => /names the venue (\w+)/u.exec(r.message)?.[1],
97
+ ),
98
+ ).toEqual(["aisec", "realm"]);
99
+ });
100
+
101
+ it("a project's own preset is judged like a shipped one: its label and aliases are its own", () => {
102
+ const extra = {
103
+ [`${PAPERS}/aidc-redux/venue.jsonc`]: JSON.stringify({
104
+ extends: "paperlint:aisec",
105
+ name: "AISec 2027",
106
+ }),
107
+ };
108
+ // Its chain is AISec's, so aisec is its own; aidc is another venue.
109
+ expect(
110
+ lint(
111
+ "aidc-redux",
112
+ { extends: "./venue.jsonc", kind: "short" },
113
+ { extra },
114
+ ),
115
+ ).toHaveLength(1);
116
+ expect(
117
+ lint(
118
+ "aisec-redux",
119
+ { extends: "./venue.jsonc", kind: "short" },
120
+ {
121
+ extra: {
122
+ [`${PAPERS}/aisec-redux/venue.jsonc`]: JSON.stringify({
123
+ extends: "paperlint:aisec",
124
+ }),
125
+ },
126
+ },
127
+ ),
128
+ ).toEqual([]);
129
+ });
130
+ });
131
+
132
+ describe("paper/folder-venue-leftover — silent", () => {
133
+ it.each<[string, string, object | undefined]>([
134
+ [
135
+ "the folder names the paper's own venue",
136
+ "agenticdev-2026",
137
+ { extends: "paperlint:agenticdev", kind: "short" },
138
+ ],
139
+ ["by its alias", "acsac-2026", AIDC],
140
+ ["the folder names no venue", "agent-rule-drift", AIDC],
141
+ // Whole words only: REALM is not inside these.
142
+ ["a venue's name inside another word", "overrealm-realms", AIDC],
143
+ ["no paperlint.json: no venue to compare with", "aisec-2026", undefined],
144
+ ["no extends yet", "aisec-2026", { extends: null }],
145
+ [
146
+ "an extends that does not resolve — pdf/profile says so",
147
+ "aisec-2026",
148
+ { extends: "paperlint:nope" },
149
+ ],
150
+ ])("%s", (_, folder, settings) => {
151
+ expect(lint(folder, settings)).toEqual([]);
152
+ });
153
+
154
+ it("a paperlint.json that does not parse — pdf/profile says so", () => {
155
+ expect(
156
+ lint("aisec-2026", undefined, {
157
+ extra: { [`${PAPERS}/aisec-2026/paperlint.json`]: "{ not json" },
158
+ }),
159
+ ).toEqual([]);
160
+ });
161
+
162
+ it("judged once per paper, on PIPELINE-STATUS.md only", () => {
163
+ expect(lint("aisec-2026", AIDC, { file: "paper.md" })).toEqual([]);
164
+ });
165
+
166
+ it("a paper on a bare template family declares no venue: a venue in its folder may be its own", () => {
167
+ // The accepted ACSAC papers in fixtures/accepted-papers/ are exactly this: `…-acsac24` folders
168
+ // extending ieee-conference, while `aidc` names ACSAC, its parent conference, as an alias.
169
+ expect(
170
+ lint("secure-acsac24", { extends: "paperlint:ieee-conference" }),
171
+ ).toEqual([]);
172
+ expect(lint("aisec-2026", { extends: "paperlint:acm-sigconf" })).toEqual(
173
+ [],
174
+ );
175
+ });
176
+ });
177
+
178
+ it("the level: a warning — a rename is the author's call", () => {
179
+ expect(FOLDER_VENUE_RULE_LEVELS).toEqual({
180
+ "paper/folder-venue-leftover": "warn",
181
+ });
182
+ });
@@ -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);