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
@@ -9,7 +9,7 @@ import { experimental_skill } from "vigiles/spec";
9
9
  export default experimental_skill({
10
10
  name: "submit-paper",
11
11
  description:
12
- 'End-to-end playbook for submitting a peer-reviewed paper to a double-blind venue (HotCRP workshops/conferences) — from a hardened draft to "ready for review." Covers building and anonymously hosting a reproduction artifact, HotCRP profile + form mechanics, double-blind hygiene, and the authorship/credential angles for an independent researcher. Use when the paper is drafted and reviewed and it\'s time to actually submit. Compose with pc-panel-review / paper-adversarial-review (harden first), and with the venue data card (references/venues/<venue>.md) for venue specifics.',
12
+ 'End-to-end playbook for submitting a peer-reviewed paper to a double-blind venue (HotCRP workshops/conferences) — from a hardened draft to "ready for review." Covers building and anonymously hosting a reproduction artifact, HotCRP profile + form mechanics, double-blind hygiene, and the authorship/credential angles for an independent researcher. Use when the paper is drafted and reviewed and it\'s time to actually submit. Compose with pc-panel-review / paper-adversarial-review (harden first), and with the venue card (presets/<venue>.md in the paperlint package) for venue specifics.',
13
13
  tools: [
14
14
  "Read",
15
15
  "Write",
@@ -28,26 +28,30 @@ Assumes the paper is already hardened (run \`pc-panel-review\` — incl. its ven
28
28
  \`paper-adversarial-review\` first). This skill is the submission mechanics — the part that bit us with
29
29
  avoidable friction the first time. Distilled from the AgenticDev 2026 submission.
30
30
 
31
- ## Venue specifics — a DATA card, not a skill
31
+ ## Venue specifics — a CARD beside the venue's preset, not a skill
32
32
  The venue-specific facts (deadline, page limits, tracks, PC-member conflict list, blind model, format
33
- quirks, whether a supplementary-upload field exists, the extension target) live as **plain reference
34
- data files** under \`references/venues/<venue>.md\`:
35
- - \`references/venues/agenticdev.md\` — AgenticDev @ ASE (workshop; ACM DL/IEEE Xplore).
36
- - \`references/venues/aisec.md\` — AISec @ ACM CCS (security workshop; ACM DL; harder bar).
37
-
38
- **The pattern (follow it for every new venue):** when you fetch a venue's exact submission instructions,
39
- save them as a new **data file** \`references/venues/<venue>.md\` (short \`title:\` frontmatter only — NOT a
40
- skill with \`name:\`/\`description:\`). This preserves the "save the exact venue instructions" rule while
41
- keeping the skill namespace from proliferating one card per venue. The *mechanics* stay here; the venue
42
- file is only the facts.
43
-
44
- **The card is prose; the machine-checked format is a PRESET beside it.** A paper declares its venue
33
+ quirks, whether a supplementary-upload field exists) live in the venue's **card**, \`presets/<venue>.md\`
34
+ in the paperlint package, beside the preset \`presets/<venue>.jsonc\` that the lint rules check a
35
+ paper against:
36
+ - \`presets/agenticdev.md\` — AgenticDev @ ASE (workshop; ASE workshop proceedings).
37
+ - \`presets/aisec.md\` — AISec @ ACM CCS (security workshop; ACM DL; harder bar).
38
+ - \`presets/aidc.md\` — AIDC @ IEEE ACSAC (workshop; IEEE template, LLM Usage Statement).
39
+ - \`presets/realm.md\` — REALM @ EMNLP (ACL family, OpenReview).
40
+
41
+ **The card is prose; the machine-checked format is the PRESET beside it.** A paper declares its venue
45
42
  in \`<paper>/paperlint.json\`: \`{ "extends": "paperlint:<venue>", "kind": "<kind>" }\`, and the
46
43
  \`pdf/*\` lint rules judge the built PDF against that preset (page limit per kind, fonts, page size,
47
- columns, font sizes). A venue with no shipped preset extends a family — \`paperlint:acm-sigconf\`
48
- for an ACM venue — or a preset of the project's own, \`./venues/<name>.jsonc\` (paperlint's
49
- \`docs/rules.md\`, "Writing your own venue preset"). Put the page limits you fetched from the CFP in
50
- the preset's \`format.kinds\`, each with its quote.
44
+ columns, font sizes). The card's last section — what the preset is, its call for papers, the rules it
45
+ sets and where each comes from — is generated from the preset, so read the numbers there, not from
46
+ memory.
47
+
48
+ **The pattern (follow it for every new venue):** when you fetch a venue's exact submission
49
+ instructions, write the numbers into a preset with their quotes and the rest into a card beside it.
50
+ A venue with no shipped preset extends a family — \`paperlint:acm-sigconf\` for an ACM venue — in a
51
+ preset of the project's own, \`./venues/<name>.jsonc\`, with its card \`./venues/<name>.md\` next to
52
+ it (paperlint's \`docs/rules.md\`, "Writing your own venue preset"). A venue preset declares
53
+ \`"type": "venue"\`, the venue's \`name\` and the \`url\` of its call for papers. The *mechanics*
54
+ stay here; the card is only the facts.
51
55
 
52
56
  ## Publisher specifics — one level ABOVE the venue
53
57
  Camera-ready mechanics belong to the **publisher**, not the venue: ACM eRights, the submit-vs-final
@@ -164,9 +168,9 @@ convention with no reader.
164
168
  ## Compose with
165
169
  - Harden first: \`pc-panel-review\` (incl. its venue-fit mode), \`paper-adversarial-review\`, \`render-paper\`.
166
170
  - Publisher mechanics (camera-ready): \`references/publishers/<publisher>.md\`.
167
- - Venue specifics: the matching data card \`references/venues/<venue>.md\` (e.g.
168
- \`references/venues/agenticdev.md\`) for deadlines, page limits, PC-member conflict lists, whether
169
- supplementary upload exists, and the extension target.
171
+ - Venue specifics: the venue's card \`presets/<venue>.md\` in the paperlint package (e.g.
172
+ \`presets/agenticdev.md\`) for deadlines, page limits, PC-member conflict lists, and whether a
173
+ supplementary upload exists.
170
174
 
171
175
  ## Provenance
172
176
  Written from the AgenticDev 2026 @ ASE submission (2026-07-13): PC-panel-reviewed, self-checking
@@ -8,7 +8,7 @@ tags: [publisher, acm, camera-ready, erights, ccs]
8
8
  # ACM: what camera-ready requires
9
9
 
10
10
  > This is the **publisher** level, not the venue level. Identical for AgenticDev@ASE, AISec@CCS,
11
- > and any other ACM conference. The venue card (`../venues/<venue>.md`) holds only what's its own:
11
+ > and any other ACM conference. The venue card (`presets/<venue>.md` in the paperlint package) holds only what's its own:
12
12
  > dates, page limit, its own DOI/ISBN, blind model.
13
13
  >
14
14
  > Captured 2026-08-24 from the live AgenticDev #20 HotCRP form — field by field, not from memory.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `\iffalse … \fi`: text TeX never reads. Authors park old titles, sections and paragraphs there,
3
+ * and every reader of the tree — the outline, the rendered text, the headings the case judge reads,
4
+ * the includes — would otherwise take them for the paper. So the branch is cut out of the tree once,
5
+ * when the source is parsed (`parseLatex`), rather than pruned per walk like `hidden.ts` prunes: a
6
+ * walk can skip a node, but the rendered text reads the strings of a node list directly, and they
7
+ * would come back.
8
+ *
9
+ * What is cut: from `\iffalse` to its matching `\fi`, both included, counting the conditionals
10
+ * nested in between. An `\else` at the same depth ends the dead part: what follows it is read, and
11
+ * the matching `\fi` is cut. A conditional is a macro whose name starts with `if`, except
12
+ * `\ifthenelse` (the `ifthen` package's command, which takes arguments and has no `\fi`). An
13
+ * `\iffalse` with no `\fi` cuts to the end of its list, as TeX would read to the end of the file.
14
+ *
15
+ * Only `\iffalse` is decided. `\iftrue`, `\if\venue1` and every switch made with `\newif` depend on
16
+ * a value this reader does not compute, so both of their branches stay.
17
+ */
18
+ import type * as Ast from "@unified-latex/unified-latex-types";
19
+
20
+ type Node = Readonly<Ast.Node>;
21
+
22
+ /** Where the scan of one node list stands: reading, inside a dead branch, or in its `\else`. */
23
+ type Mode =
24
+ | { readonly kind: "live" }
25
+ | { readonly kind: "dead"; readonly depth: number }
26
+ | { readonly kind: "else"; readonly depth: number };
27
+
28
+ /** One node against the mode before it: the mode after it, and whether the node is kept. */
29
+ interface Step {
30
+ readonly mode: Mode;
31
+ readonly keep: boolean;
32
+ }
33
+
34
+ const LIVE: Mode = { kind: "live" };
35
+
36
+ const isMacro = (n: Node, name: string): boolean =>
37
+ n.type === "macro" && n.content === name;
38
+
39
+ /** A macro that opens a conditional TeX closes with `\fi`. */
40
+ const opensConditional = (n: Node): boolean =>
41
+ n.type === "macro" &&
42
+ n.content.startsWith("if") &&
43
+ n.content !== "ifthenelse";
44
+
45
+ /** Inside the dead branch: everything is cut; nesting is counted, `\else` at depth 1 ends it. */
46
+ function stepDead(depth: number, n: Node): Step {
47
+ if (opensConditional(n))
48
+ return { mode: { kind: "dead", depth: depth + 1 }, keep: false };
49
+ if (isMacro(n, "fi"))
50
+ return {
51
+ mode: depth === 1 ? LIVE : { kind: "dead", depth: depth - 1 },
52
+ keep: false,
53
+ };
54
+ if (isMacro(n, "else") && depth === 1)
55
+ return { mode: { kind: "else", depth }, keep: false };
56
+ return { mode: { kind: "dead", depth }, keep: false };
57
+ }
58
+
59
+ /** In the `\else` branch: everything is kept but the `\fi` that closes the `\iffalse`. */
60
+ function stepElse(depth: number, n: Node): Step {
61
+ if (opensConditional(n))
62
+ return { mode: { kind: "else", depth: depth + 1 }, keep: true };
63
+ if (isMacro(n, "fi"))
64
+ return depth === 1
65
+ ? { mode: LIVE, keep: false }
66
+ : { mode: { kind: "else", depth: depth - 1 }, keep: true };
67
+ return { mode: { kind: "else", depth }, keep: true };
68
+ }
69
+
70
+ function step(mode: Mode, n: Node): Step {
71
+ switch (mode.kind) {
72
+ case "live":
73
+ return isMacro(n, "iffalse")
74
+ ? { mode: { kind: "dead", depth: 1 }, keep: false }
75
+ : { mode, keep: true };
76
+ case "dead":
77
+ return stepDead(mode.depth, n);
78
+ case "else":
79
+ return stepElse(mode.depth, n);
80
+ }
81
+ }
82
+
83
+ /** A node list without its `\iffalse` branches, each kept node cleaned the same way. */
84
+ // eslint-disable-next-line functional/prefer-immutable-types -- the tree's own content type is a mutable array
85
+ function liveList(nodes: readonly Node[]): Ast.Node[] {
86
+ const { kept } = nodes.reduce<{
87
+ readonly mode: Mode;
88
+ readonly kept: readonly Ast.Node[];
89
+ }>(
90
+ (acc, n) => {
91
+ const next = step(acc.mode, n);
92
+ return {
93
+ mode: next.mode,
94
+ kept: next.keep ? [...acc.kept, liveNode(n)] : acc.kept,
95
+ };
96
+ },
97
+ { mode: LIVE, kept: [] },
98
+ );
99
+ return [...kept];
100
+ }
101
+
102
+ // eslint-disable-next-line functional/prefer-immutable-types -- the tree's own argument type is mutable
103
+ const liveArgs = (args: readonly Readonly<Ast.Argument>[]): Ast.Argument[] =>
104
+ args.map((a) => ({ ...a, content: liveList(a.content) }));
105
+
106
+ /** A node with its content and its arguments' content cleaned; a node with neither, as it is. */
107
+ // eslint-disable-next-line functional/prefer-immutable-types -- the tree's own node type is mutable
108
+ function liveNode(n: Node): Ast.Node {
109
+ if (n.type === "macro")
110
+ return n.args === undefined ? n : { ...n, args: liveArgs(n.args) };
111
+ if (n.type === "environment" || n.type === "mathenv")
112
+ return {
113
+ ...n,
114
+ content: liveList(n.content),
115
+ ...(n.args === undefined ? {} : { args: liveArgs(n.args) }),
116
+ };
117
+ if (n.type === "group" || n.type === "inlinemath" || n.type === "displaymath")
118
+ return { ...n, content: liveList(n.content) };
119
+ return n;
120
+ }
121
+
122
+ /** The tree without what `\iffalse … \fi` hides; the same tree when the source has no `\iffalse`. */
123
+ export const withoutFalseBranches = (
124
+ src: string,
125
+ root: Readonly<Ast.Root>,
126
+ // eslint-disable-next-line functional/prefer-immutable-types -- `ParsedTex.root` is the tree's own mutable type
127
+ ): Ast.Root =>
128
+ src.includes("\\iffalse")
129
+ ? { ...root, content: liveList(root.content) }
130
+ : root;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The paper's `\title` and the titles of its headings as the case judge reads them: each as one run
3
+ * of text, every character with the source offset it came from.
4
+ *
5
+ * What a title says is its strings and spaces, and the words a formatting command wraps
6
+ * (`\emph{very}` says "very"). A label, a footnote or an index entry says nothing in the title and
7
+ * is left out. Anything else — math, `\texttt`, `\cite`, `\textsc`, an accent, an environment — is
8
+ * something the reader sees and the judge does not read, so it stands as `OPAQUE`, one character,
9
+ * where it is: the judge then knows a word is first or last only when nothing stands before or
10
+ * after it, and never reads a word that touches one.
11
+ */
12
+ import { hasAtLeast } from "remeda";
13
+ import type {
14
+ HeadingLevel,
15
+ Segment,
16
+ TitleArgument,
17
+ TitledCommand,
18
+ TitledHeading,
19
+ } from "../../domain/tex-document.ts";
20
+ import { LINE_BREAK, OPAQUE } from "../../domain/tex-document.ts";
21
+ import { isDefinition, isUnrendered } from "./hidden.ts";
22
+ import {
23
+ inPlace,
24
+ isNode,
25
+ mandatory,
26
+ optional,
27
+ placeOf,
28
+ stringText,
29
+ visited,
30
+ type Macro,
31
+ type Node,
32
+ } from "./nodes.ts";
33
+ import type { ParsedTex } from "./parse.ts";
34
+
35
+ const LEVELS: ReadonlyMap<string, HeadingLevel> = new Map<string, HeadingLevel>(
36
+ [
37
+ ["section", "section"],
38
+ ["subsection", "subsection"],
39
+ ["subsubsection", "subsubsection"],
40
+ ["paragraph", "paragraph"],
41
+ ["subparagraph", "subparagraph"],
42
+ ],
43
+ );
44
+
45
+ /** Formatting commands: they say what their argument says. */
46
+ const TRANSPARENT: ReadonlySet<string> = new Set([
47
+ "emph",
48
+ "textit",
49
+ "textbf",
50
+ "textsl",
51
+ "textup",
52
+ "textmd",
53
+ "textnormal",
54
+ "textsf",
55
+ "textrm",
56
+ ]);
57
+
58
+ /** Commands that break the line: the title goes on as a second part, a subtitle. */
59
+ const LINE_BREAKS: ReadonlySet<string> = new Set([
60
+ "\\\\",
61
+ "newline",
62
+ "linebreak",
63
+ ]);
64
+
65
+ /** Commands that put nothing in the title's words: a label, a footnote, an index entry. */
66
+ const INVISIBLE: ReadonlySet<string> = new Set([
67
+ "label",
68
+ "footnote",
69
+ "thanks",
70
+ "index",
71
+ ]);
72
+
73
+ /**
74
+ * The command a macro is, for the case judge: the paper's `\title`, a heading at its level, or none.
75
+ * `\title` is read wherever it stands — in the preamble, where acmart and IEEEtran put it, or in a
76
+ * file the paper includes.
77
+ */
78
+ const commandOf = (m: Macro): readonly TitledCommand[] => {
79
+ if (m.content === "title") return [{ kind: "title" }];
80
+ const level = LEVELS.get(m.content);
81
+ return level === undefined ? [] : [{ kind: "heading", level }];
82
+ };
83
+
84
+ /** A macro that is neither left out of the title nor read as the words it wraps. */
85
+ const isOpaqueMacro = (m: Macro): boolean =>
86
+ !INVISIBLE.has(m.content) &&
87
+ !TRANSPARENT.has(m.content) &&
88
+ !LINE_BREAKS.has(m.content);
89
+
90
+ /** What one macro says in a title. */
91
+ function macroSegments(m: Macro): readonly Segment[] {
92
+ if (INVISIBLE.has(m.content)) return [];
93
+ if (LINE_BREAKS.has(m.content)) return textAt(m, LINE_BREAK);
94
+ if (TRANSPARENT.has(m.content))
95
+ return segmentsOf(mandatory(m).at(-1)?.content);
96
+ return textAt(m, OPAQUE);
97
+ }
98
+
99
+ /** A piece of text at the start of a node. */
100
+ const textAt = (n: Node, text: string): readonly Segment[] =>
101
+ inPlace(placeOf(n), (s) => [{ text, at: s.start }]);
102
+
103
+ /** What one node says in a title: text as itself, a formatting command's words, anything else opaque. */
104
+ function nodeSegments(n: Node): readonly Segment[] {
105
+ if (n.type === "string") return textAt(n, stringText(n.content));
106
+ if (n.type === "whitespace" || n.type === "parbreak") return textAt(n, " ");
107
+ // A comment ends its line: the words on either side of it are two words.
108
+ if (n.type === "comment") return textAt(n, " ");
109
+ if (n.type === "group") return segmentsOf(n.content);
110
+ if (n.type === "macro") return macroSegments(n);
111
+ return textAt(n, OPAQUE);
112
+ }
113
+
114
+ /**
115
+ * A group that is the very next node after a command the parser has no signature for (`\ours{…}`, a macro of the
116
+ * author's own): the parser leaves such a group beside the macro, but TeX hands it to the macro as an
117
+ * argument, so it is part of what the macro prints and not words of the title. A chain of groups
118
+ * (`\findingdes{label}{a sentence}`) is all argument.
119
+ */
120
+ function isArgumentGroup(nodes: readonly Node[], n: Node, i: number): boolean {
121
+ const prev = nodes[i - 1];
122
+ if (prev === undefined || n.type !== "group") return false;
123
+ return prev.type === "macro"
124
+ ? isOpaqueMacro(prev)
125
+ : isArgumentGroup(nodes, prev, i - 1);
126
+ }
127
+
128
+ const segmentsOf = (nodes: readonly Node[] | undefined): readonly Segment[] => {
129
+ const list = nodes ?? [];
130
+ return list.flatMap((n, i) =>
131
+ isArgumentGroup(list, n, i) ? [] : nodeSegments(n),
132
+ );
133
+ };
134
+
135
+ /** One argument's contents → the heading it makes, or none when it says nothing. */
136
+ function titled(
137
+ command: TitledCommand,
138
+ argument: TitleArgument,
139
+ nodes: readonly Node[] | undefined,
140
+ ): readonly TitledHeading[] {
141
+ const segments = segmentsOf(nodes);
142
+ return hasAtLeast(segments, 1)
143
+ ? [{ command, argument, title: { segments } }]
144
+ : [];
145
+ }
146
+
147
+ /** One titled command → the short title (`[…]`) and the title (the last `{…}`), each judged. */
148
+ const headingsOfMacro = (m: Macro): readonly TitledHeading[] =>
149
+ commandOf(m).flatMap((command) => [
150
+ ...titled(command, "short", optional(m)?.content),
151
+ ...titled(command, "title", mandatory(m).at(-1)?.content),
152
+ ]);
153
+
154
+ /**
155
+ * The paper's `\title` and every `\section`, `\subsection`, `\subsubsection`, `\paragraph` and
156
+ * `\subparagraph`, starred or not, each with its optional short title, in document order. What the
157
+ * PDF never shows is pruned (`isUnrendered`), and so is a macro definition's body: a heading in a
158
+ * comment environment or in a definition is not a heading where it is written.
159
+ */
160
+ export const headingsOf = (t: ParsedTex): readonly TitledHeading[] =>
161
+ visited(t.root, (v, parent) => isUnrendered(v, parent) || isDefinition(v))
162
+ .filter(isNode)
163
+ .flatMap((n) => (n.type === "macro" ? headingsOfMacro(n) : []));
@@ -324,6 +324,71 @@ describe("outlineOf — a heading that is never rendered is not in the outline",
324
324
  });
325
325
  });
326
326
 
327
+ describe("\\iffalse … \\fi — a branch TeX never reads is not in the tree any reader walks", () => {
328
+ const dead = [
329
+ "A",
330
+ "\\iffalse",
331
+ "\\section{Old Title}",
332
+ "dead words",
333
+ "\\ifx\\a\\b nested\\fi",
334
+ "still dead",
335
+ "\\fi",
336
+ "B",
337
+ ].join("\n");
338
+ const doc = (body: string) => `\\begin{document}\n${body}\n\\end{document}`;
339
+
340
+ it("🔴 outline, headings and rendered text skip it, nested conditionals included", () => {
341
+ const t = parseLatex(doc(dead));
342
+ expect(outlineOf(t).sections).toEqual([]);
343
+ expect(latexReader.headings(doc(dead))).toEqual([]);
344
+ // Guards: the dead strings are gone from the list itself, so A and B are one run.
345
+ expect(
346
+ renderedRuns(t).map((r) => runText(r).replace(/\s+/gu, " ").trim()),
347
+ ).toEqual(["A B"]);
348
+ });
349
+
350
+ it("the \\else branch of an \\iffalse is read, and the text after its \\fi", () => {
351
+ const src = doc(
352
+ "\\iffalse\n\\section{Old}\n\\else\n\\section{New}\n\\fi\n\\section{After}",
353
+ );
354
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
355
+ "New",
356
+ "After",
357
+ ]);
358
+ });
359
+
360
+ it("inside a group too, and `\\ifthenelse` (which has no \\fi) does not swallow the rest", () => {
361
+ const src = doc(
362
+ "{\\iffalse\\section{Old}\\ifthenelse{x}{y}{z}\\fi}\\section{Real}",
363
+ );
364
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
365
+ "Real",
366
+ ]);
367
+ });
368
+
369
+ it("inside an environment with arguments, in math, and an \\else holding its own conditional", () => {
370
+ const src = doc(
371
+ [
372
+ "\\begin{minipage}{4cm}\\iffalse\\section{Old}\\fi\\section{Kept}\\end{minipage}",
373
+ "$x \\iffalse y \\fi$ \\[ z \\] \\begin{align*} a \\end{align*}",
374
+ "\\iffalse\\section{Old}\\else\\ifx\\a\\b\\section{Inner}\\fi\\section{New}\\fi",
375
+ ].join("\n"),
376
+ );
377
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
378
+ "Kept",
379
+ "Inner",
380
+ "New",
381
+ ]);
382
+ });
383
+
384
+ it("an \\iffalse that is never closed leaves out everything after it, as TeX does", () => {
385
+ const src = doc("\\section{Real}\n\\iffalse\n\\section{Old}");
386
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
387
+ "Real",
388
+ ]);
389
+ });
390
+ });
391
+
327
392
  describe("renderedRuns", () => {
328
393
  it("runs of strings and spaces, each character mapped to its source offset", () => {
329
394
  const src = "A~B \\emph{C D}\n\n E";
@@ -26,6 +26,7 @@ import {
26
26
  parseTemplate,
27
27
  replaceDocumentClass,
28
28
  } from "./document-class.ts";
29
+ import { headingsOf } from "./headings.ts";
29
30
  import { documentBodyOf, includesOf } from "./includes.ts";
30
31
  import { outlineOf } from "./outline.ts";
31
32
  import { parseLatex } from "./parse.ts";
@@ -48,6 +49,7 @@ export {
48
49
  textOf,
49
50
  } from "./nodes.ts";
50
51
  export { documentBodyOf, includesOf } from "./includes.ts";
52
+ export { headingsOf } from "./headings.ts";
51
53
  export { outlineOf } from "./outline.ts";
52
54
  export { parseLatex, type ParsedTex } from "./parse.ts";
53
55
  export { bodyEmphasis, bodyProse } from "./prose.ts";
@@ -58,6 +60,7 @@ export { layoutOverridesOf } from "./layout.ts";
58
60
  export const latexReader: LatexReader = {
59
61
  documentClass: (src) => documentClassOf(parseLatex(src)),
60
62
  outline: (src) => outlineOf(parseLatex(src)),
63
+ headings: (src) => headingsOf(parseLatex(src)),
61
64
  renderedRuns: (src) => renderedRuns(parseLatex(src)),
62
65
  bodyProse: (src) => bodyProse(parseLatex(src)),
63
66
  bodyEmphasis: (src) => bodyEmphasis(parseLatex(src)),
@@ -12,6 +12,7 @@
12
12
  import type * as Ast from "@unified-latex/unified-latex-types";
13
13
  import { getParser } from "@unified-latex/unified-latex-util-parse";
14
14
  import { once } from "remeda";
15
+ import { withoutFalseBranches } from "./conditionals.ts";
15
16
 
16
17
  const signed = (
17
18
  names: readonly string[],
@@ -133,9 +134,9 @@ const parser = once(() =>
133
134
  }),
134
135
  );
135
136
 
136
- /** A LaTeX source → its tree. */
137
+ /** A LaTeX source → its tree, without the branches `\iffalse … \fi` hides (`conditionals.ts`). */
137
138
  export const parseLatex = (src: string): ParsedTex => ({
138
139
  src,
139
- root: parser().parse(src),
140
+ root: withoutFalseBranches(src, parser().parse(src)),
140
141
  [PARSED]: true,
141
142
  });
package/src/build.ts CHANGED
@@ -121,7 +121,7 @@ export interface PaperFacts {
121
121
  readonly name: string;
122
122
  readonly options: readonly string[];
123
123
  } | null;
124
- /** The label of the venue preset `paperlint.json` extends, or null. */
124
+ /** The id of the venue preset `paperlint.json` extends (`agenticdev`), or null. */
125
125
  readonly venue: string | null;
126
126
  /** Paper-supplied build scripts found on disk — reported as ignored. */
127
127
  readonly ignoredScripts: readonly string[];
@@ -239,7 +239,7 @@ export function readFacts(paperDir: string): PaperFacts {
239
239
  return {
240
240
  main,
241
241
  documentclass: documentclassOf(ast),
242
- venue: venue?.label ?? null,
242
+ venue: venue?.id ?? null,
243
243
  ignoredScripts: IGNORED_SCRIPTS.filter((s) =>
244
244
  existsSync(join(paperDir, s)),
245
245
  ),
package/src/cli.test.ts CHANGED
@@ -10,7 +10,12 @@ import { existsSync } from "node:fs";
10
10
  import { createServer } from "node:http";
11
11
  import { dirname, join } from "node:path";
12
12
  import { test } from "vitest";
13
- import { runNode, useTempDir, writeTree } from "../test/support.ts";
13
+ import {
14
+ runNode,
15
+ useTempDir,
16
+ venuePreset,
17
+ writeTree,
18
+ } from "../test/support.ts";
14
19
  import type { runToolchain } from "./toolchain.ts";
15
20
  import {
16
21
  chooseVenue,
@@ -195,8 +200,12 @@ test("runHook: a runtime with no cli.js beside it, and a hook killed by a signal
195
200
  test("chooseVenue: a preset by path (inside and outside the paper), by prefixed name, and a broken one", async () => {
196
201
  const dir = join(root, "venues");
197
202
  writeTree(dir, {
198
- "papers/p/mine.jsonc": JSON.stringify({ extends: "paperlint:agenticdev" }),
199
- "shared/ours.jsonc": JSON.stringify({ extends: "paperlint:agenticdev" }),
203
+ "papers/p/mine.jsonc": JSON.stringify(
204
+ venuePreset("mine", { extends: "paperlint:agenticdev" }),
205
+ ),
206
+ "shared/ours.jsonc": JSON.stringify(
207
+ venuePreset("ours", { extends: "paperlint:agenticdev" }),
208
+ ),
200
209
  "shared/broken.jsonc": "{ nope",
201
210
  });
202
211
  const paperDir = join(dir, "papers", "p");
@@ -394,6 +403,38 @@ test("silentOptionalRules: a config with no rules turns nothing on", async () =>
394
403
  assert.deepEqual(await silentOptionalRules(eslint, [], {}), []);
395
404
  });
396
405
 
406
+ const lastPageOn = {
407
+ rules: [
408
+ { basePath: ".", rules: { "pdf/last-page-balance": "error" as const } },
409
+ ],
410
+ };
411
+ const eslintWith = (rules: Record<string, unknown>) => ({
412
+ calculateConfigForFile: () => Promise.resolve({ rules }),
413
+ });
414
+
415
+ test("silentOptionalRules: a rule on for a paper is reached", async () => {
416
+ const eslint = eslintWith({ "pdf/last-page-balance": [2, {}] });
417
+ assert.deepEqual(
418
+ await silentOptionalRules(eslint, ["p/paper.tex"], lastPageOn),
419
+ [],
420
+ );
421
+ });
422
+
423
+ test("silentOptionalRules: a rule a paper's own block turns off is reached — the way out of a preset's", async () => {
424
+ const eslint = eslintWith({ "pdf/last-page-balance": [0] });
425
+ assert.deepEqual(
426
+ await silentOptionalRules(eslint, ["p/paper.tex"], lastPageOn),
427
+ [],
428
+ );
429
+ });
430
+
431
+ test("silentOptionalRules: a rule no block names for any paper is silent — a glob that matched nothing", async () => {
432
+ assert.deepEqual(
433
+ await silentOptionalRules(eslintWith({}), ["p/paper.tex"], lastPageOn),
434
+ ["pdf/last-page-balance"],
435
+ );
436
+ });
437
+
397
438
  test("init with no path and --format md sets up the current directory", async () => {
398
439
  const dir = writeTree(join(root, "init-here"), { "package.json": "{}" });
399
440
  const r = await cli(
@@ -645,10 +686,12 @@ test("parseArgs: submission's flags — two values and two switches", () => {
645
686
  const submissionPaper = (dir: string, site: string): void => {
646
687
  writeTree(dir, {
647
688
  "package.json": "{}",
648
- "venue.jsonc": JSON.stringify({
649
- extends: "paperlint:aidc",
650
- portal: { kind: "hotcrp", url: site },
651
- }),
689
+ "venue.jsonc": JSON.stringify(
690
+ venuePreset("venue", {
691
+ extends: "paperlint:aidc",
692
+ portal: { kind: "hotcrp", url: site },
693
+ }),
694
+ ),
652
695
  "papers/p/paperlint.json": JSON.stringify({
653
696
  extends: "../../venue.jsonc",
654
697
  submission: { id: 7 },
package/src/cli.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  REGISTER_BAND_RULE_LEVELS,
79
79
  registerBandRules,
80
80
  } from "./register-bands.ts";
81
+ import { headingCaseRule } from "./heading-case.ts";
81
82
  import {
82
83
  paperRules,
83
84
  identityField,
@@ -291,6 +292,18 @@ const TEX_RULE_DEPS = {
291
292
  latex: latexReader,
292
293
  };
293
294
 
295
+ /** The register band rules and `tex/heading-case`: the rules of the `tex` plugin that read a paper's words. */
296
+ const REGISTER_AND_CASE_RULES = {
297
+ ...registerBandRules(TEX_RULE_DEPS),
298
+ "heading-case": headingCaseRule(TEX_RULE_DEPS),
299
+ };
300
+
301
+ /** The rules of the files `paper.tex` includes, each read on its own as a fragment (#144). */
302
+ const FRAGMENT_RULES = {
303
+ "future-promise": texBuild["future-promise"],
304
+ "heading-case": REGISTER_AND_CASE_RULES["heading-case"],
305
+ };
306
+
294
307
  /** What the talk rules read with: the disk, the shipped presets, and the three media readers. */
295
308
  const TALK_RULE_DEPS = {
296
309
  files: nodeFiles,
@@ -402,7 +415,7 @@ export function buildConfig(
402
415
  ...texVenueRules(TEX_RULE_DEPS),
403
416
  "claim-provenance": claimProvenanceRule(TEX_RULE_DEPS),
404
417
  register: registerRule(TEX_RULE_DEPS),
405
- ...registerBandRules(TEX_RULE_DEPS),
418
+ ...REGISTER_AND_CASE_RULES,
406
419
  },
407
420
  },
408
421
  paper: { rules: texPaperRules },
@@ -454,7 +467,7 @@ export function buildConfig(
454
467
  plugins: {
455
468
  tex: {
456
469
  languages: { latex: texLanguage },
457
- rules: { "future-promise": texBuild["future-promise"] },
470
+ rules: FRAGMENT_RULES,
458
471
  },
459
472
  paper: {
460
473
  rules: {
@@ -623,7 +636,11 @@ export async function silentOptionalRules(
623
636
  for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
624
637
  // Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
625
638
  const cfg = ComputedConfig.parse(await eslint.calculateConfigForFile(f));
626
- for (const id of turnedOn) if (isOn(cfg?.rules?.[id])) reached.add(id);
639
+ // Reached is "some block names the rule for this paper", on OR off: a paper that turns off what
640
+ // its venue preset turned on is the documented way out, and reads the same as a glob that missed
641
+ // only if "off" is counted as "not there" — which refused every such paper.
642
+ for (const id of turnedOn)
643
+ if (cfg?.rules?.[id] !== undefined) reached.add(id);
627
644
  }
628
645
  return [...turnedOn].filter((id) => !reached.has(id));
629
646
  }
@@ -1162,7 +1179,7 @@ export async function createPaperAt(
1162
1179
 
1163
1180
  /** A venue `paperlint new` will write, with what the messages need to say about it. */
1164
1181
  export interface VenueChoice extends VenueSetting {
1165
- /** The preset's word in messages (`agenticdev`, `my-workshop`). */
1182
+ /** The preset's word in messages: the venue's name (`AgenticDev`), else the file name (`my-workshop`). */
1166
1183
  readonly label: string;
1167
1184
  /** The preset's kinds; empty when it sets no page limit. */
1168
1185
  readonly kinds: readonly string[];