paperlint 6.3.0 → 7.1.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 (185) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +20 -13
  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 +19 -5
  26. package/dist/cli.js.map +1 -1
  27. package/dist/domain/body-pages.d.ts +29 -0
  28. package/dist/domain/body-pages.d.ts.map +1 -1
  29. package/dist/domain/body-pages.js +25 -0
  30. package/dist/domain/body-pages.js.map +1 -1
  31. package/dist/domain/heading-case.d.ts +100 -0
  32. package/dist/domain/heading-case.d.ts.map +1 -0
  33. package/dist/domain/heading-case.js +365 -0
  34. package/dist/domain/heading-case.js.map +1 -0
  35. package/dist/domain/tex-document.d.ts +32 -0
  36. package/dist/domain/tex-document.d.ts.map +1 -1
  37. package/dist/domain/tex-document.js +11 -0
  38. package/dist/domain/tex-document.js.map +1 -1
  39. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  40. package/dist/eslint-rules/latex-language.js +16 -3
  41. package/dist/eslint-rules/latex-language.js.map +1 -1
  42. package/dist/facts-file.d.ts +3 -3
  43. package/dist/facts-file.d.ts.map +1 -1
  44. package/dist/facts-file.js +3 -3
  45. package/dist/facts-file.js.map +1 -1
  46. package/dist/folder-venue-rule.js +6 -6
  47. package/dist/folder-venue-rule.js.map +1 -1
  48. package/dist/heading-case.d.ts +48 -0
  49. package/dist/heading-case.d.ts.map +1 -0
  50. package/dist/heading-case.js +191 -0
  51. package/dist/heading-case.js.map +1 -0
  52. package/dist/paper-includes.d.ts +3 -0
  53. package/dist/paper-includes.d.ts.map +1 -1
  54. package/dist/paper-includes.js +17 -2
  55. package/dist/paper-includes.js.map +1 -1
  56. package/dist/ports/latex.d.ts +7 -1
  57. package/dist/ports/latex.d.ts.map +1 -1
  58. package/dist/preset-card-rule.d.ts +100 -0
  59. package/dist/preset-card-rule.d.ts.map +1 -0
  60. package/dist/preset-card-rule.js +221 -0
  61. package/dist/preset-card-rule.js.map +1 -0
  62. package/dist/presets.d.ts +29 -5
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +104 -68
  65. package/dist/presets.js.map +1 -1
  66. package/dist/submission.d.ts +8 -0
  67. package/dist/submission.d.ts.map +1 -1
  68. package/dist/submission.js +19 -7
  69. package/dist/submission.js.map +1 -1
  70. package/dist/tex-requirements.d.ts +42 -3
  71. package/dist/tex-requirements.d.ts.map +1 -1
  72. package/dist/tex-requirements.js +34 -5
  73. package/dist/tex-requirements.js.map +1 -1
  74. package/dist/tex-venue-rules.d.ts +6 -3
  75. package/dist/tex-venue-rules.d.ts.map +1 -1
  76. package/dist/tex-venue-rules.js +29 -10
  77. package/dist/tex-venue-rules.js.map +1 -1
  78. package/dist/venue-rules.d.ts +2 -1
  79. package/dist/venue-rules.d.ts.map +1 -1
  80. package/dist/venue-rules.js +55 -7
  81. package/dist/venue-rules.js.map +1 -1
  82. package/docs/configuration.md +13 -1
  83. package/docs/e2e.md +14 -12
  84. package/docs/optional-rules.md +4 -3
  85. package/docs/rules/format/page-limit.md +19 -2
  86. package/docs/rules/paper/folder-venue-leftover.md +1 -1
  87. package/docs/rules/talk/profile.md +1 -1
  88. package/docs/rules/tex/heading-case.md +424 -0
  89. package/docs/rules/tex/template.md +25 -5
  90. package/docs/rules/tex/venue-leftover.md +13 -0
  91. package/docs/rules.md +74 -57
  92. package/docs/submission.md +5 -1
  93. package/eslint-rules/latex-language.test.mjs +12 -0
  94. package/eslint-rules/latex-language.ts +17 -4
  95. package/eslint-rules/paper-typography.test.mjs +12 -0
  96. package/eslint.config.mjs +59 -1
  97. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  98. package/fixtures/build-e2e/fallback/expect.json +3 -3
  99. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  100. package/fixtures/build-e2e/msr/expect.json +18 -0
  101. package/fixtures/build-e2e/msr/paper.tex +49 -0
  102. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  103. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  104. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  105. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  106. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  107. package/lib/skill-checks.mjs +31 -0
  108. package/package.json +7 -1
  109. package/presets/acm-sigconf.jsonc +5 -0
  110. package/presets/acm-sigconf.md +43 -0
  111. package/presets/agenticdev.jsonc +27 -6
  112. package/presets/agenticdev.md +151 -0
  113. package/presets/aidc.jsonc +6 -0
  114. package/presets/aidc.md +75 -0
  115. package/presets/aisec.jsonc +5 -1
  116. package/presets/aisec.md +117 -0
  117. package/presets/ieee-conference.jsonc +5 -0
  118. package/presets/ieee-conference.md +38 -0
  119. package/presets/msr.jsonc +71 -0
  120. package/presets/msr.md +94 -0
  121. package/presets/realm.jsonc +7 -2
  122. package/presets/realm.md +166 -0
  123. package/presets/tex/agenticdev.tex +1 -1
  124. package/presets/tex-base.jsonc +6 -0
  125. package/presets/tex-base.md +30 -0
  126. package/presets/venue-profile.schema.json +65 -1
  127. package/skills/camera-ready/SKILL.md +8 -1
  128. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  129. package/skills/find-venue/SKILL.md +6 -5
  130. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  131. package/skills/paper-pipeline/SKILL.md +5 -6
  132. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  133. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  134. package/skills/study-accepted-papers/SKILL.md +3 -3
  135. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  136. package/skills/submission-portal/SKILL.md +2 -2
  137. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  138. package/skills/submit-paper/SKILL.md +34 -22
  139. package/skills/submit-paper/SKILL.md.spec.ts +33 -21
  140. package/skills/submit-paper/references/publishers/acm.md +1 -1
  141. package/src/adapters/latex/conditionals.ts +130 -0
  142. package/src/adapters/latex/headings.ts +163 -0
  143. package/src/adapters/latex/index.test.ts +65 -0
  144. package/src/adapters/latex/index.ts +3 -0
  145. package/src/adapters/latex/parse.ts +3 -2
  146. package/src/build.ts +2 -2
  147. package/src/cli.test.ts +50 -7
  148. package/src/cli.ts +28 -6
  149. package/src/domain/body-pages.test.ts +144 -0
  150. package/src/domain/body-pages.ts +61 -1
  151. package/src/domain/heading-case.test.ts +660 -0
  152. package/src/domain/heading-case.ts +538 -0
  153. package/src/domain/tex-document.ts +36 -0
  154. package/src/domain/venue-name.test.ts +13 -7
  155. package/src/facts-file.test.ts +4 -4
  156. package/src/facts-file.ts +6 -6
  157. package/src/folder-venue-rule.test.ts +10 -10
  158. package/src/folder-venue-rule.ts +6 -6
  159. package/src/heading-case.test.ts +500 -0
  160. package/src/heading-case.ts +275 -0
  161. package/src/init.test.ts +1 -1
  162. package/src/new-paper.test.ts +37 -6
  163. package/src/paper-includes.test.ts +26 -0
  164. package/src/paper-includes.ts +20 -2
  165. package/src/paper-settings-commands.test.ts +26 -11
  166. package/src/ports/latex.ts +7 -0
  167. package/src/preset-card-rule.test.ts +267 -0
  168. package/src/preset-card-rule.ts +384 -0
  169. package/src/presets.test.ts +259 -41
  170. package/src/presets.ts +161 -75
  171. package/src/rule-cases.test.ts +136 -50
  172. package/src/submission.test.ts +44 -4
  173. package/src/submission.ts +28 -7
  174. package/src/talk-rules.test.ts +6 -3
  175. package/src/tex-requirements.harness.ts +25 -11
  176. package/src/tex-requirements.test.ts +127 -1
  177. package/src/tex-requirements.ts +91 -12
  178. package/src/tex-venue-rules.test.ts +196 -21
  179. package/src/tex-venue-rules.ts +47 -16
  180. package/src/venue-rules.test.ts +180 -8
  181. package/src/venue-rules.ts +71 -10
  182. package/templates/paper/paper.tex +1 -1
  183. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  184. package/skills/submit-paper/references/venues/aisec.md +0 -105
  185. package/skills/submit-paper/references/venues/realm.md +0 -155
package/src/facts-file.ts CHANGED
@@ -68,10 +68,10 @@ export const FACTS_FILE = "paper.facts.json";
68
68
  export const factsPath = (paperDir: string): string =>
69
69
  join(paperDir, FACTS_DIR, FACTS_FILE);
70
70
 
71
- /** What the facts need from a paper's `paperlint.json`: its venue's label, its kind, where its PDF is. */
71
+ /** What the facts need from a paper's `paperlint.json`: its venue's preset id, its kind, where its PDF is. */
72
72
  export interface VenueDecl {
73
- /** The venue preset's display label (`agenticdev`), or null when the paper extends none. */
74
- readonly label: string | null;
73
+ /** The venue preset's id (`agenticdev`, never its display name), or null when the paper extends none. */
74
+ readonly id: string | null;
75
75
  readonly kind: string | null;
76
76
  readonly pdf: string | null;
77
77
  }
@@ -91,12 +91,12 @@ export function declaredVenue(
91
91
  if (problem !== null) throw new Error(problem);
92
92
  if (p.kind === "resolved")
93
93
  return {
94
- label: p.preset.label,
94
+ id: p.preset.id,
95
95
  kind: p.settings.kind,
96
96
  pdf: p.settings.pdf,
97
97
  };
98
98
  return p.kind === "none" && p.settings
99
- ? { label: null, kind: p.settings.kind, pdf: p.settings.pdf }
99
+ ? { id: null, kind: p.settings.kind, pdf: p.settings.pdf }
100
100
  : null;
101
101
  }
102
102
 
@@ -237,7 +237,7 @@ export async function measurePaper(
237
237
  const facts = factsDocument({
238
238
  pdf: posix(relative(paperDir, pdf)),
239
239
  sha: sha256Hex(bytes),
240
- venue: o.venue ?? decl?.label ?? null,
240
+ venue: o.venue ?? decl?.id ?? null,
241
241
  kind: o.kind ?? decl?.kind ?? null,
242
242
  read: r.facts,
243
243
  geometry,
@@ -13,6 +13,7 @@ import {
13
13
  FOLDER_VENUE_RULE_LEVELS,
14
14
  folderVenueRules,
15
15
  } from "./folder-venue-rule.ts";
16
+ import { venuePreset } from "../test/support.ts";
16
17
 
17
18
  const VENUES = presetsDir();
18
19
  const PAPERS = "/work/papers";
@@ -77,7 +78,7 @@ describe("paper/folder-venue-leftover — the folder names another venue", () =>
77
78
  messageId: "leftover",
78
79
  line: 1,
79
80
  message:
80
- "the folder name «aisec-2026» names the venue aisec, and this paper extends aidc — the name went stale when the venue changed, and venues change on every resubmission. Name the folder after the work (what the paper shows), not the venue",
81
+ "the folder name «aisec-2026» names the venue AISec, and this paper extends AIDC — the name went stale when the venue changed, and venues change on every resubmission. Name the folder after the work (what the paper shows), not the venue",
81
82
  },
82
83
  ]);
83
84
  });
@@ -87,7 +88,7 @@ describe("paper/folder-venue-leftover — the folder names another venue", () =>
87
88
  lint("acm-ccs-agents", AIDC).map(
88
89
  (r) => /names the venue (.*?), and/u.exec(r.message)?.[1],
89
90
  ),
90
- ).toEqual(["aisec («ACM CCS»)"]);
91
+ ).toEqual(["AISec («ACM CCS»)"]);
91
92
  });
92
93
 
93
94
  it("two other venues are two findings", () => {
@@ -95,15 +96,14 @@ describe("paper/folder-venue-leftover — the folder names another venue", () =>
95
96
  lint("realm-then-aisec", AIDC).map(
96
97
  (r) => /names the venue (\w+)/u.exec(r.message)?.[1],
97
98
  ),
98
- ).toEqual(["aisec", "realm"]);
99
+ ).toEqual(["AISec", "REALM"]);
99
100
  });
100
101
 
101
102
  it("a project's own preset is judged like a shipped one: its label and aliases are its own", () => {
102
103
  const extra = {
103
- [`${PAPERS}/aidc-redux/venue.jsonc`]: JSON.stringify({
104
- extends: "paperlint:aisec",
105
- name: "AISec 2027",
106
- }),
104
+ [`${PAPERS}/aidc-redux/venue.jsonc`]: JSON.stringify(
105
+ venuePreset("AISec 2027", { extends: "paperlint:aisec" }),
106
+ ),
107
107
  };
108
108
  // Its chain is AISec's, so aisec is its own; aidc is another venue.
109
109
  expect(
@@ -119,9 +119,9 @@ describe("paper/folder-venue-leftover — the folder names another venue", () =>
119
119
  { extends: "./venue.jsonc", kind: "short" },
120
120
  {
121
121
  extra: {
122
- [`${PAPERS}/aisec-redux/venue.jsonc`]: JSON.stringify({
123
- extends: "paperlint:aisec",
124
- }),
122
+ [`${PAPERS}/aisec-redux/venue.jsonc`]: JSON.stringify(
123
+ venuePreset("venue", { extends: "paperlint:aisec" }),
124
+ ),
125
125
  },
126
126
  },
127
127
  ),
@@ -83,13 +83,13 @@ export function judgeFolder(
83
83
  }
84
84
 
85
85
  /**
86
- * A template family (`acm-sigconf`, `ieee-conference`): a preset with no kinds, which a paper for a
87
- * venue paperlint has no preset for extends directly (`src/venue-rules.ts` reads it the same way).
88
- * Such a paper declares a format, not a venue, so a venue in its folder name may be its own: the
89
- * accepted ACSAC papers in `fixtures/accepted-papers/` extend `ieee-conference` from folders named
90
- * `…-acsac24`, and ACSAC is the parent conference `aidc` names as an alias.
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
91
  */
92
- const isFamily = (preset: Preset): boolean => preset.format.kinds.size === 0;
92
+ const isFamily = (preset: Preset): boolean => preset.identity.type === "family";
93
93
 
94
94
  /** The findings for the paper folder `dir`; none when it has no resolved venue, or only a family. */
95
95
  function findingsFor(dir: string, deps: VenueRuleDeps): readonly Finding[] {
@@ -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
+ });