paperlint 6.3.0 → 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 (144) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +2 -2
  3. package/dist/adapters/latex/conditionals.d.ts +21 -0
  4. package/dist/adapters/latex/conditionals.d.ts.map +1 -0
  5. package/dist/adapters/latex/conditionals.js +75 -0
  6. package/dist/adapters/latex/conditionals.js.map +1 -0
  7. package/dist/adapters/latex/headings.d.ts +10 -0
  8. package/dist/adapters/latex/headings.d.ts.map +1 -0
  9. package/dist/adapters/latex/headings.js +129 -0
  10. package/dist/adapters/latex/headings.js.map +1 -0
  11. package/dist/adapters/latex/index.d.ts +1 -0
  12. package/dist/adapters/latex/index.d.ts.map +1 -1
  13. package/dist/adapters/latex/index.js +3 -0
  14. package/dist/adapters/latex/index.js.map +1 -1
  15. package/dist/adapters/latex/parse.d.ts +1 -1
  16. package/dist/adapters/latex/parse.d.ts.map +1 -1
  17. package/dist/adapters/latex/parse.js +3 -2
  18. package/dist/adapters/latex/parse.js.map +1 -1
  19. package/dist/build.d.ts +1 -1
  20. package/dist/build.d.ts.map +1 -1
  21. package/dist/build.js +1 -1
  22. package/dist/build.js.map +1 -1
  23. package/dist/cli.d.ts +1 -1
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +17 -3
  26. package/dist/cli.js.map +1 -1
  27. package/dist/domain/heading-case.d.ts +100 -0
  28. package/dist/domain/heading-case.d.ts.map +1 -0
  29. package/dist/domain/heading-case.js +365 -0
  30. package/dist/domain/heading-case.js.map +1 -0
  31. package/dist/domain/tex-document.d.ts +32 -0
  32. package/dist/domain/tex-document.d.ts.map +1 -1
  33. package/dist/domain/tex-document.js +11 -0
  34. package/dist/domain/tex-document.js.map +1 -1
  35. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  36. package/dist/eslint-rules/latex-language.js +16 -3
  37. package/dist/eslint-rules/latex-language.js.map +1 -1
  38. package/dist/facts-file.d.ts +3 -3
  39. package/dist/facts-file.d.ts.map +1 -1
  40. package/dist/facts-file.js +3 -3
  41. package/dist/facts-file.js.map +1 -1
  42. package/dist/folder-venue-rule.js +6 -6
  43. package/dist/folder-venue-rule.js.map +1 -1
  44. package/dist/heading-case.d.ts +48 -0
  45. package/dist/heading-case.d.ts.map +1 -0
  46. package/dist/heading-case.js +191 -0
  47. package/dist/heading-case.js.map +1 -0
  48. package/dist/ports/latex.d.ts +7 -1
  49. package/dist/ports/latex.d.ts.map +1 -1
  50. package/dist/preset-card-rule.d.ts +100 -0
  51. package/dist/preset-card-rule.d.ts.map +1 -0
  52. package/dist/preset-card-rule.js +221 -0
  53. package/dist/preset-card-rule.js.map +1 -0
  54. package/dist/presets.d.ts +19 -2
  55. package/dist/presets.d.ts.map +1 -1
  56. package/dist/presets.js +97 -62
  57. package/dist/presets.js.map +1 -1
  58. package/dist/tex-requirements.d.ts +33 -3
  59. package/dist/tex-requirements.d.ts.map +1 -1
  60. package/dist/tex-requirements.js +27 -2
  61. package/dist/tex-requirements.js.map +1 -1
  62. package/dist/tex-venue-rules.d.ts +1 -1
  63. package/dist/tex-venue-rules.d.ts.map +1 -1
  64. package/dist/tex-venue-rules.js.map +1 -1
  65. package/docs/configuration.md +13 -1
  66. package/docs/optional-rules.md +4 -3
  67. package/docs/rules/format/page-limit.md +1 -1
  68. package/docs/rules/paper/folder-venue-leftover.md +1 -1
  69. package/docs/rules/talk/profile.md +1 -1
  70. package/docs/rules/tex/heading-case.md +424 -0
  71. package/docs/rules.md +62 -53
  72. package/eslint-rules/latex-language.test.mjs +12 -0
  73. package/eslint-rules/latex-language.ts +17 -4
  74. package/eslint-rules/paper-typography.test.mjs +12 -0
  75. package/eslint.config.mjs +59 -1
  76. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  77. package/fixtures/build-e2e/fallback/expect.json +3 -3
  78. package/lib/skill-checks.mjs +31 -0
  79. package/package.json +7 -1
  80. package/presets/acm-sigconf.jsonc +5 -0
  81. package/presets/acm-sigconf.md +43 -0
  82. package/presets/agenticdev.jsonc +27 -6
  83. package/presets/agenticdev.md +151 -0
  84. package/presets/aidc.jsonc +6 -0
  85. package/presets/aidc.md +75 -0
  86. package/presets/aisec.jsonc +5 -1
  87. package/presets/aisec.md +117 -0
  88. package/presets/ieee-conference.jsonc +5 -0
  89. package/presets/ieee-conference.md +38 -0
  90. package/presets/realm.jsonc +7 -2
  91. package/presets/realm.md +166 -0
  92. package/presets/tex/agenticdev.tex +1 -1
  93. package/presets/tex-base.jsonc +6 -0
  94. package/presets/tex-base.md +30 -0
  95. package/presets/venue-profile.schema.json +40 -1
  96. package/skills/find-venue/SKILL.md +6 -5
  97. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  98. package/skills/paper-pipeline/SKILL.md +5 -6
  99. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  100. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  101. package/skills/study-accepted-papers/SKILL.md +3 -3
  102. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  103. package/skills/submit-paper/SKILL.md +26 -22
  104. package/skills/submit-paper/SKILL.md.spec.ts +25 -21
  105. package/skills/submit-paper/references/publishers/acm.md +1 -1
  106. package/src/adapters/latex/conditionals.ts +130 -0
  107. package/src/adapters/latex/headings.ts +163 -0
  108. package/src/adapters/latex/index.test.ts +65 -0
  109. package/src/adapters/latex/index.ts +3 -0
  110. package/src/adapters/latex/parse.ts +3 -2
  111. package/src/build.ts +2 -2
  112. package/src/cli.test.ts +50 -7
  113. package/src/cli.ts +21 -4
  114. package/src/domain/heading-case.test.ts +660 -0
  115. package/src/domain/heading-case.ts +538 -0
  116. package/src/domain/tex-document.ts +36 -0
  117. package/src/domain/venue-name.test.ts +13 -7
  118. package/src/facts-file.test.ts +4 -4
  119. package/src/facts-file.ts +6 -6
  120. package/src/folder-venue-rule.test.ts +10 -10
  121. package/src/folder-venue-rule.ts +6 -6
  122. package/src/heading-case.test.ts +500 -0
  123. package/src/heading-case.ts +275 -0
  124. package/src/init.test.ts +1 -1
  125. package/src/new-paper.test.ts +10 -6
  126. package/src/paper-settings-commands.test.ts +25 -10
  127. package/src/ports/latex.ts +7 -0
  128. package/src/preset-card-rule.test.ts +266 -0
  129. package/src/preset-card-rule.ts +384 -0
  130. package/src/presets.test.ts +193 -40
  131. package/src/presets.ts +139 -70
  132. package/src/rule-cases.test.ts +136 -50
  133. package/src/submission.test.ts +2 -1
  134. package/src/talk-rules.test.ts +6 -3
  135. package/src/tex-requirements.harness.ts +25 -11
  136. package/src/tex-requirements.test.ts +127 -1
  137. package/src/tex-requirements.ts +70 -8
  138. package/src/tex-venue-rules.test.ts +35 -21
  139. package/src/tex-venue-rules.ts +2 -1
  140. package/src/venue-rules.test.ts +7 -6
  141. package/templates/paper/paper.tex +1 -1
  142. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  143. package/skills/submit-paper/references/venues/aisec.md +0 -105
  144. 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
@@ -532,7 +532,7 @@ describe("paperlint init — a first paper named after a venue", () => {
532
532
  expect(made).toEqual([]);
533
533
  expect(asked.filter((q) => /format/.test(q))).toEqual([]);
534
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',
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
536
  );
537
537
  });
538
538
  });
@@ -175,6 +175,7 @@ async function newIn(
175
175
 
176
176
  describe("paperlint new --venue / --kind", () => {
177
177
  const PRESET = `{
178
+ "type": "venue", "name": "my-workshop", "url": "https://example.org/cfp",
178
179
  "extends": "paperlint:acm-sigconf",
179
180
  "format": { "kinds": { "short": { "body_pages_max": 4 } } },
180
181
  }
@@ -205,6 +206,7 @@ describe("paperlint new --venue / --kind", () => {
205
206
  for (const template of [undefined, "\\documentclass[a]{}"]) {
206
207
  const r = await newIn(["--venue", "./venues/bare.jsonc"], {
207
208
  "venues/bare.jsonc": JSON.stringify({
209
+ type: "family",
208
210
  ...(template === undefined ? {} : { template }),
209
211
  tex: { packages: { x: ["x.sty"] } },
210
212
  }),
@@ -230,7 +232,9 @@ describe("paperlint new --venue / --kind", () => {
230
232
  expect(r.out).not.toMatch(/pdf\/profile/);
231
233
  expect(r.code).toBe(0);
232
234
  });
235
+ });
233
236
 
237
+ describe("paperlint new --venue — a venue it cannot use", () => {
234
238
  it("an unknown venue exits 2, lists the shipped presets, and creates nothing", async () => {
235
239
  const r = await newIn(["--venue", "icse"]);
236
240
  expect(r.code).toBe(2);
@@ -246,7 +250,7 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
246
250
  [
247
251
  "a kind the preset lacks",
248
252
  ["--venue", "agenticdev", "--kind", "long"],
249
- /`agenticdev` has no kind `long`; its kinds: short, full, demo/,
253
+ /`AgenticDev` has no kind `long`; its kinds: short, full, demo/,
250
254
  ],
251
255
  ["a kind without a venue", ["--kind", "short"], /--kind needs --venue/],
252
256
  [
@@ -270,7 +274,7 @@ describe("paperlint new --venue / --kind — refusals and defaults", () => {
270
274
  const r = await newIn(["--venue", "agenticdev"]);
271
275
  expect(r.settings()).toEqual({ extends: "paperlint:agenticdev" });
272
276
  expect(r.out).toMatch(
273
- /kind: not set — `agenticdev` sets a page limit per kind/,
277
+ /kind: not set — `AgenticDev` sets a page limit per kind/,
274
278
  );
275
279
  expect(r.out).toMatch(/pdf\/profile/);
276
280
  });
@@ -298,19 +302,19 @@ describe("paperlint new — a name that names a venue is refused", () => {
298
302
  "a venue's label, with the work's name left to suggest",
299
303
  "aisec-agent-drift",
300
304
  [],
301
- '`aisec-agent-drift` — the paper folder names a venue ("aisec"); venues change on resubmission, so name it after the work (e.g. "agent-drift")',
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")',
302
306
  ],
303
307
  [
304
308
  "nothing but a year left: no example",
305
309
  "aisec-2026",
306
310
  [],
307
- '`aisec-2026` — the paper folder names a venue ("aisec"); venues change on resubmission, so name it after the work',
311
+ '`aisec-2026` — the paper folder names a venue ("AISec"); venues change on resubmission, so name it after the work',
308
312
  ],
309
313
  [
310
314
  "its own venue too: right today, stale on the next resubmission",
311
315
  "aidc-2026",
312
316
  ["--venue", "aidc"],
313
- '`aidc-2026` — the paper folder names a venue ("aidc"); venues change on resubmission, so name it after the work',
317
+ '`aidc-2026` — the paper folder names a venue ("AIDC"); venues change on resubmission, so name it after the work',
314
318
  ],
315
319
  [
316
320
  "by an alias, glued to a year",
@@ -322,7 +326,7 @@ describe("paperlint new — a name that names a venue is refused", () => {
322
326
  "two venues",
323
327
  "realm-aisec-rules",
324
328
  [],
325
- '`realm-aisec-rules` — the paper folder names a venue ("aisec", "realm"); venues change on resubmission, so name it after the work (e.g. "rules")',
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")',
326
330
  ],
327
331
  ])("%s: exit 2, nothing written", async (_, name, args, reason) => {
328
332
  const r = await newIn(args, {}, name);
@@ -14,6 +14,7 @@ import { join } from "node:path";
14
14
  import { afterEach, describe, expect, it } from "vitest";
15
15
  import { run, toolchainTex } from "./cli.ts";
16
16
  import { lintReport } from "../test/lint-report.ts";
17
+ import { venuePreset } from "../test/support.ts";
17
18
 
18
19
  const dirs: string[] = [];
19
20
  afterEach(() => {
@@ -203,7 +204,7 @@ describe("papersDir — optional, `papers` by default", () => {
203
204
  });
204
205
 
205
206
  describe("paperlint lint — the venue preset's rules", () => {
206
- it("agenticdev's preset turns pdf/last-page-balance on for its paper alone", async () => {
207
+ it("agenticdev's preset turns pdf/last-page-balance and tex/heading-case on for its paper alone", async () => {
207
208
  const root = project({
208
209
  "papers/a/paperlint.json": JSON.stringify({
209
210
  extends: "paperlint:agenticdev",
@@ -219,7 +220,13 @@ describe("paperlint lint — the venue preset's rules", () => {
219
220
  // Scoped to the paper by `basePath` alone: which of its files the rule reaches is decided
220
221
  // once, by narrowing to paperlint's owned scopes (#101), not by a copy of their globs.
221
222
  basePath: join(root, "papers/a"),
222
- rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
223
+ rules: {
224
+ "pdf/last-page-balance": ["error", { tolerancePt: 120 }],
225
+ "tex/heading-case": [
226
+ "error",
227
+ { title: "chicago-headline", headings: "chicago-headline" },
228
+ ],
229
+ },
223
230
  },
224
231
  ],
225
232
  own: [],
@@ -256,6 +263,10 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
256
263
  // The preset's block comes first and the paper's own after it: the later block wins.
257
264
  expect(blocks.ok && blocks.value.preset[0]?.rules).toEqual({
258
265
  "pdf/last-page-balance": ["error", { tolerancePt: 120 }],
266
+ "tex/heading-case": [
267
+ "error",
268
+ { title: "chicago-headline", headings: "chicago-headline" },
269
+ ],
259
270
  });
260
271
  expect(blocks.ok && blocks.value.own[0]?.rules).toEqual({
261
272
  "pdf/last-page-balance": "off",
@@ -264,10 +275,12 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
264
275
 
265
276
  it("a project's own preset, by relative path: its rules apply, an unknown rule id is refused", async () => {
266
277
  const root = project({
267
- "venues/usenix-sec.jsonc": JSON.stringify({
268
- extends: "paperlint:acm-sigconf",
269
- rules: { "pdf/no-such-rule": "error" },
270
- }),
278
+ "venues/usenix-sec.jsonc": JSON.stringify(
279
+ venuePreset("usenix-sec", {
280
+ extends: "paperlint:acm-sigconf",
281
+ rules: { "pdf/no-such-rule": "error" },
282
+ }),
283
+ ),
271
284
  "papers/a/paperlint.json": JSON.stringify({
272
285
  extends: "../../venues/usenix-sec.jsonc",
273
286
  }),
@@ -294,10 +307,12 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
294
307
  describe("paperlint toolchain — installs what the project's own presets need", () => {
295
308
  it("the shipped union plus a relative preset's tex packages", () => {
296
309
  const root = project({
297
- "venues/usenix-sec.jsonc": JSON.stringify({
298
- extends: "paperlint:acm-sigconf",
299
- tex: { packages: { usenix: ["usenix.sty"] } },
300
- }),
310
+ "venues/usenix-sec.jsonc": JSON.stringify(
311
+ venuePreset("usenix-sec", {
312
+ extends: "paperlint:acm-sigconf",
313
+ tex: { packages: { usenix: ["usenix.sty"] } },
314
+ }),
315
+ ),
301
316
  "papers/a/paperlint.json": JSON.stringify({
302
317
  extends: "../../venues/usenix-sec.jsonc",
303
318
  }),
@@ -12,6 +12,7 @@ import type {
12
12
  Passage,
13
13
  Span,
14
14
  TextRun,
15
+ TitledHeading,
15
16
  } from "../domain/tex-document.ts";
16
17
 
17
18
  export interface LatexReader {
@@ -19,6 +20,12 @@ export interface LatexReader {
19
20
  readonly documentClass: (src: string) => ClassLine;
20
21
  /** Its sections, where the back matter begins, and where the document ends. */
21
22
  readonly outline: (src: string) => Outline;
23
+ /**
24
+ * Its `\title` and the titles of its `\section`, `\subsection`, `\subsubsection`, `\paragraph`
25
+ * and `\subparagraph` (starred or not, and each optional short title), each as the text the case
26
+ * judge reads: in document order.
27
+ */
28
+ readonly headings: (src: string) => readonly TitledHeading[];
22
29
  /** The text a reader sees: prose, the author block, footnotes — not comments, keys, math, code. */
23
30
  readonly renderedRuns: (src: string) => readonly TextRun[];
24
31
  /**