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,275 @@
1
+ /**
2
+ * THE RULE THAT JUDGES THE CASE OF TITLES — `tex/heading-case`, over the paper's `\title` and the
3
+ * titles of `\section`, `\subsection`, `\subsubsection`, `\paragraph` and `\subparagraph`
4
+ * (starred or not, and each optional short title).
5
+ *
6
+ * tex/heading-case off unless turned on every word of a title is in the case its scope asks:
7
+ * { title, headings, levels? }, each a CaseStyle
8
+ *
9
+ * ── WHY IT IS AN OPTION AND NOT A DEFAULT ────────────────────────────────────────
10
+ * Which style a venue asks is the venue's, and venues name different authorities: Conference
11
+ * Publishing (AgenticDev's proceedings) quotes the Chicago Manual of Style, ACL links APA title case,
12
+ * IEEE's manual has its own list. So the rule is optional, like `pdf/last-page-balance`: a venue
13
+ * preset whose author kit was read turns it on in its `rules` — today only `presets/agenticdev.jsonc`
14
+ * — and a paper may turn it on in its own `paperlint.json`.
15
+ *
16
+ * ── THE OPTIONS ARE PARSED ONCE, WHEN ESLINT LOADS THE RULE ──────────────────────
17
+ * ESLint checks the SHAPE against `meta.schema`: `title` and `headings` required, no other key, no
18
+ * unknown level. The VALUES are judged by `parseOptions`, because ajv's enum message («should be
19
+ * equal to one of the allowed values») names none of them: a misspelt style, the retired words
20
+ * `headline` and `off`, and `any` for both scopes each throw from `create`, which ESLint reports as
21
+ * «Error while loading rule» and stops the run. The judge then gets `HeadingCaseOptions` and never
22
+ * looks at the raw object again. A bare severity (`"error"`, no options) passes ESLint's schema —
23
+ * it validates the options it is given, not their count — so it is reported once, at the top of the
24
+ * file, rather than passing as a rule that checked nothing.
25
+ *
26
+ * ── ONE FILE AT A TIME ───────────────────────────────────────────────────────────
27
+ * The rule reads the file ESLint hands it, like `paper/section-word`: it runs on `paper.tex` and on
28
+ * every file `paper.tex` includes from its body (the `**` block of `src/cli.ts`), so a heading in
29
+ * `sections/results.tex` is reported at that file and line, and `--fix` edits that file. The
30
+ * preamble is read too: that is where `\title` stands.
31
+ *
32
+ * What a word is asked is decided in `src/domain/heading-case.ts`.
33
+ */
34
+ import {
35
+ CASE_STYLES,
36
+ HEADING_LEVELS,
37
+ judgeHeadingCase,
38
+ type CaseFinding,
39
+ type CaseStyle,
40
+ type HeadingCaseOptions,
41
+ } from "./domain/heading-case.ts";
42
+ import { fieldOf, isRecord } from "./domain/record.ts";
43
+ import type { HeadingLevel } from "./domain/tex-document.ts";
44
+ import type { LatexReader } from "./ports/latex.ts";
45
+ import { ruleDocsUrl } from "./tex-venue-rules.ts";
46
+
47
+ /** What the rule reads with: the LaTeX reader. */
48
+ export interface HeadingCaseDeps {
49
+ readonly latex: LatexReader;
50
+ }
51
+
52
+ type Loc = { readonly line: number; readonly column: number };
53
+
54
+ /** The slice of ESLint's rule context this rule uses. `raw` exists on the `.tex` language's. */
55
+ export interface HeadingCaseContext {
56
+ readonly options: readonly unknown[];
57
+ readonly sourceCode: {
58
+ readonly text: string;
59
+ readonly raw?: string;
60
+ getLocFromIndex(index: number): Loc;
61
+ };
62
+ report(d: {
63
+ readonly loc: { readonly start: Loc; readonly end: Loc };
64
+ readonly messageId: string;
65
+ readonly data?: Readonly<Record<string, string>>;
66
+ readonly fix?: (fixer: {
67
+ replaceTextRange(range: readonly [number, number], text: string): unknown;
68
+ }) => unknown;
69
+ }): void;
70
+ }
71
+
72
+ export interface HeadingCaseRule {
73
+ readonly meta: {
74
+ readonly type: "suggestion";
75
+ readonly fixable: "code";
76
+ readonly docs: { readonly description: string; readonly url: string };
77
+ readonly schema: readonly object[];
78
+ readonly messages: Readonly<Record<string, string>>;
79
+ };
80
+ create(context: HeadingCaseContext): { root?: () => void };
81
+ }
82
+
83
+ const DOCS_URL = ruleDocsUrl("heading-case");
84
+
85
+ /** The rule's options as ESLint gave them: a configuration, none at all, or one it refuses. */
86
+ type Asked =
87
+ | { readonly kind: "options"; readonly options: HeadingCaseOptions }
88
+ | { readonly kind: "none" }
89
+ | { readonly kind: "refused"; readonly reason: string };
90
+
91
+ const refused = (reason: string): Asked => ({
92
+ kind: "refused",
93
+ reason: `${reason} — ${DOCS_URL}`,
94
+ });
95
+
96
+ const quoted = (xs: readonly string[]): string =>
97
+ xs.map((x) => JSON.stringify(x)).join(", ");
98
+
99
+ /** One style value, or why it is none; `key` is how the message names where it stands. */
100
+ function styleOf(
101
+ key: string,
102
+ v: unknown,
103
+ ): CaseStyle | { readonly why: string } {
104
+ const style = CASE_STYLES.find((s) => s === v);
105
+ if (style !== undefined) return style;
106
+ if (v === "headline")
107
+ return {
108
+ why: `"${key}" is "headline", which names no single style: write "chicago-headline" for the Chicago Manual of Style headline style. Other headline variants (APA title case, IEEE, Springer) are not implemented yet, and they disagree with Chicago on words such as With, Between and From`,
109
+ };
110
+ if (v === "off")
111
+ return {
112
+ why: `"${key}" is "off", which is not a style: write "any" to leave this scope unconstrained, or turn the whole rule off with "tex/heading-case": "off"`,
113
+ };
114
+ const shown = v === undefined ? "missing" : JSON.stringify(v);
115
+ return {
116
+ why: `"${key}" is ${shown}, which is not a style: one of ${quoted(CASE_STYLES)}`,
117
+ };
118
+ }
119
+
120
+ type Levels = Readonly<Partial<Record<HeadingLevel, CaseStyle>>>;
121
+
122
+ /** The `levels` object: the levels it names, each with its style, or why it is refused. */
123
+ function levelsOf(v: unknown): Levels | { readonly why: string } {
124
+ if (v === undefined) return {};
125
+ if (!isRecord(v))
126
+ return {
127
+ why: `"levels" is ${JSON.stringify(v)}, not an object such as {"paragraph": "sentence"}`,
128
+ };
129
+ return HEADING_LEVELS.reduce<Levels | { readonly why: string }>(
130
+ (acc, level) => {
131
+ if ("why" in acc || !(level in v)) return acc;
132
+ const style = styleOf(`levels.${level}`, v[level]);
133
+ return typeof style === "string" ? { ...acc, [level]: style } : style;
134
+ },
135
+ {},
136
+ );
137
+ }
138
+
139
+ /** Whether every scope is `any`: the rule turned off, said in a second way. */
140
+ const asksNothing = (
141
+ o: HeadingCaseOptions & { readonly levels: Levels },
142
+ ): boolean =>
143
+ o.title === "any" &&
144
+ o.headings === "any" &&
145
+ Object.values(o.levels).every((s) => s === "any");
146
+
147
+ /** ESLint's options for the rule → what it is asked, parsed once. */
148
+ function parseOptions(options: readonly unknown[]): Asked {
149
+ const [raw] = options;
150
+ if (raw === undefined) return { kind: "none" };
151
+ if (!isRecord(raw))
152
+ return refused(
153
+ `the options are ${JSON.stringify(raw)}, not an object such as {"title": "chicago-headline", "headings": "chicago-headline"}`,
154
+ );
155
+ const title = styleOf("title", fieldOf(raw, "title"));
156
+ if (typeof title !== "string") return refused(title.why);
157
+ const headings = styleOf("headings", fieldOf(raw, "headings"));
158
+ if (typeof headings !== "string") return refused(headings.why);
159
+ const levels = levelsOf(fieldOf(raw, "levels"));
160
+ if ("why" in levels) return refused(levels.why);
161
+ const parsed = { title, headings, levels };
162
+ return asksNothing(parsed)
163
+ ? refused(
164
+ `"title" and "headings" are both "any" and no level asks for more: that is the rule turned off — remove it from "rules", or set it to "off"`,
165
+ )
166
+ : { kind: "options", options: parsed };
167
+ }
168
+
169
+ const MESSAGES: Readonly<Record<string, string>> = {
170
+ capitalize:
171
+ "{{where}}: capitalize «{{word}}» → «{{expected}}» — {{why}} (chicago-headline)",
172
+ lowercase:
173
+ "{{where}}: lowercase «{{word}}» → «{{expected}}» — {{why}} (chicago-headline)",
174
+ sentenceFirst:
175
+ "{{where}}: capitalize the first word, «{{word}}» → «{{expected}}» (sentence; not fixed: a product name written in lowercase looks the same)",
176
+ noOptions: `tex/heading-case is on with no options, so it judged nothing: give it the styles the venue asks, e.g. ["error", {"title": "chicago-headline", "headings": "chicago-headline"}] (styles: ${quoted(CASE_STYLES)})`,
177
+ };
178
+
179
+ /** One finding reported where its word stands, with the fix when the judge is sure of it. */
180
+ function report(context: HeadingCaseContext, f: CaseFinding): void {
181
+ const sc = context.sourceCode;
182
+ const { fix } = f;
183
+ context.report({
184
+ loc: {
185
+ start: sc.getLocFromIndex(f.at.start),
186
+ end: sc.getLocFromIndex(f.at.end),
187
+ },
188
+ messageId: f.messageId,
189
+ data: f.data,
190
+ ...(fix === null
191
+ ? {}
192
+ : {
193
+ fix: (fixer) =>
194
+ fixer.replaceTextRange([fix.span.start, fix.span.end], fix.text),
195
+ }),
196
+ });
197
+ }
198
+
199
+ /**
200
+ * A style value, as the schema checks it: a string. Which strings are styles is `parseOptions`'s to
201
+ * say, so that the refusal can name them (see the header).
202
+ */
203
+ const STYLE = { type: "string" } as const;
204
+
205
+ const META: HeadingCaseRule["meta"] = {
206
+ type: "suggestion",
207
+ fixable: "code",
208
+ docs: {
209
+ description:
210
+ "the words of the paper's title and of its headings are in the capitalization the venue asks: chicago-headline or sentence",
211
+ url: DOCS_URL,
212
+ },
213
+ schema: [
214
+ {
215
+ type: "object",
216
+ properties: {
217
+ title: STYLE,
218
+ headings: STYLE,
219
+ levels: {
220
+ type: "object",
221
+ properties: Object.fromEntries(
222
+ HEADING_LEVELS.map((level) => [level, STYLE]),
223
+ ),
224
+ additionalProperties: false,
225
+ },
226
+ },
227
+ required: ["title", "headings"],
228
+ additionalProperties: false,
229
+ },
230
+ ],
231
+ messages: MESSAGES,
232
+ };
233
+
234
+ /** The rule turned on with no options: said once, at the top of the file. */
235
+ function reportNoOptions(context: HeadingCaseContext): void {
236
+ const start = context.sourceCode.getLocFromIndex(0);
237
+ context.report({ loc: { start, end: start }, messageId: "noOptions" });
238
+ }
239
+
240
+ /** One file against the options the rule was given. */
241
+ function judgeFile(
242
+ context: HeadingCaseContext,
243
+ deps: HeadingCaseDeps,
244
+ options: HeadingCaseOptions,
245
+ ): void {
246
+ const sc = context.sourceCode;
247
+ const src = sc.raw ?? sc.text;
248
+ judgeHeadingCase(deps.latex.headings(src), options, src).forEach((f) => {
249
+ report(context, f);
250
+ });
251
+ }
252
+
253
+ /** The rule: its options are parsed when ESLint loads it, and a refused one stops the run. */
254
+ export const headingCaseRule = (deps: HeadingCaseDeps): HeadingCaseRule => ({
255
+ meta: META,
256
+ create: (context) => {
257
+ const asked = parseOptions(context.options);
258
+ switch (asked.kind) {
259
+ case "refused":
260
+ throw new Error(asked.reason);
261
+ case "none":
262
+ return {
263
+ root: () => {
264
+ reportNoOptions(context);
265
+ },
266
+ };
267
+ case "options":
268
+ return {
269
+ root: () => {
270
+ judgeFile(context, deps, asked.options);
271
+ },
272
+ };
273
+ }
274
+ },
275
+ });
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"),
@@ -169,6 +175,7 @@ async function newIn(args: string[], files: Record<string, string> = {}) {
169
175
 
170
176
  describe("paperlint new --venue / --kind", () => {
171
177
  const PRESET = `{
178
+ "type": "venue", "name": "my-workshop", "url": "https://example.org/cfp",
172
179
  "extends": "paperlint:acm-sigconf",
173
180
  "format": { "kinds": { "short": { "body_pages_max": 4 } } },
174
181
  }
@@ -199,6 +206,7 @@ describe("paperlint new --venue / --kind", () => {
199
206
  for (const template of [undefined, "\\documentclass[a]{}"]) {
200
207
  const r = await newIn(["--venue", "./venues/bare.jsonc"], {
201
208
  "venues/bare.jsonc": JSON.stringify({
209
+ type: "family",
202
210
  ...(template === undefined ? {} : { template }),
203
211
  tex: { packages: { x: ["x.sty"] } },
204
212
  }),
@@ -224,7 +232,9 @@ describe("paperlint new --venue / --kind", () => {
224
232
  expect(r.out).not.toMatch(/pdf\/profile/);
225
233
  expect(r.code).toBe(0);
226
234
  });
235
+ });
227
236
 
237
+ describe("paperlint new --venue — a venue it cannot use", () => {
228
238
  it("an unknown venue exits 2, lists the shipped presets, and creates nothing", async () => {
229
239
  const r = await newIn(["--venue", "icse"]);
230
240
  expect(r.code).toBe(2);
@@ -240,7 +250,7 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
240
250
  [
241
251
  "a kind the preset lacks",
242
252
  ["--venue", "agenticdev", "--kind", "long"],
243
- /`agenticdev` has no kind `long`; its kinds: short, full, demo/,
253
+ /`AgenticDev` has no kind `long`; its kinds: short, full, demo/,
244
254
  ],
245
255
  ["a kind without a venue", ["--kind", "short"], /--kind needs --venue/],
246
256
  [
@@ -264,7 +274,7 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
264
274
  const r = await newIn(["--venue", "agenticdev"]);
265
275
  expect(r.settings()).toEqual({ extends: "paperlint:agenticdev" });
266
276
  expect(r.out).toMatch(
267
- /kind: not set — `agenticdev` sets a page limit per kind/,
277
+ /kind: not set — `AgenticDev` sets a page limit per kind/,
268
278
  );
269
279
  expect(r.out).toMatch(/pdf\/profile/);
270
280
  });
@@ -286,6 +296,117 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
286
296
  });
287
297
  });
288
298
 
299
+ describe("paperlint new — a name that names a venue is refused", () => {
300
+ it.each<[string, string, string[], string]>([
301
+ [
302
+ "a venue's label, with the work's name left to suggest",
303
+ "aisec-agent-drift",
304
+ [],
305
+ '`aisec-agent-drift` — the paper folder names a venue ("AISec"); venues change on resubmission, so name it after the work (e.g. "agent-drift")',
306
+ ],
307
+ [
308
+ "nothing but a year left: no example",
309
+ "aisec-2026",
310
+ [],
311
+ '`aisec-2026` — the paper folder names a venue ("AISec"); venues change on resubmission, so name it after the work',
312
+ ],
313
+ [
314
+ "its own venue too: right today, stale on the next resubmission",
315
+ "aidc-2026",
316
+ ["--venue", "aidc"],
317
+ '`aidc-2026` — the paper folder names a venue ("AIDC"); venues change on resubmission, so name it after the work',
318
+ ],
319
+ [
320
+ "by an alias, glued to a year",
321
+ "acsac2026-rules",
322
+ [],
323
+ '`acsac2026-rules` — the paper folder names a venue ("ACSAC"); venues change on resubmission, so name it after the work',
324
+ ],
325
+ [
326
+ "two venues",
327
+ "realm-aisec-rules",
328
+ [],
329
+ '`realm-aisec-rules` — the paper folder names a venue ("AISec", "REALM"); venues change on resubmission, so name it after the work (e.g. "rules")',
330
+ ],
331
+ ])("%s: exit 2, nothing written", async (_, name, args, reason) => {
332
+ const r = await newIn(args, {}, name);
333
+ expect(r.code).toBe(2);
334
+ expect(r.out).toBe(
335
+ ` ✗ ${reason}\n if the venue really belongs in the name, rerun \`paperlint new\` with --allow-venue-name`,
336
+ );
337
+ expect(existsSync(r.dir)).toBe(false);
338
+ });
339
+
340
+ it("an existing folder that names a venue gets no missing files either", async () => {
341
+ const r = await newIn(
342
+ [],
343
+ { "papers/aisec-2026/paper.tex": "x" },
344
+ "aisec-2026",
345
+ );
346
+ expect(r.code).toBe(2);
347
+ expect(readdirSync(r.dir)).toEqual(["paper.tex"]);
348
+ });
349
+
350
+ it.each(["agent-rule-drift", "overrealm-realms"])(
351
+ "%s names no venue: created, as before",
352
+ async (name) => {
353
+ const r = await newIn([], {}, name);
354
+ expect(r.out).not.toMatch(/names a venue/);
355
+ expect(existsSync(join(r.dir, "PIPELINE-STATUS.md"))).toBe(true);
356
+ expect(r.code).toBe(0);
357
+ },
358
+ );
359
+ });
360
+
361
+ describe("paperlint new --allow-venue-name", () => {
362
+ it("the folder is created as any other, and nothing is said about the venue", async () => {
363
+ const r = await newIn(["--allow-venue-name"], {}, "realm-of-agents");
364
+ expect(r.code).toBe(0);
365
+ expect(r.out).not.toMatch(/venue really belongs|names a venue/);
366
+ expect(readdirSync(r.dir).sort()).toEqual([
367
+ "PIPELINE-STATUS.md",
368
+ "paper.tex",
369
+ "paperlint.json",
370
+ ]);
371
+ });
372
+
373
+ it.each<{
374
+ readonly what: string;
375
+ readonly name: string;
376
+ readonly args: readonly string[];
377
+ readonly files: Record<string, string>;
378
+ readonly says: RegExp;
379
+ }>([
380
+ {
381
+ what: "an invalid name",
382
+ name: "Realm",
383
+ args: [],
384
+ files: {},
385
+ says: /may hold only a-z/,
386
+ },
387
+ {
388
+ what: "an unknown preset",
389
+ name: "realm-of-agents",
390
+ args: ["--venue", "nope"],
391
+ files: {},
392
+ says: /no such venue preset/,
393
+ },
394
+ {
395
+ what: "an existing paperlint.json with --venue",
396
+ name: "realm-of-agents",
397
+ args: ["--venue", "aisec"],
398
+ files: {
399
+ "papers/realm-of-agents/paperlint.json": '{ "extends": null }\n',
400
+ },
401
+ says: /already exists and is never overwritten/,
402
+ },
403
+ ])("skips no other refusal: $what", async ({ name, args, files, says }) => {
404
+ const r = await newIn(["--allow-venue-name", ...args], files, name);
405
+ expect(r.code).toBe(2);
406
+ expect(`${r.out}\n${r.err}`).toMatch(says);
407
+ });
408
+ });
409
+
289
410
  describe("chooseVenue — on a terminal", () => {
290
411
  const at = (answers: string[]) => {
291
412
  const asked: string[] = [];
@@ -340,6 +461,7 @@ describe("a project's own template", () => {
340
461
  const src = join(papers, OVERRIDE_DIR, "paperlint.json");
341
462
  writeFileSync(src, '{\n // our house venue\n "extends": null\n}\n');
342
463
  const r = newPaper(papers, "p", "tex", {
464
+ venues: [],
343
465
  venue: { extends: "paperlint:aisec", kind: null },
344
466
  });
345
467
  expect(r).toEqual({
@@ -353,7 +475,10 @@ describe("a project's own template", () => {
353
475
  const papers = join(tmp(), "papers");
354
476
  mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
355
477
  writeFileSync(join(papers, OVERRIDE_DIR, "paperlint.json"), "{}\n");
356
- const lines = reportNewPaper(newPaper(papers, "p", "tex"), (p) => p);
478
+ const lines = reportNewPaper(
479
+ newPaper(papers, "p", "tex", { venues: [] }),
480
+ (p) => p,
481
+ );
357
482
  expect(lines).toContain(
358
483
  " + paperlint.json (from the project's .template/ template)",
359
484
  );