paperlint 6.2.3 → 7.0.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 (234) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +10 -3
  3. package/dist/adapters/hotcrp/index.d.ts +7 -0
  4. package/dist/adapters/hotcrp/index.d.ts.map +1 -0
  5. package/dist/adapters/hotcrp/index.js +7 -0
  6. package/dist/adapters/hotcrp/index.js.map +1 -0
  7. package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
  8. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
  9. package/dist/adapters/hotcrp/portal.io.js +90 -0
  10. package/dist/adapters/hotcrp/portal.io.js.map +1 -0
  11. package/dist/adapters/hotcrp/response.d.ts +13 -0
  12. package/dist/adapters/hotcrp/response.d.ts.map +1 -0
  13. package/dist/adapters/hotcrp/response.js +138 -0
  14. package/dist/adapters/hotcrp/response.js.map +1 -0
  15. package/dist/adapters/latex/conditionals.d.ts +21 -0
  16. package/dist/adapters/latex/conditionals.d.ts.map +1 -0
  17. package/dist/adapters/latex/conditionals.js +75 -0
  18. package/dist/adapters/latex/conditionals.js.map +1 -0
  19. package/dist/adapters/latex/headings.d.ts +10 -0
  20. package/dist/adapters/latex/headings.d.ts.map +1 -0
  21. package/dist/adapters/latex/headings.js +129 -0
  22. package/dist/adapters/latex/headings.js.map +1 -0
  23. package/dist/adapters/latex/index.d.ts +1 -0
  24. package/dist/adapters/latex/index.d.ts.map +1 -1
  25. package/dist/adapters/latex/index.js +3 -0
  26. package/dist/adapters/latex/index.js.map +1 -1
  27. package/dist/adapters/latex/parse.d.ts +1 -1
  28. package/dist/adapters/latex/parse.d.ts.map +1 -1
  29. package/dist/adapters/latex/parse.js +3 -2
  30. package/dist/adapters/latex/parse.js.map +1 -1
  31. package/dist/build.d.ts +1 -1
  32. package/dist/build.d.ts.map +1 -1
  33. package/dist/build.js +1 -1
  34. package/dist/build.js.map +1 -1
  35. package/dist/cli.d.ts +4 -2
  36. package/dist/cli.d.ts.map +1 -1
  37. package/dist/cli.js +126 -37
  38. package/dist/cli.js.map +1 -1
  39. package/dist/domain/heading-case.d.ts +100 -0
  40. package/dist/domain/heading-case.d.ts.map +1 -0
  41. package/dist/domain/heading-case.js +365 -0
  42. package/dist/domain/heading-case.js.map +1 -0
  43. package/dist/domain/result.d.ts +4 -0
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +2 -0
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/domain/submission.d.ts +106 -0
  48. package/dist/domain/submission.d.ts.map +1 -0
  49. package/dist/domain/submission.js +39 -0
  50. package/dist/domain/submission.js.map +1 -0
  51. package/dist/domain/tex-document.d.ts +32 -0
  52. package/dist/domain/tex-document.d.ts.map +1 -1
  53. package/dist/domain/tex-document.js +11 -0
  54. package/dist/domain/tex-document.js.map +1 -1
  55. package/dist/domain/venue-name.d.ts +38 -0
  56. package/dist/domain/venue-name.d.ts.map +1 -0
  57. package/dist/domain/venue-name.js +71 -0
  58. package/dist/domain/venue-name.js.map +1 -0
  59. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  60. package/dist/eslint-rules/latex-language.js +16 -3
  61. package/dist/eslint-rules/latex-language.js.map +1 -1
  62. package/dist/facts-file.d.ts +3 -3
  63. package/dist/facts-file.d.ts.map +1 -1
  64. package/dist/facts-file.js +3 -3
  65. package/dist/facts-file.js.map +1 -1
  66. package/dist/folder-venue-rule.d.ts +51 -0
  67. package/dist/folder-venue-rule.d.ts.map +1 -0
  68. package/dist/folder-venue-rule.js +96 -0
  69. package/dist/folder-venue-rule.js.map +1 -0
  70. package/dist/heading-case.d.ts +48 -0
  71. package/dist/heading-case.d.ts.map +1 -0
  72. package/dist/heading-case.js +191 -0
  73. package/dist/heading-case.js.map +1 -0
  74. package/dist/init.d.ts +6 -0
  75. package/dist/init.d.ts.map +1 -1
  76. package/dist/init.js +3 -3
  77. package/dist/init.js.map +1 -1
  78. package/dist/lib/paper-config.d.ts.map +1 -1
  79. package/dist/lib/paper-config.js +5 -1
  80. package/dist/lib/paper-config.js.map +1 -1
  81. package/dist/new-paper.d.ts +19 -5
  82. package/dist/new-paper.d.ts.map +1 -1
  83. package/dist/new-paper.js +23 -4
  84. package/dist/new-paper.js.map +1 -1
  85. package/dist/paper-settings.d.ts +3 -0
  86. package/dist/paper-settings.d.ts.map +1 -1
  87. package/dist/paper-settings.js +20 -4
  88. package/dist/paper-settings.js.map +1 -1
  89. package/dist/ports/latex.d.ts +7 -1
  90. package/dist/ports/latex.d.ts.map +1 -1
  91. package/dist/ports/submission-portal.d.ts +19 -0
  92. package/dist/ports/submission-portal.d.ts.map +1 -0
  93. package/dist/ports/submission-portal.js +2 -0
  94. package/dist/ports/submission-portal.js.map +1 -0
  95. package/dist/preset-card-rule.d.ts +100 -0
  96. package/dist/preset-card-rule.d.ts.map +1 -0
  97. package/dist/preset-card-rule.js +221 -0
  98. package/dist/preset-card-rule.js.map +1 -0
  99. package/dist/presets.d.ts +43 -2
  100. package/dist/presets.d.ts.map +1 -1
  101. package/dist/presets.js +130 -63
  102. package/dist/presets.js.map +1 -1
  103. package/dist/rules-config.js +1 -1
  104. package/dist/submission.d.ts +61 -0
  105. package/dist/submission.d.ts.map +1 -0
  106. package/dist/submission.js +211 -0
  107. package/dist/submission.js.map +1 -0
  108. package/dist/tex-requirements.d.ts +36 -3
  109. package/dist/tex-requirements.d.ts.map +1 -1
  110. package/dist/tex-requirements.js +30 -2
  111. package/dist/tex-requirements.js.map +1 -1
  112. package/dist/tex-venue-rules.d.ts +7 -8
  113. package/dist/tex-venue-rules.d.ts.map +1 -1
  114. package/dist/tex-venue-rules.js +10 -14
  115. package/dist/tex-venue-rules.js.map +1 -1
  116. package/dist/types.d.ts +13 -0
  117. package/dist/types.d.ts.map +1 -1
  118. package/docs/configuration.md +30 -11
  119. package/docs/optional-rules.md +4 -3
  120. package/docs/rules/format/page-limit.md +1 -1
  121. package/docs/rules/paper/folder-venue-leftover.md +104 -0
  122. package/docs/rules/talk/captions-cover.md +1 -1
  123. package/docs/rules/talk/duration-floor.md +1 -1
  124. package/docs/rules/talk/profile.md +1 -1
  125. package/docs/rules/tex/heading-case.md +424 -0
  126. package/docs/rules.md +64 -53
  127. package/docs/skills.md +5 -4
  128. package/docs/submission.md +64 -0
  129. package/docs/talk.md +1 -1
  130. package/eslint-rules/latex-language.test.mjs +12 -0
  131. package/eslint-rules/latex-language.ts +17 -4
  132. package/eslint-rules/paper-typography.test.mjs +12 -0
  133. package/eslint.config.mjs +59 -1
  134. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  135. package/fixtures/build-e2e/fallback/expect.json +3 -3
  136. package/lib/paper-config.ts +6 -1
  137. package/lib/skill-checks.d.mts +7 -0
  138. package/lib/skill-checks.mjs +31 -0
  139. package/lib/skill-trigger-cases.mjs +11 -0
  140. package/package.json +8 -2
  141. package/presets/acm-sigconf.jsonc +5 -0
  142. package/presets/acm-sigconf.md +43 -0
  143. package/presets/agenticdev.jsonc +27 -6
  144. package/presets/agenticdev.md +151 -0
  145. package/presets/aidc.jsonc +12 -0
  146. package/presets/aidc.md +75 -0
  147. package/presets/aisec.jsonc +5 -1
  148. package/presets/aisec.md +117 -0
  149. package/presets/ieee-conference.jsonc +5 -0
  150. package/presets/ieee-conference.md +38 -0
  151. package/presets/realm.jsonc +7 -2
  152. package/presets/realm.md +166 -0
  153. package/presets/tex/agenticdev.tex +1 -1
  154. package/presets/tex-base.jsonc +6 -0
  155. package/presets/tex-base.md +30 -0
  156. package/presets/venue-profile.schema.json +57 -2
  157. package/skills/find-venue/SKILL.md +6 -5
  158. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  159. package/skills/paper-pipeline/SKILL.md +5 -6
  160. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  161. package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
  162. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  163. package/skills/study-accepted-papers/SKILL.md +3 -3
  164. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  165. package/skills/submission-portal/SKILL.md +69 -0
  166. package/skills/submission-portal/SKILL.md.spec.ts +71 -0
  167. package/skills/submission-portal/submission-portal.eval.mjs +15 -0
  168. package/skills/submission-portal/submission-portal.test.ts +81 -0
  169. package/skills/submit-paper/SKILL.md +27 -22
  170. package/skills/submit-paper/SKILL.md.spec.ts +26 -21
  171. package/skills/submit-paper/references/publishers/acm.md +1 -1
  172. package/src/adapters/hotcrp/cassette/README.md +24 -0
  173. package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
  174. package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
  175. package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
  176. package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
  177. package/src/adapters/hotcrp/index.ts +11 -0
  178. package/src/adapters/hotcrp/portal.io.ts +130 -0
  179. package/src/adapters/hotcrp/portal.test.ts +503 -0
  180. package/src/adapters/hotcrp/response.ts +181 -0
  181. package/src/adapters/latex/conditionals.ts +130 -0
  182. package/src/adapters/latex/headings.ts +163 -0
  183. package/src/adapters/latex/index.test.ts +65 -0
  184. package/src/adapters/latex/index.ts +3 -0
  185. package/src/adapters/latex/parse.ts +3 -2
  186. package/src/build.ts +2 -2
  187. package/src/cli.test.ts +155 -3
  188. package/src/cli.ts +161 -25
  189. package/src/domain/heading-case.test.ts +660 -0
  190. package/src/domain/heading-case.ts +538 -0
  191. package/src/domain/result.test.ts +9 -1
  192. package/src/domain/result.ts +5 -0
  193. package/src/domain/submission.test.ts +45 -0
  194. package/src/domain/submission.ts +141 -0
  195. package/src/domain/tex-document.ts +36 -0
  196. package/src/domain/venue-name.test.ts +121 -0
  197. package/src/domain/venue-name.ts +129 -0
  198. package/src/facts-file.test.ts +4 -4
  199. package/src/facts-file.ts +6 -6
  200. package/src/folder-venue-rule.test.ts +182 -0
  201. package/src/folder-venue-rule.ts +141 -0
  202. package/src/heading-case.test.ts +500 -0
  203. package/src/heading-case.ts +275 -0
  204. package/src/init.test.ts +22 -0
  205. package/src/init.ts +9 -2
  206. package/src/new-paper.harness.mjs +11 -9
  207. package/src/new-paper.test.ts +135 -10
  208. package/src/new-paper.ts +38 -7
  209. package/src/paper-settings-commands.test.ts +25 -10
  210. package/src/paper-settings.test.ts +12 -1
  211. package/src/paper-settings.ts +25 -5
  212. package/src/parse-args.test.ts +19 -0
  213. package/src/ports/latex.ts +7 -0
  214. package/src/ports/submission-portal.ts +26 -0
  215. package/src/preset-card-rule.test.ts +266 -0
  216. package/src/preset-card-rule.ts +384 -0
  217. package/src/presets.test.ts +212 -37
  218. package/src/presets.ts +183 -71
  219. package/src/rule-cases.test.ts +147 -42
  220. package/src/rules-config.ts +1 -1
  221. package/src/submission.test.ts +497 -0
  222. package/src/submission.ts +311 -0
  223. package/src/talk-rules.test.ts +6 -3
  224. package/src/tex-requirements.harness.ts +25 -11
  225. package/src/tex-requirements.test.ts +127 -1
  226. package/src/tex-requirements.ts +78 -8
  227. package/src/tex-venue-rules.test.ts +36 -22
  228. package/src/tex-venue-rules.ts +14 -26
  229. package/src/types.ts +13 -0
  230. package/src/venue-rules.test.ts +7 -6
  231. package/templates/paper/paper.tex +1 -1
  232. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  233. package/skills/submit-paper/references/venues/aisec.md +0 -105
  234. package/skills/submit-paper/references/venues/realm.md +0 -155
@@ -0,0 +1,141 @@
1
+ /**
2
+ * THE SUBMISSION ON A CONFERENCE PORTAL — what `paperlint submission` reads and changes.
3
+ *
4
+ * Two declarations, each in the file that owns the fact:
5
+ *
6
+ * venue preset `portal` where the venue takes submissions: `{ "kind": "hotcrp", "url": … }`
7
+ * paper `submission` which submission on that portal is this paper's: `{ "id": 7 }`
8
+ *
9
+ * The values here are the portal's answers in paperlint's words, not in any one portal's: an adapter
10
+ * per portal kind maps its API onto them (`src/adapters/hotcrp/`).
11
+ */
12
+ import { err, ok, type Result } from "./result.ts";
13
+ import { isRecord } from "./record.ts";
14
+ import type { Sha256 } from "./sha256.ts";
15
+
16
+ /** The portal kinds an adapter exists for. Any other kind is refused by name. */
17
+ export type PortalKind = "hotcrp";
18
+ export const PORTAL_KINDS: readonly PortalKind[] = ["hotcrp"];
19
+
20
+ /** A venue preset's `portal`, as the preset declares it: the kind is checked where it is used. */
21
+ export interface VenuePortal {
22
+ readonly kind: string;
23
+ /** The portal site's base URL, without a trailing slash. */
24
+ readonly url: string;
25
+ }
26
+
27
+ /** A portal whose kind has an adapter. */
28
+ export interface SupportedPortal {
29
+ readonly kind: PortalKind;
30
+ readonly url: string;
31
+ }
32
+
33
+ const isPortalKind = (k: string): k is PortalKind =>
34
+ PORTAL_KINDS.some((x) => x === k);
35
+
36
+ /** The declared portal → one paperlint can talk to, or the refusal naming the kind. Pure. */
37
+ export function supportedPortal(
38
+ p: VenuePortal,
39
+ ): Result<SupportedPortal, string> {
40
+ return isPortalKind(p.kind)
41
+ ? ok({ kind: p.kind, url: p.url.replace(/\/+$/, "") })
42
+ : err(`portal kind "${p.kind}" is not supported; upload by hand`);
43
+ }
44
+
45
+ /** A paper's `submission`: which submission on the venue's portal is this paper's. */
46
+ export interface PaperSubmission {
47
+ readonly id: number;
48
+ }
49
+
50
+ /** A paper's `submission` key → the value, null when absent, or one line saying what is wrong. Pure. */
51
+ export function parsePaperSubmission(
52
+ v: unknown,
53
+ ): Result<PaperSubmission | null, string> {
54
+ if (v === undefined) return ok(null);
55
+ const bad = err(
56
+ `"submission" must be { "id": <the submission number on the venue's portal> }`,
57
+ );
58
+ if (!isRecord(v)) return bad;
59
+ const unknown = Object.keys(v).find((k) => k !== "id");
60
+ if (unknown !== undefined)
61
+ return err(`"submission" has an unknown key "${unknown}" — known: id`);
62
+ const id = v["id"];
63
+ return typeof id === "number" && Number.isInteger(id) && id > 0
64
+ ? ok({ id })
65
+ : bad;
66
+ }
67
+
68
+ /** The PDF the portal holds for a submission. */
69
+ export interface PortalDocument {
70
+ /** The sha256 of the stored bytes; null when the portal names another hash or none. */
71
+ readonly sha256: Sha256 | null;
72
+ /** The hash as the portal spells it. */
73
+ readonly hash: string;
74
+ readonly size: number | null;
75
+ readonly mimetype: string | null;
76
+ /** Unix seconds; null when the portal does not say. */
77
+ readonly uploadedAt: number | null;
78
+ }
79
+
80
+ /** One submission, as the portal shows it to its author. */
81
+ export interface SubmissionView {
82
+ readonly id: number;
83
+ readonly status: string;
84
+ readonly title: string | null;
85
+ readonly paperType: string | null;
86
+ readonly topics: readonly string[];
87
+ readonly abstract: string | null;
88
+ /** The PDF the portal holds; null when none is uploaded. */
89
+ readonly pdf: PortalDocument | null;
90
+ /** Unix seconds; null when not submitted or not said. */
91
+ readonly submittedAt: number | null;
92
+ readonly modifiedAt: number | null;
93
+ /** What the portal said beside the answer. */
94
+ readonly messages: readonly PortalMessage[];
95
+ }
96
+
97
+ /** A change to send. Every absent part is left as the portal has it. */
98
+ export interface SubmissionChange {
99
+ readonly pdf: { readonly name: string; readonly bytes: Uint8Array } | null;
100
+ readonly abstract: string | null;
101
+ /** Mark the submission submitted (ready for review). */
102
+ readonly submit: boolean;
103
+ }
104
+
105
+ /** One message from the portal: its text, and the field it is about when it names one. */
106
+ export interface PortalMessage {
107
+ readonly message: string;
108
+ readonly field: string | null;
109
+ /** The portal's severity number, when it gives one. */
110
+ readonly status: number | null;
111
+ }
112
+
113
+ /** What the portal answered to a change. */
114
+ export interface UpdateOutcome {
115
+ readonly httpStatus: number;
116
+ /** The change is valid; for a saved change, it was also committed. */
117
+ readonly valid: boolean;
118
+ /** The fields the change touched. */
119
+ readonly changes: readonly string[];
120
+ readonly messages: readonly PortalMessage[];
121
+ /** The portal reports that nothing was saved. */
122
+ readonly dryRun: boolean;
123
+ }
124
+
125
+ /** Why the portal gave no usable answer. */
126
+ export type PortalFailure =
127
+ | {
128
+ readonly kind: "refused";
129
+ readonly httpStatus: number;
130
+ readonly messages: readonly PortalMessage[];
131
+ }
132
+ | { readonly kind: "unreachable"; readonly detail: string }
133
+ | {
134
+ readonly kind: "malformed";
135
+ readonly httpStatus: number;
136
+ readonly detail: string;
137
+ };
138
+
139
+ /** Words in a text, as a reader counts them: runs of non-space. Pure. */
140
+ export const wordCount = (s: string): number =>
141
+ s.split(/\s+/).filter((w) => w !== "").length;
@@ -185,3 +185,39 @@ export interface LayoutOverride {
185
185
  readonly command: string;
186
186
  readonly place: Place;
187
187
  }
188
+
189
+ /**
190
+ * What stands in a heading's text for something the reader sees and the case judge does not read —
191
+ * math, code, a citation, any command whose text is not plain: one character no title contains.
192
+ */
193
+ export const OPAQUE = "";
194
+
195
+ /**
196
+ * What stands in a heading's text for a forced line break (`\\`, `\newline`, `\linebreak`): one
197
+ * character no title contains. A title broken over two lines is two parts — a title and its
198
+ * subtitle — so the case judge reads the word after it as a start, like the word after a dash.
199
+ */
200
+ export const LINE_BREAK = "\u2028";
201
+
202
+ /** The headings whose title a venue sets in a case style. */
203
+ export type HeadingLevel =
204
+ "section" | "subsection" | "subsubsection" | "paragraph" | "subparagraph";
205
+
206
+ /** Which command a title belongs to: the paper's own `\title`, or a heading at its level. */
207
+ export type TitledCommand =
208
+ | { readonly kind: "title" }
209
+ | { readonly kind: "heading"; readonly level: HeadingLevel };
210
+
211
+ /** Which argument of the command: the title, or the optional short title in brackets. */
212
+ export type TitleArgument = "title" | "short";
213
+
214
+ /**
215
+ * One title as the case judge reads it — the paper's or a heading's: its text, each character with
216
+ * the source offset it came from, and `OPAQUE` where something it does not read stands. A label, a
217
+ * footnote and a comment are not in it; a title with nothing else has no `TitledHeading`.
218
+ */
219
+ export interface TitledHeading {
220
+ readonly command: TitledCommand;
221
+ readonly argument: TitleArgument;
222
+ readonly title: TextRun;
223
+ }
@@ -0,0 +1,121 @@
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
+ import { venuePreset } from "../../test/support.ts";
15
+
16
+ describe("nameTokens — a name as the words it is made of", () => {
17
+ it.each<[string, readonly string[]]>([
18
+ ["aisec-2026", ["aisec", "2026"]],
19
+ ["my_paper.v2", ["my", "paper", "v", "2"]],
20
+ ["ACM CCS", ["acm", "ccs"]],
21
+ ["acm-sigconf", ["acm", "sigconf"]],
22
+ // Letters and digits glued together are two words: `aisec2026` names AISec as much as `aisec-2026`.
23
+ ["aisec2026", ["aisec", "2026"]],
24
+ ["--x--", ["x"]],
25
+ ["", []],
26
+ ])("%s", (name, want) => {
27
+ expect(nameTokens(name)).toEqual(want);
28
+ });
29
+ });
30
+
31
+ /**
32
+ * The venues are the SHIPPED presets, read through the one source of venue names
33
+ * (`shippedVenueNames`) — never a list of names written here. A case below depends on what a preset
34
+ * says (`aidc`'s alias ACSAC, `aisec`'s ACM CCS); a preset edit that changes it shows here.
35
+ */
36
+ const VENUES: readonly NamedVenue[] = shippedVenueNames({
37
+ files: nodeFiles,
38
+ venuesDir: presetsDir(),
39
+ });
40
+
41
+ describe("venuesNamedBy — which venues a folder name names", () => {
42
+ it.each<[string, string, readonly { venue: string; name: string }[]]>([
43
+ ["the label", "aisec-2026", [{ venue: "AISec", name: "AISec" }]],
44
+ ["an alias", "acsac-2026", [{ venue: "AIDC", name: "ACSAC" }]],
45
+ [
46
+ "a two-word alias, as two tokens in a row",
47
+ "acm-ccs-agents",
48
+ [{ venue: "AISec", name: "ACM CCS" }],
49
+ ],
50
+ ["the two words of an alias apart are not it", "acm-agents-ccs", []],
51
+ ["glued to a year", "realm2026", [{ venue: "REALM", name: "REALM" }]],
52
+ [
53
+ "two venues, each once, in the order they are listed",
54
+ "realm-aisec-aisec",
55
+ [
56
+ { venue: "AISec", name: "AISec" },
57
+ { venue: "REALM", name: "REALM" },
58
+ ],
59
+ ],
60
+ // Whole tokens only: a venue's name inside another word is that other word.
61
+ ["inside a word: overrealm", "overrealm-study", []],
62
+ ["inside a word: realms", "realms-of-agents", []],
63
+ ["inside a word: aisecure", "aisecure", []],
64
+ ["no venue at all", "agent-rule-drift", []],
65
+ ])("%s: %s", (_, folder, want) => {
66
+ expect(venuesNamedBy(folder, VENUES)).toEqual(want);
67
+ });
68
+
69
+ it("a name that is also the paper's own is not reported, whichever venue declares it", () => {
70
+ // A paper at a venue whose own chain also says "ACM CCS" (two workshops of one conference).
71
+ expect(venuesNamedBy("ccs-acm-ccs", VENUES, ["ACM CCS"])).toEqual([]);
72
+ expect(venuesNamedBy("aisec-2026", VENUES, ["aisec"])).toEqual([]);
73
+ });
74
+
75
+ it("own names compare by their words, not their spelling", () => {
76
+ expect(venuesNamedBy("acsac-2026", VENUES, ["ACSAC"])).toEqual([]);
77
+ expect(venuesNamedBy("acm-ccs-x", VENUES, ["acm-ccs"])).toEqual([]);
78
+ });
79
+
80
+ it("a preset whose alias has no words names nothing by it", () => {
81
+ // A fixture preset in a copy of the presets directory: alias "—" has no letter or digit, and a
82
+ // name of no words must not stand everywhere.
83
+ const dir = realpathSync(
84
+ mkdtempSync(join(tmpdir(), "paperlint-venue-name-")),
85
+ );
86
+ cpSync(presetsDir(), dir, { recursive: true });
87
+ writeFileSync(
88
+ join(dir, "dash.jsonc"),
89
+ JSON.stringify(
90
+ venuePreset("dash", {
91
+ extends: "paperlint:acm-sigconf",
92
+ aliases: ["—"],
93
+ }),
94
+ ),
95
+ );
96
+ const venues = shippedVenueNames({ files: nodeFiles, venuesDir: dir });
97
+ expect(venues.find((v) => v.label === "dash")?.aliases).toContain("—");
98
+ expect(venuesNamedBy("agent-rule-drift", venues)).toEqual([]);
99
+ expect(venuesNamedBy("dash-2026", venues)).toEqual([
100
+ { venue: "dash", name: "dash" },
101
+ ]);
102
+ });
103
+ });
104
+
105
+ describe("withoutVenues — the name with its venues taken out, when that is a name for the work", () => {
106
+ it.each<[string, string | null]>([
107
+ ["aisec-agent-drift", "agent-drift"],
108
+ ["agent_drift.aisec", "agent-drift"],
109
+ ["acm-ccs-agents", "agents"],
110
+ ["realm-rules-aisec-2026", "rules-2026"],
111
+ ["--aisec--agents", "agents"],
112
+ // Only numbers left: `2026` names no work.
113
+ ["aisec-2026", null],
114
+ ["aisec", null],
115
+ // A venue glued to other words in one part is not cleanly taken out.
116
+ ["aisec2026-agents", null],
117
+ ["agent-rule-drift", "agent-rule-drift"],
118
+ ])("%s → %s", (name, want) => {
119
+ expect(withoutVenues(name, VENUES)).toBe(want);
120
+ });
121
+ });
@@ -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
+ }
@@ -267,24 +267,24 @@ test("writeFactsFile: <paper>/_build/paper.facts.json holds exactly the document
267
267
  assert.deepEqual(JSON.parse(text), m.value.facts);
268
268
  });
269
269
 
270
- test("declaredVenue: none without paperlint.json; no label when it extends no preset", () => {
270
+ test("declaredVenue: none without paperlint.json; no id when it extends no preset", () => {
271
271
  assert.equal(declaredVenue(memoryFiles(), PAPER), null);
272
272
  const files = memoryFiles({
273
273
  [`${PAPER}/paperlint.json`]: '{"kind":"x","pdf":"b/p.pdf"}',
274
274
  });
275
275
  assert.deepEqual(declaredVenue(files, PAPER), {
276
- label: null,
276
+ id: null,
277
277
  kind: "x",
278
278
  pdf: "b/p.pdf",
279
279
  });
280
280
  });
281
281
 
282
- test("declaredVenue: the label is the preset's, and an extends that resolves nowhere is refused", () => {
282
+ test("declaredVenue: the id is the preset's — its file name, not its display name — and an extends that resolves nowhere is refused", () => {
283
283
  const ok = memoryFiles({
284
284
  ...SHIPPED_PRESETS,
285
285
  [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
286
286
  });
287
- assert.equal(declaredVenue(ok, PAPER)?.label, "aisec");
287
+ assert.equal(declaredVenue(ok, PAPER)?.id, "aisec");
288
288
  const typo = memoryFiles({
289
289
  ...SHIPPED_PRESETS,
290
290
  [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisek"}',
package/src/facts-file.ts CHANGED
@@ -68,10 +68,10 @@ export const FACTS_FILE = "paper.facts.json";
68
68
  export const factsPath = (paperDir: string): string =>
69
69
  join(paperDir, FACTS_DIR, FACTS_FILE);
70
70
 
71
- /** What the facts need from a paper's `paperlint.json`: its venue's label, its kind, where its PDF is. */
71
+ /** What the facts need from a paper's `paperlint.json`: its venue's preset id, its kind, where its PDF is. */
72
72
  export interface VenueDecl {
73
- /** The venue preset's display label (`agenticdev`), or null when the paper extends none. */
74
- readonly label: string | null;
73
+ /** The venue preset's id (`agenticdev`, never its display name), or null when the paper extends none. */
74
+ readonly id: string | null;
75
75
  readonly kind: string | null;
76
76
  readonly pdf: string | null;
77
77
  }
@@ -91,12 +91,12 @@ export function declaredVenue(
91
91
  if (problem !== null) throw new Error(problem);
92
92
  if (p.kind === "resolved")
93
93
  return {
94
- label: p.preset.label,
94
+ id: p.preset.id,
95
95
  kind: p.settings.kind,
96
96
  pdf: p.settings.pdf,
97
97
  };
98
98
  return p.kind === "none" && p.settings
99
- ? { label: null, kind: p.settings.kind, pdf: p.settings.pdf }
99
+ ? { id: null, kind: p.settings.kind, pdf: p.settings.pdf }
100
100
  : null;
101
101
  }
102
102
 
@@ -237,7 +237,7 @@ export async function measurePaper(
237
237
  const facts = factsDocument({
238
238
  pdf: posix(relative(paperDir, pdf)),
239
239
  sha: sha256Hex(bytes),
240
- venue: o.venue ?? decl?.label ?? null,
240
+ venue: o.venue ?? decl?.id ?? null,
241
241
  kind: o.kind ?? decl?.kind ?? null,
242
242
  read: r.facts,
243
243
  geometry,
@@ -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
+ import { venuePreset } from "../test/support.ts";
17
+
18
+ const VENUES = presetsDir();
19
+ const PAPERS = "/work/papers";
20
+ const shipped = Object.fromEntries(
21
+ readdirSync(VENUES)
22
+ .filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
23
+ .map((f) => [join(VENUES, f), readFileSync(join(VENUES, f))]),
24
+ );
25
+
26
+ interface Reported {
27
+ readonly messageId: string;
28
+ readonly message: string;
29
+ readonly line: number;
30
+ }
31
+
32
+ /**
33
+ * The rule over one paper folder's file, the way ESLint runs it: `create`, then `root`. `settings`
34
+ * is the paper's paperlint.json (absent: no file); `extra` more files, by absolute path.
35
+ */
36
+ function lint(
37
+ folder: string,
38
+ settings: object | undefined,
39
+ {
40
+ file = "PIPELINE-STATUS.md",
41
+ extra = {},
42
+ }: { file?: string; extra?: Record<string, string> } = {},
43
+ ): Reported[] {
44
+ const dir = `${PAPERS}/${folder}`;
45
+ const files = memoryFiles({
46
+ ...shipped,
47
+ ...(settings === undefined
48
+ ? {}
49
+ : { [`${dir}/paperlint.json`]: JSON.stringify(settings) }),
50
+ ...extra,
51
+ });
52
+ const rule = folderVenueRules({ files, venuesDir: VENUES })[
53
+ "folder-venue-leftover"
54
+ ];
55
+ const out: Reported[] = [];
56
+ const visitor = rule.create({
57
+ filename: `${dir}/${file}`,
58
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
59
+ report: (d) => {
60
+ const template = rule.meta.messages[d.messageId] ?? "";
61
+ const message = Object.entries(d.data).reduce(
62
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
63
+ template,
64
+ );
65
+ out.push({ messageId: d.messageId, message, line: d.loc.start.line });
66
+ },
67
+ });
68
+ visitor.root?.();
69
+ return out;
70
+ }
71
+
72
+ const AIDC = { extends: "paperlint:aidc", kind: "regular" };
73
+
74
+ describe("paper/folder-venue-leftover — the folder names another venue", () => {
75
+ it("a folder named after the venue the paper left is reported, naming both and the cure", () => {
76
+ expect(lint("aisec-2026", AIDC)).toEqual([
77
+ {
78
+ messageId: "leftover",
79
+ line: 1,
80
+ message:
81
+ "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",
82
+ },
83
+ ]);
84
+ });
85
+
86
+ it("a venue named by an alias says which alias", () => {
87
+ expect(
88
+ lint("acm-ccs-agents", AIDC).map(
89
+ (r) => /names the venue (.*?), and/u.exec(r.message)?.[1],
90
+ ),
91
+ ).toEqual(["AISec («ACM CCS»)"]);
92
+ });
93
+
94
+ it("two other venues are two findings", () => {
95
+ expect(
96
+ lint("realm-then-aisec", AIDC).map(
97
+ (r) => /names the venue (\w+)/u.exec(r.message)?.[1],
98
+ ),
99
+ ).toEqual(["AISec", "REALM"]);
100
+ });
101
+
102
+ it("a project's own preset is judged like a shipped one: its label and aliases are its own", () => {
103
+ const extra = {
104
+ [`${PAPERS}/aidc-redux/venue.jsonc`]: JSON.stringify(
105
+ venuePreset("AISec 2027", { extends: "paperlint:aisec" }),
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
+ venuePreset("venue", { 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
+ });