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
+ * `paper/folder-venue-leftover` — a paper folder named after a venue the paper no longer targets.
3
+ *
4
+ * A folder named `aisec-2026` reads as "the AISec paper" for as long as it exists; once the paper is
5
+ * rejected and goes to another venue, the name is wrong and nothing says so. paperlint already knows
6
+ * the venue a paper targets — its `paperlint.json` `extends` — and the names of every venue it ships
7
+ * a preset for, so the stale case costs one comparison. The sibling of `tex/venue-leftover`, which
8
+ * does the same for the text of the paper.
9
+ *
10
+ * ── WHAT IT READS ────────────────────────────────────────────────────────────────
11
+ * The folder's name, as the path module gives it (the directory of the linted file), its words
12
+ * against each shipped venue's label and `aliases` (`src/domain/venue-name.ts`). The venues are
13
+ * `otherVenues` — the presets not on this paper's own chain, the same list `tex/venue-leftover`
14
+ * reads — and the paper's own label and aliases are never reported.
15
+ *
16
+ * ── WHICH FILE IT REPORTS ON ─────────────────────────────────────────────────────
17
+ * The folder itself is not a file ESLint lints. `PIPELINE-STATUS.md` is: every paper folder has one
18
+ * (`paperlint lint` requires it, `paperlint new` writes it), whatever the paper's source format, so
19
+ * the finding is made once per paper, on that file's first line, where a disable directive can
20
+ * also sit.
21
+ *
22
+ * ── WHO SPEAKS WHEN THERE IS NOTHING TO JUDGE ───────────────────────────────────
23
+ * No `paperlint.json`, no `extends`, or one that does not resolve: silent. With no venue chosen
24
+ * there is no "other" venue, and `pdf/measured` and `pdf/profile` already speak about the rest. An
25
+ * `extends` that names only a template family is silent too (`isFamily`).
26
+ */
27
+ import { basename, dirname } from "node:path";
28
+ import { venuesNamedBy, type NamedIn } from "./domain/venue-name.ts";
29
+ import { paperPreset, type Preset } from "./presets.ts";
30
+ import { otherVenues, type OtherVenue } from "./tex-venue-rules.ts";
31
+ import type { Finding, VenueRuleDeps } from "./venue-rules.ts";
32
+
33
+ /** The file the rule reports on: one per paper folder. */
34
+ const STATUS_FILE = "PIPELINE-STATUS.md";
35
+
36
+ /** The slice of ESLint's rule context this rule uses: the file, its first line, and `report`. */
37
+ export interface FolderRuleContext {
38
+ readonly filename: string;
39
+ readonly sourceCode: {
40
+ getLocFromIndex(index: number): Readonly<{ line: number; column: number }>;
41
+ };
42
+ report(d: {
43
+ readonly loc: {
44
+ readonly start: { line: number; column: number };
45
+ readonly end: { line: number; column: number };
46
+ };
47
+ readonly messageId: string;
48
+ readonly data: Readonly<Record<string, string | number>>;
49
+ }): void;
50
+ }
51
+
52
+ export interface FolderRuleModule {
53
+ readonly meta: {
54
+ readonly type: "suggestion";
55
+ readonly docs: { readonly description: string; readonly url: string };
56
+ readonly schema: readonly object[];
57
+ readonly messages: Readonly<Record<string, string>>;
58
+ };
59
+ create(context: FolderRuleContext): { root?: () => void };
60
+ }
61
+
62
+ /** A venue as the message names it: its label, and the alias when the folder used another name. */
63
+ const shown = (n: NamedIn): string =>
64
+ n.name.toLowerCase() === n.venue.toLowerCase()
65
+ ? n.venue
66
+ : `${n.venue} («${n.name}»)`;
67
+
68
+ /**
69
+ * Each other venue the folder name names, as a finding. Pure: the folder's name, the paper's
70
+ * resolved preset, and the other shipped venues.
71
+ */
72
+ export function judgeFolder(
73
+ folder: string,
74
+ preset: Pick<Preset, "label" | "aliases">,
75
+ others: readonly OtherVenue[],
76
+ ): readonly Finding[] {
77
+ return venuesNamedBy(folder, others, [preset.label, ...preset.aliases]).map(
78
+ (n) => ({
79
+ messageId: "leftover",
80
+ data: { folder, other: shown(n), venue: preset.label },
81
+ }),
82
+ );
83
+ }
84
+
85
+ /**
86
+ * A template family (`acm-sigconf`, `ieee-conference`), as the preset declares itself (`"type":
87
+ * "family"`), which a paper for a venue paperlint has no preset for extends directly. Such a paper
88
+ * declares a format, not a venue, so a venue in its folder name may be its own: the accepted ACSAC
89
+ * papers in `fixtures/accepted-papers/` extend `ieee-conference` from folders named `…-acsac24`, and
90
+ * ACSAC is the parent conference `aidc` names as an alias.
91
+ */
92
+ const isFamily = (preset: Preset): boolean => preset.identity.type === "family";
93
+
94
+ /** The findings for the paper folder `dir`; none when it has no resolved venue, or only a family. */
95
+ function findingsFor(dir: string, deps: VenueRuleDeps): readonly Finding[] {
96
+ const p = paperPreset(dir, deps);
97
+ if (p.kind !== "resolved" || isFamily(p.preset)) return [];
98
+ const others = otherVenues(p.preset, deps);
99
+ return judgeFolder(basename(dir), p.preset, others);
100
+ }
101
+
102
+ const META: FolderRuleModule["meta"] = {
103
+ type: "suggestion",
104
+ docs: {
105
+ description:
106
+ "the paper folder's name names a shipped venue other than the one the paper extends — a name left from an earlier submission",
107
+ url: "https://github.com/zernie/paperlint/blob/main/docs/rules/paper/folder-venue-leftover.md",
108
+ },
109
+ schema: [],
110
+ messages: {
111
+ leftover:
112
+ "the folder name «{{folder}}» names the venue {{other}}, and this paper extends {{venue}} — the name went stale when the venue changed, and venues change on every resubmission. Name the folder after the work (what the paper shows), not the venue",
113
+ },
114
+ };
115
+
116
+ /** The rule, reading the paper's settings and the shipped presets through `deps`. */
117
+ export function folderVenueRules(
118
+ deps: VenueRuleDeps,
119
+ ): Readonly<Record<"folder-venue-leftover", FolderRuleModule>> {
120
+ return {
121
+ "folder-venue-leftover": {
122
+ meta: META,
123
+ create(context) {
124
+ if (basename(context.filename) !== STATUS_FILE) return {};
125
+ return {
126
+ root() {
127
+ const at = context.sourceCode.getLocFromIndex(0);
128
+ findingsFor(dirname(context.filename), deps).forEach((f) => {
129
+ context.report({ loc: { start: at, end: at }, ...f });
130
+ });
131
+ },
132
+ };
133
+ },
134
+ },
135
+ };
136
+ }
137
+
138
+ /** The level it is on at in paperlint's own config, for every paper's `PIPELINE-STATUS.md`. */
139
+ export const FOLDER_VENUE_RULE_LEVELS: Readonly<
140
+ Record<"paper/folder-venue-leftover", "warn">
141
+ > = { "paper/folder-venue-leftover": "warn" };
@@ -0,0 +1,500 @@
1
+ /**
2
+ * `tex/heading-case` through the whole CLI, on small paper trees: what turns it on and what it says
3
+ * once it is on, the configurations it refuses when ESLint loads it, the fixes `--fix` writes, a
4
+ * title or heading in an included file, and the escape hatch. The words the judge asks for are
5
+ * tested where it lives (`src/domain/heading-case.test.ts`).
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+ import { z } from "zod";
11
+ import { run } from "./cli.ts";
12
+ import { headingCaseRule } from "./heading-case.ts";
13
+ import { latexReader } from "./adapters/latex/index.ts";
14
+ import { useTempDir, writeTree } from "../test/support.ts";
15
+
16
+ const Results = z.array(
17
+ z.looseObject({
18
+ filePath: z.string(),
19
+ messages: z.array(
20
+ z.looseObject({
21
+ ruleId: z.string().nullable(),
22
+ severity: z.number(),
23
+ line: z.number().optional(),
24
+ message: z.string(),
25
+ }),
26
+ ),
27
+ }),
28
+ );
29
+
30
+ const ACM = "\\documentclass[sigconf]{acmart}";
31
+ const doc = (body: string): string =>
32
+ `${ACM}\n\\begin{document}\n${body}\n\\end{document}\n`;
33
+
34
+ const paper = (
35
+ tex: string,
36
+ settings: Record<string, unknown> | null,
37
+ extra: Record<string, string> = {},
38
+ ): Record<string, string> => ({
39
+ "package.json": '{"name":"consumer","private":true}',
40
+ "papers/p/PIPELINE-STATUS.md": "---\nstages: []\n---\n# PIPELINE-STATUS\n",
41
+ "papers/p/paper.tex": tex,
42
+ ...(settings === null
43
+ ? {}
44
+ : { "papers/p/paperlint.json": JSON.stringify(settings) }),
45
+ ...extra,
46
+ });
47
+
48
+ /** Lint the tree; the messages of `tex/heading-case` as `file:line:severity message`, and the project dir. */
49
+ async function lint(
50
+ tree: Record<string, string>,
51
+ args: readonly string[] = [],
52
+ ): Promise<{ found: string[]; dir: string }> {
53
+ const dir = writeTree(useTempDir("paperlint-heading-case-"), tree);
54
+ const out: string[] = [];
55
+ await run(["lint", "--json", ...args], {
56
+ cwd: dir,
57
+ log: (...a: unknown[]) => out.push(a.join(" ")),
58
+ err: () => undefined,
59
+ });
60
+ const found = Results.parse(JSON.parse(out.join("\n"))).flatMap((r) =>
61
+ r.messages
62
+ .filter((m) => m.ruleId === "tex/heading-case")
63
+ .map(
64
+ (m) =>
65
+ `${r.filePath.slice(dir.length + 1)}:${String(m.line)}:${String(m.severity)} ${m.message}`,
66
+ ),
67
+ );
68
+ return { found, dir };
69
+ }
70
+
71
+ /**
72
+ * What a lint run threw when ESLint loaded the rule with `entry`: its message, whole, with the
73
+ * temporary project directory written `<project>`.
74
+ */
75
+ async function refusal(entry: unknown): Promise<string> {
76
+ const settings = { rules: { "tex/heading-case": entry } };
77
+ return lint(paper(doc("\\section{Related Work}"), settings)).then(
78
+ () => "no error",
79
+ (e: unknown) =>
80
+ (e instanceof Error ? e.message : String(e)).replace(
81
+ /\S*paperlint-heading-case-[^/]+/gu,
82
+ "<project>",
83
+ ),
84
+ );
85
+ }
86
+
87
+ const PRESET = { extends: "paperlint:agenticdev", kind: "short" };
88
+ const CHICAGO = { title: "chicago-headline", headings: "chicago-headline" };
89
+ const SENTENCE_CASE = [
90
+ "\\section{Related work}",
91
+ "\\subsection{The advertised savings don't show up}",
92
+ "\\section{Method: a cost-aware, correctness-gated harness}",
93
+ "\\subsection{A ceiling for any output-trimming tool}",
94
+ ].join("\n");
95
+ const HEADLINE_CASE = [
96
+ "\\section{Related Work}",
97
+ "\\subsection{The Advertised Savings Don't Show Up}",
98
+ "\\section{Method: A Cost-Aware, Correctness-Gated Harness}",
99
+ "\\subsection{A Ceiling for Any Output-Trimming Tool}",
100
+ ].join("\n");
101
+ /** A paper whose preamble holds `title` (a whole `\\title…` command). */
102
+ const titled = (title: string, body = "\\section{Introduction}"): string =>
103
+ `${ACM}\n${title}\n\\begin{document}\n\\maketitle\n${body}\n\\end{document}\n`;
104
+
105
+ describe("tex/heading-case on a paper that extends the AgenticDev preset", () => {
106
+ it("🔴 reports every sentence-case heading, as an error, on its line", async () => {
107
+ const { found } = await lint(paper(doc(SENTENCE_CASE), PRESET));
108
+ expect(found.map((f) => f.slice(0, f.indexOf(" ")))).toEqual([
109
+ "papers/p/paper.tex:3:2",
110
+ "papers/p/paper.tex:4:2",
111
+ "papers/p/paper.tex:4:2",
112
+ "papers/p/paper.tex:4:2",
113
+ "papers/p/paper.tex:4:2",
114
+ "papers/p/paper.tex:4:2",
115
+ "papers/p/paper.tex:5:2",
116
+ "papers/p/paper.tex:5:2",
117
+ "papers/p/paper.tex:5:2",
118
+ "papers/p/paper.tex:5:2",
119
+ "papers/p/paper.tex:6:2",
120
+ "papers/p/paper.tex:6:2",
121
+ "papers/p/paper.tex:6:2",
122
+ "papers/p/paper.tex:6:2",
123
+ ]);
124
+ expect(found[0]).toBe(
125
+ "papers/p/paper.tex:3:2 \\section: capitalize «work» → «Work» — the last word of a heading is capitalized (chicago-headline)",
126
+ );
127
+ });
128
+
129
+ it("🔴 stays silent on the corrected headings", async () => {
130
+ expect((await lint(paper(doc(HEADLINE_CASE), PRESET))).found).toEqual([]);
131
+ });
132
+
133
+ it("--fix writes the corrected headings, and a second run is clean", async () => {
134
+ const tree = paper(doc(SENTENCE_CASE), PRESET);
135
+ const { dir } = await lint(tree, ["--fix"]);
136
+ expect(readFileSync(join(dir, "papers/p/paper.tex"), "utf8")).toBe(
137
+ doc(HEADLINE_CASE),
138
+ );
139
+ const again: string[] = [];
140
+ await run(["lint", "--json"], {
141
+ cwd: dir,
142
+ log: (...a: unknown[]) => again.push(a.join(" ")),
143
+ err: () => undefined,
144
+ });
145
+ expect(again.join("\n")).not.toContain("tex/heading-case");
146
+ });
147
+ });
148
+
149
+ describe("tex/heading-case on an AgenticDev paper — its \\title, and the paper's own choices", () => {
150
+ it("🔴 judges the paper's \\title too, short and long, at the line each stands on", async () => {
151
+ const tex = titled(
152
+ "\\title[the short\n one]{Measuring the wrong\n thing}",
153
+ );
154
+ const { found } = await lint(paper(tex, PRESET));
155
+ expect(found).toEqual([
156
+ "papers/p/paper.tex:2:2 \\title[…]: capitalize «the» → «The» — the first word of a title is capitalized (chicago-headline)",
157
+ "papers/p/paper.tex:2:2 \\title[…]: capitalize «short» → «Short» — nouns, pronouns, verbs, adjectives and adverbs are capitalized (chicago-headline)",
158
+ "papers/p/paper.tex:3:2 \\title[…]: capitalize «one» → «One» — the last word of a title is capitalized (chicago-headline)",
159
+ "papers/p/paper.tex:3:2 \\title: capitalize «wrong» → «Wrong» — nouns, pronouns, verbs, adjectives and adverbs are capitalized (chicago-headline)",
160
+ "papers/p/paper.tex:4:2 \\title: capitalize «thing» → «Thing» — the last word of a title is capitalized (chicago-headline)",
161
+ ]);
162
+ });
163
+
164
+ it("--fix writes the corrected \\title, and leaves code and math in it alone", async () => {
165
+ const tree = paper(
166
+ titled("\\title{Why \\texttt{grep} fails for $k$ in practice}"),
167
+ PRESET,
168
+ );
169
+ const { dir } = await lint(tree, ["--fix"]);
170
+ expect(readFileSync(join(dir, "papers/p/paper.tex"), "utf8")).toBe(
171
+ titled("\\title{Why \\texttt{grep} Fails for $k$ in Practice}"),
172
+ );
173
+ });
174
+ });
175
+
176
+ describe("tex/heading-case on an AgenticDev paper — what the paper changes for itself", () => {
177
+ it("the paper turns it off in its own paperlint.json", async () => {
178
+ const off = { ...PRESET, rules: { "tex/heading-case": "off" } };
179
+ expect((await lint(paper(doc(SENTENCE_CASE), off))).found).toEqual([]);
180
+ });
181
+
182
+ it("the paper chooses its own styles: sentence asks only for the first word", async () => {
183
+ const own = {
184
+ ...PRESET,
185
+ rules: {
186
+ "tex/heading-case": ["error", { title: "any", headings: "sentence" }],
187
+ },
188
+ };
189
+ expect((await lint(paper(doc(SENTENCE_CASE), own))).found).toEqual([]);
190
+ const { found } = await lint(paper(doc("\\section{related work}"), own));
191
+ expect(found).toEqual([
192
+ "papers/p/paper.tex:3:2 \\section: capitalize the first word, «related» → «Related» (sentence; not fixed: a product name written in lowercase looks the same)",
193
+ ]);
194
+ });
195
+
196
+ it("a level the paper names follows its own style, the others follow `headings`", async () => {
197
+ const own = {
198
+ ...PRESET,
199
+ rules: {
200
+ "tex/heading-case": [
201
+ "error",
202
+ { ...CHICAGO, levels: { paragraph: "sentence" } },
203
+ ],
204
+ },
205
+ };
206
+ const body = "\\section{Related Work}\n\\paragraph{Threats to validity.}";
207
+ expect((await lint(paper(doc(body), own))).found).toEqual([]);
208
+ const loose = "\\section{Related work}\n\\paragraph{threats to validity.}";
209
+ expect((await lint(paper(doc(loose), own))).found).toEqual([
210
+ "papers/p/paper.tex:3:2 \\section: capitalize «work» → «Work» — the last word of a heading is capitalized (chicago-headline)",
211
+ "papers/p/paper.tex:4:2 \\paragraph: capitalize the first word, «threats» → «Threats» (sentence; not fixed: a product name written in lowercase looks the same)",
212
+ ]);
213
+ });
214
+ });
215
+
216
+ describe("tex/heading-case on an AgenticDev paper — where a finding stands, and the directive", () => {
217
+ it("🔴 a heading over several lines: each word on its own line, and a directive below it still applies (LF and CRLF)", async () => {
218
+ for (const nl of ["\n", "\r\n"]) {
219
+ const body = [
220
+ "\\section{Related",
221
+ "work in the field}",
222
+ "% eslint-disable-next-line tex/heading-case -- the tool's own name is lowercase",
223
+ "\\section{Evaluating vigiles}",
224
+ ].join(nl);
225
+ const { found } = await lint(paper(doc(body), PRESET));
226
+ expect(found.map((f) => f.slice(0, f.indexOf(" ")))).toEqual([
227
+ "papers/p/paper.tex:4:2",
228
+ "papers/p/paper.tex:4:2",
229
+ ]);
230
+ expect(
231
+ found.map((f) => f.slice(f.indexOf("«"), f.indexOf("»") + 1)),
232
+ ).toEqual(["«work»", "«field»"]);
233
+ }
234
+ });
235
+
236
+ it("a disable directive with its reason silences one heading", async () => {
237
+ const body = [
238
+ "% eslint-disable-next-line tex/heading-case -- the tool's own name is lowercase",
239
+ "\\section{Evaluating vigiles}",
240
+ ].join("\n");
241
+ expect((await lint(paper(doc(body), PRESET))).found).toEqual([]);
242
+ });
243
+ });
244
+
245
+ describe("tex/heading-case on a paper whose preset does not turn it on", () => {
246
+ const SENTENCE_TITLED = titled(
247
+ "\\title{Measuring the wrong thing}",
248
+ SENTENCE_CASE,
249
+ );
250
+
251
+ it("🔴 is silent with no preset", async () => {
252
+ expect((await lint(paper(SENTENCE_TITLED, null))).found).toEqual([]);
253
+ });
254
+
255
+ it.each([
256
+ ["acm-sigconf", undefined],
257
+ ["aisec", "research"],
258
+ ["ieee-conference", undefined],
259
+ ["aidc", "short"],
260
+ ["realm", "short"],
261
+ ])(
262
+ "🔴 is silent on paperlint:%s: its venue's case rule was not read, or is a variant not built",
263
+ async (venue, kind) => {
264
+ const settings = {
265
+ extends: `paperlint:${venue}`,
266
+ ...(kind === undefined ? {} : { kind }),
267
+ };
268
+ expect((await lint(paper(SENTENCE_TITLED, settings))).found).toEqual([]);
269
+ },
270
+ );
271
+
272
+ it("a paper turns it on for itself", async () => {
273
+ const own = { rules: { "tex/heading-case": ["error", CHICAGO] } };
274
+ const { found } = await lint(paper(doc("\\section{Related work}"), own));
275
+ expect(found).toHaveLength(1);
276
+ });
277
+
278
+ it("🔴 turned on with no options it says so once, and does not pass as a check that ran", async () => {
279
+ const own = { rules: { "tex/heading-case": "error" } };
280
+ const { found } = await lint(paper(doc("\\section{related work}"), own));
281
+ expect(found).toEqual([
282
+ 'papers/p/paper.tex:1:2 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: "chicago-headline", "sentence", "any")',
283
+ ]);
284
+ });
285
+ });
286
+
287
+ const LOADING = "Error while loading rule 'tex/heading-case': ";
288
+ const DOCS_URL =
289
+ "https://github.com/zernie/paperlint/blob/main/docs/rules/tex/heading-case.md";
290
+ /** Where a refusal ends: the rule's page, and the file ESLint was linting when it loaded the rule. */
291
+ const REFUSED_AT = `${DOCS_URL}\nOccurred while linting <project>/papers/p/paper.tex`;
292
+
293
+ describe("tex/heading-case refuses a style value when ESLint loads it", () => {
294
+ it("🔴 the bare word «headline», naming the style it meant and why the word is gone", async () => {
295
+ expect(
296
+ await refusal(["error", { title: "headline", headings: "headline" }]),
297
+ ).toBe(
298
+ `${LOADING}"title" 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 — ${REFUSED_AT}`,
299
+ );
300
+ });
301
+
302
+ it("🔴 «off» as a style", async () => {
303
+ expect(await refusal(["error", { ...CHICAGO, headings: "off" }])).toBe(
304
+ `${LOADING}"headings" 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" — ${REFUSED_AT}`,
305
+ );
306
+ });
307
+
308
+ it("🔴 a misspelt style, naming the ones there are", async () => {
309
+ expect(
310
+ await refusal(["error", { ...CHICAGO, title: "chicago-headlin" }]),
311
+ ).toBe(
312
+ `${LOADING}"title" is "chicago-headlin", which is not a style: one of "chicago-headline", "sentence", "any" — ${REFUSED_AT}`,
313
+ );
314
+ });
315
+
316
+ it("🔴 a misspelt style at a level names the level", async () => {
317
+ expect(
318
+ await refusal([
319
+ "error",
320
+ { ...CHICAGO, levels: { paragraph: "Sentence" } },
321
+ ]),
322
+ ).toBe(
323
+ `${LOADING}"levels.paragraph" is "Sentence", which is not a style: one of "chicago-headline", "sentence", "any" — ${REFUSED_AT}`,
324
+ );
325
+ });
326
+ });
327
+
328
+ describe("tex/heading-case refuses, when ESLint loads it, a configuration that asks nothing or has the wrong shape", () => {
329
+ it("🔴 «any» for the title and for every heading: the rule turned off, said another way", async () => {
330
+ expect(await refusal(["error", { title: "any", headings: "any" }])).toBe(
331
+ `${LOADING}"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" — ${REFUSED_AT}`,
332
+ );
333
+ expect(
334
+ await refusal([
335
+ "error",
336
+ { title: "any", headings: "any", levels: { section: "any" } },
337
+ ]),
338
+ ).toBe(
339
+ `${LOADING}"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" — ${REFUSED_AT}`,
340
+ );
341
+ // Guards: a level that asks for more makes the same pair a real configuration.
342
+ expect(
343
+ await refusal([
344
+ "error",
345
+ { title: "any", headings: "any", levels: { section: "sentence" } },
346
+ ]),
347
+ ).toBe("no error");
348
+ });
349
+
350
+ it("🔴 the old `style` key, an unknown key, a missing scope and an unknown level, in ESLint's words", async () => {
351
+ expect(await refusal(["error", { style: "headline" }])).toBe(
352
+ 'Key "rules": Key "tex/heading-case":\n\tValue {"style":"headline"} should NOT have additional properties.\n\t\tUnexpected property "style". Expected properties: "title", "headings", "levels".\n',
353
+ );
354
+ expect(await refusal(["error", { ...CHICAGO, captions: "sentence" }])).toBe(
355
+ 'Key "rules": Key "tex/heading-case":\n\tValue {"title":"chicago-headline","headings":"chicago-headline","captions":"sentence"} should NOT have additional properties.\n\t\tUnexpected property "captions". Expected properties: "title", "headings", "levels".\n',
356
+ );
357
+ expect(await refusal(["error", { title: "chicago-headline" }])).toBe(
358
+ 'Key "rules": Key "tex/heading-case":\n\tValue {"title":"chicago-headline"} should have required property \'headings\'.\n',
359
+ );
360
+ expect(await refusal(["error", { headings: "chicago-headline" }])).toBe(
361
+ 'Key "rules": Key "tex/heading-case":\n\tValue {"headings":"chicago-headline"} should have required property \'title\'.\n',
362
+ );
363
+ expect(
364
+ await refusal(["error", { ...CHICAGO, levels: { chapter: "sentence" } }]),
365
+ ).toBe(
366
+ 'Key "rules": Key "tex/heading-case":\n\tValue {"chapter":"sentence"} should NOT have additional properties.\n\t\tUnexpected property "chapter". Expected properties: "section", "subsection", "subsubsection", "paragraph", "subparagraph".\n',
367
+ );
368
+ });
369
+ });
370
+
371
+ describe("tex/heading-case in the files a paper includes", () => {
372
+ const tree = paper(
373
+ doc("\\section{Introduction}\n\\input{sections/results}"),
374
+ PRESET,
375
+ { "papers/p/sections/results.tex": "\\section{Results and discussion}\n" },
376
+ );
377
+
378
+ it("reports a heading at its own file and line, and --fix edits that file", async () => {
379
+ const { found, dir } = await lint(tree, ["--fix"]);
380
+ expect(found).toEqual([]);
381
+ expect(
382
+ readFileSync(join(dir, "papers/p/sections/results.tex"), "utf8"),
383
+ ).toBe("\\section{Results and Discussion}\n");
384
+ });
385
+
386
+ it("reports without --fix", async () => {
387
+ const { found } = await lint(tree);
388
+ expect(found.map((f) => f.slice(0, f.indexOf(" ")))).toEqual([
389
+ "papers/p/sections/results.tex:1:2",
390
+ ]);
391
+ });
392
+
393
+ it("🔴 reads a \\title kept in a file the body includes, where it stands", async () => {
394
+ const withTitle = paper(
395
+ `${ACM}\n\\begin{document}\n\\input{front}\n\\maketitle\n\\end{document}\n`,
396
+ PRESET,
397
+ {
398
+ "papers/p/front.tex": "% the front matter\n\\title{Measuring things}\n",
399
+ },
400
+ );
401
+ const { found } = await lint(withTitle);
402
+ expect(found).toEqual([
403
+ "papers/p/front.tex:2:2 \\title: capitalize «things» → «Things» — the last word of a title is capitalized (chicago-headline)",
404
+ ]);
405
+ });
406
+ });
407
+
408
+ const rule = headingCaseRule({ latex: latexReader });
409
+
410
+ describe("the rule's own contract", () => {
411
+ const STYLE = { type: "string" };
412
+
413
+ it("takes a style for the title and for the headings, and optionally per level", () => {
414
+ expect(rule.meta.schema).toEqual([
415
+ {
416
+ type: "object",
417
+ properties: {
418
+ title: STYLE,
419
+ headings: STYLE,
420
+ levels: {
421
+ type: "object",
422
+ properties: {
423
+ section: STYLE,
424
+ subsection: STYLE,
425
+ subsubsection: STYLE,
426
+ paragraph: STYLE,
427
+ subparagraph: STYLE,
428
+ },
429
+ additionalProperties: false,
430
+ },
431
+ },
432
+ required: ["title", "headings"],
433
+ additionalProperties: false,
434
+ },
435
+ ]);
436
+ expect(rule.meta.fixable).toBe("code");
437
+ });
438
+ });
439
+
440
+ describe("the rule's create, called without ESLint", () => {
441
+ /** What `create` does with `options` on `raw`: the message ids it reported, or what it threw. */
442
+ function created(
443
+ options: readonly unknown[],
444
+ raw = "\\section{related}",
445
+ ): string[] {
446
+ const reported: string[] = [];
447
+ try {
448
+ rule
449
+ .create({
450
+ options,
451
+ sourceCode: {
452
+ text: raw,
453
+ getLocFromIndex: (i: number) => ({ line: 1, column: i }),
454
+ },
455
+ report: (d) => reported.push(d.messageId),
456
+ })
457
+ .root?.();
458
+ } catch (e) {
459
+ return [e instanceof Error ? e.message : String(e)];
460
+ }
461
+ return reported;
462
+ }
463
+
464
+ it("reads the file's text when the language gives no raw source", () => {
465
+ expect(created([CHICAGO])).toEqual(["capitalize"]);
466
+ });
467
+
468
+ it("refuses what ESLint's schema would have stopped, when called without it", () => {
469
+ expect(created(["headline"])).toEqual([
470
+ `the options are "headline", not an object such as {"title": "chicago-headline", "headings": "chicago-headline"} — ${DOCS_URL}`,
471
+ ]);
472
+ expect(created([{ headings: "sentence" }])).toEqual([
473
+ `"title" is missing, which is not a style: one of "chicago-headline", "sentence", "any" — ${DOCS_URL}`,
474
+ ]);
475
+ expect(created([{ ...CHICAGO, levels: "sentence" }])).toEqual([
476
+ `"levels" is "sentence", not an object such as {"paragraph": "sentence"} — ${DOCS_URL}`,
477
+ ]);
478
+ expect(created([{ ...CHICAGO, levels: { section: 3 } }])).toEqual([
479
+ `"levels.section" is 3, which is not a style: one of "chicago-headline", "sentence", "any" — ${DOCS_URL}`,
480
+ ]);
481
+ });
482
+
483
+ it("a word split by markup is reported, and without a fix", () => {
484
+ const reported: { readonly messageId: string; readonly fix?: unknown }[] =
485
+ [];
486
+ const sourceCode = {
487
+ text: "",
488
+ getLocFromIndex: (i: number) => ({ line: 1, column: i }),
489
+ };
490
+ rule
491
+ .create({
492
+ options: [CHICAGO],
493
+ sourceCode: { ...sourceCode, raw: "\\section{a\\emph{b}c}" },
494
+ report: (d) => reported.push(d),
495
+ })
496
+ .root?.();
497
+ expect(reported.map((d) => d.messageId)).toEqual(["capitalize"]);
498
+ expect(reported[0]?.fix).toBeUndefined();
499
+ });
500
+ });