paperlint 4.0.0 → 4.2.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 (236) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +55 -11
  3. package/CONTRIBUTING.md +30 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +23 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +110 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/includes.d.ts +12 -0
  15. package/dist/adapters/latex/includes.d.ts.map +1 -0
  16. package/dist/adapters/latex/includes.js +42 -0
  17. package/dist/adapters/latex/includes.js.map +1 -0
  18. package/dist/adapters/latex/index.d.ts +33 -0
  19. package/dist/adapters/latex/index.d.ts.map +1 -0
  20. package/dist/adapters/latex/index.js +25 -0
  21. package/dist/adapters/latex/index.js.map +1 -0
  22. package/dist/adapters/latex/nodes.d.ts +58 -0
  23. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  24. package/dist/adapters/latex/nodes.js +97 -0
  25. package/dist/adapters/latex/nodes.js.map +1 -0
  26. package/dist/adapters/latex/outline.d.ts +10 -0
  27. package/dist/adapters/latex/outline.d.ts.map +1 -0
  28. package/dist/adapters/latex/outline.js +39 -0
  29. package/dist/adapters/latex/outline.js.map +1 -0
  30. package/dist/adapters/latex/parse.d.ts +42 -0
  31. package/dist/adapters/latex/parse.d.ts.map +1 -0
  32. package/dist/adapters/latex/parse.js +95 -0
  33. package/dist/adapters/latex/parse.js.map +1 -0
  34. package/dist/adapters/latex/prose.d.ts +8 -0
  35. package/dist/adapters/latex/prose.d.ts.map +1 -0
  36. package/dist/adapters/latex/prose.js +353 -0
  37. package/dist/adapters/latex/prose.js.map +1 -0
  38. package/dist/adapters/latex/rendered.d.ts +10 -0
  39. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  40. package/dist/adapters/latex/rendered.js +39 -0
  41. package/dist/adapters/latex/rendered.js.map +1 -0
  42. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  43. package/dist/adapters/pdfjs/fill.js +12 -12
  44. package/dist/adapters/pdfjs/fill.js.map +1 -1
  45. package/dist/build.js +4 -4
  46. package/dist/build.js.map +1 -1
  47. package/dist/claim-provenance.d.ts +34 -0
  48. package/dist/claim-provenance.d.ts.map +1 -0
  49. package/dist/claim-provenance.js +146 -0
  50. package/dist/claim-provenance.js.map +1 -0
  51. package/dist/cli.d.ts.map +1 -1
  52. package/dist/cli.js +49 -5
  53. package/dist/cli.js.map +1 -1
  54. package/dist/domain/paper-source.d.ts +58 -0
  55. package/dist/domain/paper-source.d.ts.map +1 -0
  56. package/dist/domain/paper-source.js +109 -0
  57. package/dist/domain/paper-source.js.map +1 -0
  58. package/dist/domain/sentences.d.ts +26 -0
  59. package/dist/domain/sentences.d.ts.map +1 -0
  60. package/dist/domain/sentences.js +48 -0
  61. package/dist/domain/sentences.js.map +1 -0
  62. package/dist/domain/tex-document.d.ts +119 -0
  63. package/dist/domain/tex-document.d.ts.map +1 -0
  64. package/dist/domain/tex-document.js +43 -0
  65. package/dist/domain/tex-document.js.map +1 -0
  66. package/dist/eslint-rules/paper-typography.d.ts +7 -0
  67. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  68. package/dist/eslint-rules/paper-typography.js +53 -1
  69. package/dist/eslint-rules/paper-typography.js.map +1 -1
  70. package/dist/facts-file.d.ts.map +1 -1
  71. package/dist/facts-file.js +2 -2
  72. package/dist/facts-file.js.map +1 -1
  73. package/dist/new-paper.d.ts +5 -0
  74. package/dist/new-paper.d.ts.map +1 -1
  75. package/dist/new-paper.js +3 -0
  76. package/dist/new-paper.js.map +1 -1
  77. package/dist/package-dirs.d.ts +5 -0
  78. package/dist/package-dirs.d.ts.map +1 -0
  79. package/dist/package-dirs.js +14 -0
  80. package/dist/package-dirs.js.map +1 -0
  81. package/dist/ports/latex.d.ts +35 -0
  82. package/dist/ports/latex.d.ts.map +1 -0
  83. package/dist/ports/latex.js +2 -0
  84. package/dist/ports/latex.js.map +1 -0
  85. package/dist/presets.d.ts +15 -2
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -6
  88. package/dist/presets.js.map +1 -1
  89. package/dist/register.d.ts +40 -0
  90. package/dist/register.d.ts.map +1 -0
  91. package/dist/register.js +134 -0
  92. package/dist/register.js.map +1 -0
  93. package/dist/tex-paper.d.ts +19 -0
  94. package/dist/tex-paper.d.ts.map +1 -0
  95. package/dist/tex-paper.js +105 -0
  96. package/dist/tex-paper.js.map +1 -0
  97. package/dist/tex-requirements.d.ts +11 -0
  98. package/dist/tex-requirements.d.ts.map +1 -1
  99. package/dist/tex-requirements.js +14 -8
  100. package/dist/tex-requirements.js.map +1 -1
  101. package/dist/tex-venue-rules.d.ts +88 -0
  102. package/dist/tex-venue-rules.d.ts.map +1 -0
  103. package/dist/tex-venue-rules.js +268 -0
  104. package/dist/tex-venue-rules.js.map +1 -0
  105. package/dist/venue-rules.d.ts +1 -1
  106. package/dist/venue-rules.d.ts.map +1 -1
  107. package/docs/configuration.md +3 -2
  108. package/docs/e2e.md +69 -40
  109. package/docs/install.md +1 -1
  110. package/docs/prior-art/README.md +1 -1
  111. package/docs/prior-art/package-location.md +5 -5
  112. package/docs/prior-art/repro/README.md +1 -1
  113. package/docs/prior-art/test-tooling.md +21 -10
  114. package/docs/rules/tex/claim-provenance.md +83 -0
  115. package/docs/rules/tex/missing-input.md +58 -0
  116. package/docs/rules/tex/register.md +77 -0
  117. package/docs/rules/tex/required-section.md +75 -0
  118. package/docs/rules/tex/template.md +74 -0
  119. package/docs/rules/tex/venue-leftover.md +73 -0
  120. package/docs/rules.md +73 -48
  121. package/docs/testing.md +17 -4
  122. package/docs/texlive-install-decision.md +1 -1
  123. package/docs/toolchain.md +2 -2
  124. package/eslint-rules/paper-typography.test.mjs +48 -1
  125. package/eslint-rules/paper-typography.ts +72 -1
  126. package/eslint.config.mjs +156 -15
  127. package/fixtures/build-e2e/acmart/expect.json +26 -0
  128. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  129. package/fixtures/build-e2e/aidc/expect.json +14 -0
  130. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  131. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  132. package/fixtures/build-e2e/broken/expect.json +11 -0
  133. package/fixtures/build-e2e/cite/expect.json +27 -0
  134. package/fixtures/build-e2e/empty/expect.json +12 -0
  135. package/fixtures/build-e2e/fallback/expect.json +53 -0
  136. package/fixtures/build-e2e/guards/expect.json +6 -0
  137. package/fixtures/build-e2e/no-source/expect.json +10 -0
  138. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  139. package/fixtures/pdf-facts/README.md +1 -1
  140. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  141. package/fixtures/render-paper/build-clean.sh +3 -5
  142. package/package.json +14 -2
  143. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  144. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  145. package/presets/aidc.jsonc +73 -0
  146. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  147. package/presets/ieee-conference.jsonc +66 -0
  148. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  149. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  150. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  151. package/scripts/check.harness.ts +64 -10
  152. package/scripts/check.test.ts +60 -13
  153. package/scripts/check.ts +83 -30
  154. package/scripts/test.test.ts +1 -1
  155. package/scripts/test.ts +1 -1
  156. package/skills/cold-read-diff/SKILL.md +31 -10
  157. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  158. package/skills/draft-paper/SKILL.md +2 -2
  159. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  160. package/skills/grade-paper-writing/SKILL.md +109 -44
  161. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  162. package/skills/harden-paper/SKILL.md +35 -36
  163. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  164. package/skills/paper-adversarial-review/SKILL.md +2 -2
  165. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  166. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  167. package/skills/paper-pipeline/SKILL.md +4 -4
  168. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -3
  169. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  170. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  171. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  172. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  173. package/skills/pc-panel-review/SKILL.md +36 -11
  174. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  175. package/skills/render-paper/SKILL.md +2 -2
  176. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  177. package/skills/render-paper/check-render.sh +16 -63
  178. package/skills/render-paper/render-paper.harness.mjs +17 -59
  179. package/src/adapters/banal/index.test.ts +1 -1
  180. package/src/adapters/banal/invocation.ts +1 -1
  181. package/src/adapters/banal/output.test.ts +1 -1
  182. package/src/adapters/banal/run.test.ts +1 -1
  183. package/src/adapters/banal/xml.test.ts +1 -1
  184. package/src/adapters/latex/document-class.ts +155 -0
  185. package/src/adapters/latex/hidden.ts +70 -0
  186. package/src/adapters/latex/includes.ts +66 -0
  187. package/src/adapters/latex/index.test.ts +535 -0
  188. package/src/adapters/latex/index.ts +65 -0
  189. package/src/adapters/latex/nodes.ts +150 -0
  190. package/src/adapters/latex/outline.ts +64 -0
  191. package/src/adapters/latex/parse.ts +137 -0
  192. package/src/adapters/latex/prose.ts +484 -0
  193. package/src/adapters/latex/rendered.ts +59 -0
  194. package/src/adapters/pdfjs/fill.test.ts +1 -1
  195. package/src/adapters/pdfjs/fill.ts +18 -10
  196. package/src/build.harness.mjs +5 -7
  197. package/src/build.ts +4 -4
  198. package/src/claim-provenance.test.ts +352 -0
  199. package/src/claim-provenance.ts +217 -0
  200. package/src/cli.harness.mjs +1 -1
  201. package/src/cli.test.ts +2 -8
  202. package/src/cli.ts +53 -8
  203. package/src/domain/paper-source.test.ts +156 -0
  204. package/src/domain/paper-source.ts +233 -0
  205. package/src/domain/sentences.ts +67 -0
  206. package/src/domain/tex-document.test.ts +80 -0
  207. package/src/domain/tex-document.ts +143 -0
  208. package/src/engine.harness.mjs +1 -1
  209. package/src/facts-file.test.ts +4 -7
  210. package/src/facts-file.ts +2 -2
  211. package/src/latex-loop.harness.ts +1 -1
  212. package/src/new-paper.test.ts +28 -3
  213. package/src/new-paper.ts +8 -0
  214. package/src/package-dirs.ts +17 -0
  215. package/src/paper-settings-commands.test.ts +6 -1
  216. package/src/ports/latex.ts +42 -0
  217. package/src/presets.test.ts +65 -4
  218. package/src/presets.ts +38 -15
  219. package/src/register.test.ts +165 -0
  220. package/src/register.ts +177 -0
  221. package/src/rule-cases.test.ts +524 -0
  222. package/src/rule-docs.test.ts +159 -0
  223. package/src/tex-paper.test.ts +131 -0
  224. package/src/tex-paper.ts +152 -0
  225. package/src/tex-requirements.harness.ts +13 -23
  226. package/src/tex-requirements.ts +35 -8
  227. package/src/tex-venue-rules.test.ts +741 -0
  228. package/src/tex-venue-rules.ts +461 -0
  229. package/src/toolchain.harness.mjs +1 -1
  230. package/src/venue-rules.test.ts +96 -4
  231. package/src/venue-rules.ts +1 -1
  232. package/templates/paper/paper.tex +2 -1
  233. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  234. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  235. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  236. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,74 @@
1
+ # tex/template
2
+
3
+ **Level:** error · **Reads:** `paper.tex`, and the preset its `paperlint.json` extends
4
+
5
+ ## What it catches
6
+
7
+ The paper is not set in the class its venue requires. The rule compares the `\documentclass` line
8
+ of `paper.tex` with the preset's `template`: the class must be the same, and every option the
9
+ template names must be there. The paper may add options of its own.
10
+
11
+ A source that keeps one file for several venues and picks the class behind a TeX switch has several
12
+ `\documentclass` lines. The switch is not evaluated: each line is a candidate, the paper passes when
13
+ one of them is the template's class with its options, and when none is, one finding at the first
14
+ candidate names every one:
15
+
16
+ ```latex
17
+ \def\venue{2}
18
+ \if\venue1 \documentclass{article} \fi
19
+ \if\venue2 \documentclass[conference,compsoc]{IEEEtran} \fi % passes: the second candidate matches
20
+ ```
21
+
22
+ It only knows the class a preset names. A paper whose `paperlint.json` names no preset, or a
23
+ preset with no `template`, gets no finding.
24
+
25
+ ## Why
26
+
27
+ A venue checks the template before anyone reads the paper, and a paper in the wrong template can
28
+ be rejected with no review. Venues that name a class say so plainly — AIDC, for one: «LaTeX
29
+ submissions must use the IEEEtran.cls version 1.8b template with the
30
+ \documentclass[conference,compsoc]{IEEEtran} document class option», and «This requirement will be
31
+ strictly enforced» (https://aidcworkshop.github.io/).
32
+
33
+ The `pdf/*` rules see the consequences of a wrong class only after a build — fonts, page size,
34
+ columns — and some not at all: `compsoc` changes the text block and the headings, not the fonts.
35
+ This rule reads the cause, in the source, before any build.
36
+
37
+ ## Examples
38
+
39
+ Under a preset with `"template": "\\documentclass[conference,compsoc]{IEEEtran}"`:
40
+
41
+ ```latex
42
+ \documentclass[conference]{IEEEtran} % fails: compsoc is missing
43
+ \documentclass[sigconf]{acmart} % fails: another class
44
+ \documentclass[compsoc, conference]{IEEEtran} % passes: any order, spaces allowed
45
+ \documentclass[conference,compsoc,final]{IEEEtran} % passes: an extra option of the paper's own
46
+ ```
47
+
48
+ The first line reports:
49
+
50
+ > the class option `compsoc` is missing: aidc requires `\documentclass[conference,compsoc]{IEEEtran}`
51
+
52
+ ## Preset field
53
+
54
+ No rule options. It reads `template`, merged along `extends` (a child's replaces its parent's):
55
+
56
+ ```jsonc
57
+ "template": "\\documentclass[conference,compsoc]{IEEEtran}"
58
+ ```
59
+
60
+ A whole `\documentclass[…]{…}` line, or a bare class name (`"article"`), which requires the class
61
+ and no option. A value that is neither is reported as a finding that names the preset file.
62
+
63
+ ## What it does not check
64
+
65
+ - Options a venue forbids (`review`, `anonymous` in a camera-ready): only that what the venue
66
+ requires is there.
67
+ - The version of the class file (#94).
68
+ - A `\documentclass` inside a comment: the rule reads the parse tree, so it does not count.
69
+ - Which branch of a TeX switch builds: with several class lines, one matching candidate is enough.
70
+
71
+ ## How to fix
72
+
73
+ Set the `\documentclass` line to the one the message quotes, keeping your own extra options. A new
74
+ paper made with `paperlint new <name> --venue <preset>` already has it.
@@ -0,0 +1,73 @@
1
+ # tex/venue-leftover
2
+
3
+ **Level:** warn · **Reads:** `paper.tex`, and the shipped venue presets
4
+
5
+ ## What it catches
6
+
7
+ The paper mentions a venue other than the one set in its `paperlint.json`. Usually that is text
8
+ left over from an earlier submission: "Submission to VenueA 2026" in the author block of a paper
9
+ now going to VenueB.
10
+
11
+ It only knows the venues paperlint ships a preset for, by each preset's `name` and `aliases`. A
12
+ venue with no preset is not recognized.
13
+
14
+ It reads the whole paper — `paper.tex` with the files it `\input`s, `\include`s or `\subfile`s
15
+ spliced in; a name found in an included file is reported at that include, with the file and line at
16
+ the front of the message. It looks at what a reader sees — prose, the author block, captions, footnotes, a citation's note in
17
+ square brackets, and front-matter macros such as `\acmConference` — and ignores comments, citation
18
+ keys, labels, URLs (a `\url`, and the address of an `\href` — its link text is read), math, code and
19
+ the bibliography. Names are matched as whole words, case-sensitive.
20
+
21
+ A macro definition that names another venue — `\newcommand{\oldvenue}{VenueA}` — is reported at the
22
+ definition, whether or not the macro is used: the rule does not expand macros, so a use such as
23
+ `Submitted to \oldvenue` has no text of its own, and the definition is where the old name lives.
24
+
25
+ ## Why
26
+
27
+ A reviewer reads the author block before the abstract. A line naming the previous venue tells them
28
+ the paper was written for somewhere else, and at a double-blind venue it can say more than that.
29
+ Nothing else compares what the source says about the venue with what the settings say.
30
+
31
+ ## Examples
32
+
33
+ Under `"extends": "paperlint:realm"`, where the `agenticdev` preset's alias is `AgenticDev`:
34
+
35
+ ```latex
36
+ \acmConference[AgenticDev '26]{Workshop on Agentic Development} % warns: «AgenticDev»
37
+ As shown~\cite{agenticdev2025}. % silent: a citation key
38
+ % AgenticDev draft % silent: a comment
39
+ ```
40
+
41
+ The first line reports:
42
+
43
+ > «AgenticDev» names agenticdev, and this paper extends realm — a leftover from an earlier
44
+ > submission? A reviewer reads it before the abstract. Comments and citation keys are not
45
+ > reported; a sentence that names the other venue on purpose can keep it with a disable directive
46
+
47
+ The same line passes under `"extends": "paperlint:agenticdev"`: it names the paper's own venue.
48
+
49
+ ## Preset fields
50
+
51
+ No rule options. It reads every shipped preset's `name` and `aliases`, collected along `extends`:
52
+
53
+ ```jsonc
54
+ "aliases": ["AgenticDev"]
55
+ ```
56
+
57
+ A name the paper's own chain also declares (two workshops of one parent conference) is never
58
+ reported.
59
+
60
+ ## What it does not check
61
+
62
+ - A venue no shipped preset describes.
63
+ - A name split by markup (`\textbf{AI}Sec`).
64
+
65
+ ## How to fix
66
+
67
+ Remove or replace the old venue's name. A sentence that names another venue on purpose keeps it
68
+ with ESLint's directive and the reason:
69
+
70
+ ```latex
71
+ % eslint-disable-next-line tex/venue-leftover -- comparing with the venue that published [3]
72
+ Unlike AgenticDev, this workshop …
73
+ ```
package/docs/rules.md CHANGED
@@ -3,30 +3,40 @@
3
3
  The README lists what each check catches in one line. This page says exactly which file each one
4
4
  reads and when it fails. Errors fail `paperlint lint`; warnings print and do not.
5
5
 
6
- | Rule | Level | Reads | Fails when |
7
- | ------------------------------ | ----- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
8
- | `paper/stages` | error | `PIPELINE-STATUS.md` | a declared stage's PDF is missing, or the `bytes:` it names does not match the file's real size, or a frozen PDF exists that no stage declares |
9
- | `paper/source` | error | `PIPELINE-STATUS.md` | a declared stage has no frozen `.tex` beside its PDF (a commit hash does not count — squash and gc destroy it) |
10
- | `paper/author-list` | error | `paper.tex` → `_build/references.json` | an entry's authors are not those of the version it cites — typically the arXiv preprint's list under a published venue. Checked online by `paperlint build` (DBLP), reported on the entry's own line |
11
- | `paper/research-question` | warn | `paper.tex`, `paper.md`, `PIPELINE-STATUS.md` | a stage is declared and either the scorecard has no `researchQuestion` field, or it has one the paper does not contain. The declared sentence is compared against the source with whitespace collapsed — your words, not a pattern. Advisory, because nothing here can judge whether what you declared _is_ a research question |
12
- | `paper/section-word` | warn | `paper.tex`, `paper.md` | `§` or `\S\ref` instead of the word Section. **Fixable:** `--fix` writes `Section~\ref{…}` / `Section 5` |
13
- | `paper/leading-zero` | warn | `paper.tex`, `paper.md` | a decimal below 1 without its leading zero (`.05` instead of `0.05`, IEEE / ISO 80000-1 style), counted only where the reader sees it — prose, math and table cells, not macro options, column specs, comments, code or tikz. A number right after `¶` or `§` (`¶¶.42`) is a paragraph designator and is not reported; in a paragraph that cites numbers that way, another bare decimal is reported without a fix, with the zero as a suggestion. **Fixable** |
14
- | `paper/figure-ref-style` | warn | `paper.tex` | `Fig.~\ref` and `Figure~\ref` mixed in one document; each minority occurrence is reported. **Fixable:** `--fix` writes the majority form |
15
- | `bib/reachable-entry` | warn | `paper.tex` (its `filecontents` bibliography) | an entry has no doi, url or arXiv id — a reader has nothing to follow. Reported on the entry; no fix, the link has to be looked up |
16
- | `paper/cite-exists` | error | `paper.tex` → `_build/references.json` | an entry's doi or arXiv id resolves to nothing, or to a different work. Checked online by `paperlint build`; a work that was merely not found is not a finding |
17
- | `paper/refs-fresh` | error | `paper.tex` → `_build/references.json` | the bibliography changed since the build checked it (its SHA-256 differs) — run `npx paperlint build` |
18
- | `paper/refs-checked` | warn | `paper.tex` → `_build/references.json` | the paper has a bibliography and no build has checked it, or the last build could not (no network) — so the two rules above did not run |
19
- | `tex/future-promise` | warn | `paper.tex` | a camera-ready build still says "will be released" about something already handed over |
20
- | `tex/acm-frontmatter-override` | error | `paper.tex` | an `acmart` build overrides ACM's front-matter commands and drops template elements from page 1 |
21
- | `review/frontmatter` | error | `reviews/*.md` | the review's frontmatter fails paperlint's JSON Schema — a `findings` record without `id`/`status`, an unknown field, or an **open** finding with no `cause` (`skill-defect` · `missing-skill` · `hook` · `rule`). A review with no `findings` key is not checked for findings |
22
- | `sibling/frontmatter` | warn | `siblings/*.md` (not `README.md`) | a sibling card's frontmatter has no `read:` saying how much of the competing paper was read (`full` · `abstract` · `none`) |
23
- | `pdf/fresh` | error | `paper.tex` → `_build/paper.facts.json` | the paper names a venue and the facts cannot be judged: not JSON, a schema other than 2, or they describe a PDF that is gone or differs from the one on disk (its SHA-256) |
24
- | `pdf/profile` | error | `paper.tex` → `paperlint.json` | `paperlint.json` is not JSON or has an unknown key, its `extends` does not resolve (not found, a cycle, a chain longer than four, a preset that fails the schema, an npm name), it names no `kind` while the preset has kinds, or names a kind the preset does not have |
25
- | `pdf/fonts` | error | `paper.tex` → `_build/paper.facts.json` | a font the pages draw is Type 3 or not embedded, or no font starts with the family the venue preset names for body text (`fonts_text`) or headings (`fonts_title`) |
26
- | `pdf/geometry` | error | `paper.tex` → `_build/paper.facts.json` | the page width or height is more than `dimTol` (default 0.05 in) off the preset's, or the column count differs |
27
- | `pdf/limits` | error | `paper.tex` → `_build/paper.facts.json` | body or reference pages exceed the limit of the paper's kind, or the reference font size is outside the preset's range widened by `body_pt_tol` |
28
- | `pdf/body-size` | warn | `paper.tex` → `_build/paper.facts.json` | the body font size is more than `body_pt_tol` off the preset's. A warning: banal measures the mode of the rendered text, not the declared size (9.30 pt measured at a declared 9) |
29
- | `pdf/measured` | warn | `paper.tex` → `_build/paper.facts.json` | the paper's `paperlint.json` names no venue preset yet; or it names one and there are no facts (it was not built), or the facts carry no page geometry (banal was not found) — so the checks above did not run |
6
+ | Rule | Level | Reads | Fails when |
7
+ | ------------------------------------------------------- | ----- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
8
+ | `paper/stages` | error | `PIPELINE-STATUS.md` | a declared stage's PDF is missing, or the `bytes:` it names does not match the file's real size, or a frozen PDF exists that no stage declares |
9
+ | `paper/source` | error | `PIPELINE-STATUS.md` | a declared stage has no frozen `.tex` beside its PDF (a commit hash does not count — squash and gc destroy it) |
10
+ | `paper/author-list` | error | `paper.tex` → `_build/references.json` | an entry's authors are not those of the version it cites — typically the arXiv preprint's list under a published venue. Checked online by `paperlint build` (DBLP), reported on the entry's own line |
11
+ | `paper/research-question` | warn | `paper.tex`, `paper.md`, `PIPELINE-STATUS.md` | a stage is declared and either the scorecard has no `researchQuestion` field, or it has one the paper does not contain. The declared sentence is compared against the source with whitespace collapsed — your words, not a pattern. Advisory, because nothing here can judge whether what you declared _is_ a research question |
12
+ | `paper/section-word` | warn | `paper.tex`, `paper.md` | `§` or `\S\ref` instead of the word Section. **Fixable:** `--fix` writes `Section~\ref{…}` / `Section 5` |
13
+ | `paper/leading-zero` | warn | `paper.tex`, `paper.md` | a decimal below 1 without its leading zero (`.05` instead of `0.05`, IEEE / ISO 80000-1 style), counted only where the reader sees it — prose, math and table cells, not macro options, column specs, comments, code or tikz. A number right after `¶` or `§` (`¶¶.42`) is a paragraph designator and is not reported; in a paragraph that cites numbers that way, another bare decimal is reported without a fix, with the zero as a suggestion. **Fixable** |
14
+ | `paper/figure-ref-style` | warn | `paper.tex` | `Fig.~\ref` and `Figure~\ref` mixed in one document; each minority occurrence is reported. **Fixable:** `--fix` writes the majority form |
15
+ | `bib/reachable-entry` | warn | `paper.tex` (its `filecontents` bibliography) | an entry has no doi, url or arXiv id — a reader has nothing to follow. Reported on the entry; no fix, the link has to be looked up |
16
+ | `paper/cite-exists` | error | `paper.tex` → `_build/references.json` | an entry's doi or arXiv id resolves to nothing, or to a different work. Checked online by `paperlint build`; a work that was merely not found is not a finding |
17
+ | `paper/refs-fresh` | error | `paper.tex` → `_build/references.json` | the bibliography changed since the build checked it (its SHA-256 differs) — run `npx paperlint build` |
18
+ | `paper/refs-checked` | warn | `paper.tex` → `_build/references.json` | the paper has a bibliography and no build has checked it, or the last build could not (no network) — so the two rules above did not run |
19
+ | `tex/future-promise` | warn | `paper.tex` | a camera-ready build still says "will be released" about something already handed over |
20
+ | `tex/acm-frontmatter-override` | error | `paper.tex` | an `acmart` build overrides ACM's front-matter commands and drops template elements from page 1 |
21
+ | [`tex/template`](rules/tex/template.md) | error | `paper.tex` → `paperlint.json` | the `\documentclass` is not the preset's `template` class, or lacks an option it names — [page](rules/tex/template.md) |
22
+ | [`tex/required-section`](rules/tex/required-section.md) | error | `paper.tex` → `paperlint.json` | a section the preset requires (AIDC: «LLM Usage Statement», last) is missing or not where it must be — [page](rules/tex/required-section.md) |
23
+ | [`tex/venue-leftover`](rules/tex/venue-leftover.md) | warn | `paper.tex` → `paperlint.json` | the text names another shipped venue than the one the paper extends («Submission to VenueA 2026» in a paper now set for VenueB) — [page](rules/tex/venue-leftover.md) |
24
+ | [`tex/claim-provenance`](rules/tex/claim-provenance.md) | warn | `paper.tex` | a sentence of the body states a number and does not say whose it is — no citation or link, no _we_/_our_, no `\ref` or "Table 2", no sample ("n = 134", "48 runs") — [page](rules/tex/claim-provenance.md) |
25
+ | [`tex/missing-input`](rules/tex/missing-input.md) | warn | `paper.tex` and the files it includes | an `\input`, `\include` or `\subfile` names a file that is not there, so no rule read its text — [page](rules/tex/missing-input.md) |
26
+ | [`tex/register`](rules/tex/register.md) | warn | `paper.tex` and the files it includes | the body runs to a post's register: more than 0.8 sentences per 1000 words open with _And_, _So_, _But_, _Nor_, _Or_ or _Yet_ — the limit is an option — [page](rules/tex/register.md) |
27
+ | `review/frontmatter` | error | `reviews/*.md` | the review's frontmatter fails paperlint's JSON Schema — a `findings` record without `id`/`status`, an unknown field, or an **open** finding with no `cause` (`skill-defect` · `missing-skill` · `hook` · `rule`). A review with no `findings` key is not checked for findings |
28
+ | `sibling/frontmatter` | warn | `siblings/*.md` (not `README.md`) | a sibling card's frontmatter has no `read:` saying how much of the competing paper was read (`full` · `abstract` · `none`) |
29
+ | `pdf/fresh` | error | `paper.tex` → `_build/paper.facts.json` | the paper names a venue and the facts cannot be judged: not JSON, a schema other than 2, or they describe a PDF that is gone or differs from the one on disk (its SHA-256) |
30
+ | `pdf/profile` | error | `paper.tex` → `paperlint.json` | `paperlint.json` is not JSON or has an unknown key, its `extends` does not resolve (not found, a cycle, a chain longer than four, a preset that fails the schema, an npm name), it names no `kind` while the preset has kinds, or names a kind the preset does not have |
31
+ | `pdf/fonts` | error | `paper.tex` → `_build/paper.facts.json` | a font the pages draw is Type 3 or not embedded, or no font starts with the family the venue preset names for body text (`fonts_text`) or headings (`fonts_title`) |
32
+ | `pdf/geometry` | error | `paper.tex` → `_build/paper.facts.json` | the page width or height is more than `dimTol` (default 0.05 in) off the preset's, or the column count differs |
33
+ | `pdf/limits` | error | `paper.tex` → `_build/paper.facts.json` | body or reference pages exceed the limit of the paper's kind, or the reference font size is outside the preset's range widened by `body_pt_tol` |
34
+ | `pdf/body-size` | warn | `paper.tex` → `_build/paper.facts.json` | the body font size is more than `body_pt_tol` off the preset's. A warning: banal measures the mode of the rendered text, not the declared size (9.30 pt measured at a declared 9) |
35
+ | `pdf/measured` | warn | `paper.tex` → `_build/paper.facts.json` | the paper's `paperlint.json` names no venue preset yet; or it names one and there are no facts (it was not built), or the facts carry no page geometry (banal was not found) — so the checks above did not run |
36
+
37
+ `tex/required-section` requires AIDC's «LLM Usage Statement» **always**. ACSAC asks for it «if
38
+ LLMs are used», which no rule can know; an author who used none says so in the section
39
+ ([page](rules/tex/required-section.md)).
30
40
 
31
41
  Optional rules — off unless you turn them on in the `rules` setting, because only some venues need
32
42
  them — are on their own page: [`optional-rules.md`](optional-rules.md). Today there is one,
@@ -79,21 +89,34 @@ for papers, each with the quote it came from, the TeX packages its template need
79
89
  the venue implies. A preset may itself extend another: the ACM venues extend the `acm-sigconf`
80
90
  family, which holds everything the ACM template decides.
81
91
 
82
- | preset | extends | template | kinds | page limit checked | rules it turns on |
83
- | ----------------------- | ----------------------- | -------------------- | -------------------------------------------------------- | ------------------ | ----------------------- |
84
- | `paperlint:acm-sigconf` | — | ACM `acmart` sigconf | none | no (no kinds) | — |
85
- | `paperlint:agenticdev` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `short` (5 + 2 refs), `full` (10 + 2), `demo` (5 + 2) | yes | `pdf/last-page-balance` |
86
- | `paperlint:aisec` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `research`, `benchmark`, `position`, `sok` (10 + 2 each) | yes | — |
87
- | `paperlint:realm` | — | ACL | `long`, `short` | no — see below | — |
88
-
89
- That is all that ships today. There is no IEEE, NeurIPS, USENIX or Springer preset. A paper for an
90
- ACM venue nobody has profiled can extend `paperlint:acm-sigconf` directly: page size, columns and
91
- fonts are checked, and `pdf/profile` says the page limit is not. For anything else, write your own
92
- preset ([below](#writing-your-own-venue-preset)). REALM's preset sets no page limit on purpose:
93
- banal counts the Limitations and Ethics sections as body, ACL does not, and a limit on banal's
94
- number would fail a correct paper. AgenticDev's turns on `pdf/last-page-balance` because its
95
- proceedings are produced by Conference Publishing Consulting, which sends back an unbalanced last
96
- page; AISec's does not, because nothing in hand says who produces the AISec proceedings.
92
+ | preset | extends | template | kinds | page limit checked | rules it turns on |
93
+ | --------------------------- | --------------------------- | ------------------------------------- | -------------------------------------------------------- | ------------------ | ----------------------- |
94
+ | `paperlint:acm-sigconf` | — | ACM `acmart` sigconf | none | no (no kinds) | — |
95
+ | `paperlint:agenticdev` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `short` (5 + 2 refs), `full` (10 + 2), `demo` (5 + 2) | yes | `pdf/last-page-balance` |
96
+ | `paperlint:aisec` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `research`, `benchmark`, `position`, `sok` (10 + 2 each) | yes | — |
97
+ | `paperlint:realm` | — | ACL | `long`, `short` | no — see below | — |
98
+ | `paperlint:ieee-conference` | — | IEEE `IEEEtran` conference | none | no (no kinds) | — |
99
+ | `paperlint:aidc` | `paperlint:ieee-conference` | IEEE `IEEEtran` conference, `compsoc` | `regular` (12), `short` (6) | no — see below | — |
100
+
101
+ That is all that ships today. There is no NeurIPS, USENIX or Springer preset. A paper for an ACM or
102
+ IEEE conference venue nobody has profiled can extend `paperlint:acm-sigconf` or
103
+ `paperlint:ieee-conference` directly: page size, columns and fonts are checked, and `pdf/profile`
104
+ says the page limit is not. For anything else, write your own preset
105
+ ([below](#writing-your-own-venue-preset)). REALM's preset sets no page limit on purpose: banal
106
+ counts the Limitations and Ethics sections as body, ACL does not, and a limit on banal's number
107
+ would fail a correct paper. AIDC's sets none for the same reason, measured: banal counts a page as
108
+ body when an appendix or the LLM Usage Statement sits on it before the bibliography, and AIDC counts
109
+ neither, so a correct 12-page paper measured 13. AgenticDev's turns on `pdf/last-page-balance`
110
+ because its proceedings are produced by Conference Publishing Consulting, which sends back an
111
+ unbalanced last page; AISec's does not, because nothing in hand says who produces the AISec
112
+ proceedings.
113
+
114
+ **How presets are named.** A template family — the preset venues extend, owning the template,
115
+ the format and the TeX packages — is `<publisher>-<template variant>`: `acm-sigconf`, `ieee-conference`. A venue
116
+ is its own short name, lowercase, with no publisher and no parent-conference prefix: `aisec`,
117
+ `agenticdev`, `realm`, `aidc`. Which template a venue uses is said by its `extends`, not by its
118
+ name — a name repeating it would be wrong the day the venue changes template — and the parent
119
+ conference (CCS, ASE, ACSAC) goes into the preset's comment and its `aliases`.
97
120
 
98
121
  **Build, then lint.** The rules judge what `paperlint build` measured and wrote to
99
122
  `_build/paper.facts.json` — page count, fonts, and, through banal, page size, columns, font sizes
@@ -130,7 +153,7 @@ To skip a check for one paper — a finding you accept — set it to `"off"` in
130
153
 
131
154
  A preset is a JSONC file (JSON with comments — keep the call-for-papers quote beside each number)
132
155
  of the same shape as the shipped ones, validated by
133
- [`venue-profile.schema.json`](../skills/submit-paper/references/venues/venue-profile.schema.json).
156
+ [`venue-profile.schema.json`](../presets/venue-profile.schema.json).
134
157
  Put it in your repository and extend it by a path relative to the file that names it:
135
158
 
136
159
  ```
@@ -170,14 +193,16 @@ papers/
170
193
 
171
194
  The numbers and packages above illustrate the shape; take yours from the venue's own call for papers and template.
172
195
 
173
- | key | what it is |
174
- | ---------- | --------------------------------------------------------------------------------------------------- |
175
- | `extends` | the preset this one builds on: `paperlint:<name>` or `./path` / `../path`, relative to this file |
176
- | `name` | how messages name the venue; the file name otherwise |
177
- | `template` | the `\documentclass` the venue's template uses |
178
- | `format` | page size, columns, fonts, font sizes, and `kinds` (page limits per kind of paper) |
179
- | `tex` | TeX Live packages, each with the files that prove it is installed. Required unless `extends` is set |
180
- | `rules` | rules the venue implies, rule id → severity or `[severity, options]` |
196
+ | key | what it is |
197
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
198
+ | `extends` | the preset this one builds on: `paperlint:<name>` or `./path` / `../path`, relative to this file |
199
+ | `name` | how messages name the venue; the file name otherwise |
200
+ | `template` | the `\documentclass` the venue's template uses — a whole line (`\documentclass[conference,compsoc]{IEEEtran}`) or a bare class name; `tex/template` requires the paper's class and every option named here |
201
+ | `aliases` | other names the venue goes by in a paper's text — its acronym, its parent conference: `["AISec", "ACM CCS"]`. Collected along `extends`, with `name`. Read by `tex/venue-leftover` |
202
+ | `required_sections` | sections the venue requires, `[{ "title": "LLM Usage Statement", "position": "last" }]`: the exact title, and `last` when it must close the paper. Read by `tex/required-section`; a child's list replaces its parent's |
203
+ | `format` | page size, columns, fonts, font sizes, and `kinds` (page limits per kind of paper) |
204
+ | `tex` | TeX Live packages, each with the files that prove it is installed. Required unless `extends` is set |
205
+ | `rules` | rules the venue implies, rule id → severity or `[severity, options]` |
181
206
 
182
207
  How a chain merges, from the root preset to the paper: `tex` is the union — a child never removes a
183
208
  package; `format` keys are replaced one by one, and a child's kind replaces that kind whole;
package/docs/testing.md CHANGED
@@ -40,6 +40,11 @@ When in doubt between unit and integration: if the function takes a port (`Files
40
40
  - **Example:** `src/adapters/references/index.test.ts` — the reference checker over a fake
41
41
  `fetch`: a cold run stores every answer, a warm run makes no request at all (the recorded list of
42
42
  requests is empty), an edited entry asks only for itself.
43
+ - **A rule through real ESLint is integration, not e2e:** `src/rule-cases.test.ts` reads every rule
44
+ the registry holds (`rulePlugins`) and runs each through `paperlint lint --json` in-process on a
45
+ small paper tree — the CLI's own config, the `tex/latex` language, `loc`, message templating. Each
46
+ rule needs a case that reports (id, severity, line) and one that stays silent; a registered rule
47
+ without cases fails the test by name. A `_build/` file a rule reads is planted, not built.
43
48
 
44
49
  ## Harness — vigiles, the agent-facing surface only
45
50
 
@@ -56,14 +61,20 @@ When in doubt between unit and integration: if the function takes a port (`Files
56
61
  - **Skills are tested through vigiles**, not a bespoke runner: a home-grown runner here once printed
57
62
  byte-identical "clean" verdicts for three skills that had never loaded.
58
63
 
59
- ## End-to-end — `test/e2e/*.ts`
64
+ ## End-to-end — `test/e2e/<area>/*.e2e.ts`
60
65
 
61
66
  - **Answers:** does the package work once it is somewhere else? The tarball installed into another
62
67
  tree, a real `pdflatex` build, the PDF's fonts measured.
63
68
  - **May touch:** everything a user's machine has, except the network: a citation service is a
64
- counting fake `fetch` even here (`test/e2e/build.ts`).
69
+ counting fake `fetch` even here (`test/e2e/tex/build.e2e.ts`).
65
70
  - **Types:** the runs import the built modules from `dist/` (the shipped path is the point), so
66
71
  their types are the declarations `tsc` emits — `npm run build` before type-checking them.
72
+ - **Where:** a directory per environment the tests need — `test/e2e/install/` (npm and pnpm),
73
+ `test/e2e/tex/` (TeX Live) — each a vitest project that finds `*.e2e.ts` by its glob. A new e2e
74
+ file needs no list anywhere. Helpers beside them are named without `.e2e.ts`.
75
+ - **A missing tool:** `missing(what, present)` from `test/e2e/need.ts` → `describe.skipIf`. Locally
76
+ the tests are reported skipped; with `CI` set they fail.
77
+ - **A build fixture:** a folder in `fixtures/build-e2e/` with its own `expect.json`; no test code.
67
78
  - **What each run proves, and when a change owes one:** [`e2e.md`](e2e.md).
68
79
 
69
80
  ## Running
@@ -71,9 +82,11 @@ When in doubt between unit and integration: if the function takes a port (`Files
71
82
  ```bash
72
83
  npx vitest run <file or folder> # unit and integration tests
73
84
  npx vigiles test <file> # one harness
74
- npm test # everything: vitest, then every harness
85
+ npm test # unit and integration (vitest project `unit`), then every harness
75
86
  npm run coverage # the same under c8, failing below the thresholds in .c8rc.json
76
- npm run test:e2e # the end-to-end runs
87
+ npm run test:e2e # every end-to-end run
88
+ npm run test:e2e:install # the package packed and installed under npm and pnpm
89
+ npm run test:e2e:tex # real TeX Live: toolchain, builds of every fixture, banal
77
90
  npm run check # every gate CI runs, and the list of what it does not reproduce
78
91
  ```
79
92
 
@@ -18,7 +18,7 @@ another"_.
18
18
  > **Where this ended up (2026-09-24).** Method 5 below won, and it is no longer a CI script: it is
19
19
  > `paperlint toolchain` (`src/toolchain.ts`), the command users run and the `build-e2e` job runs on Linux
20
20
  > and macOS. The package list and the file contract (`REQUIRED_FILES` in the deleted
21
- > `ensure-toolchain.sh`) moved into the venue profiles — each `skills/submit-paper/references/venues/*.jsonc`
21
+ > `ensure-toolchain.sh`) moved into the venue profiles — each `presets/*.jsonc`
22
22
  > declares a `tex` block of CTAN package → proof files, and `tex-base.jsonc` the set every paper
23
23
  > gets. This file stays as the record of why tlmgr and not apt or a container; the scripts it names
24
24
  > (`ensure-toolchain.sh`, `ci-install-texlive.sh`) were deleted in the same change.
package/docs/toolchain.md CHANGED
@@ -58,7 +58,7 @@ that has every declared package, so a new-year install that was interrupted neve
58
58
  complete older one.
59
59
 
60
60
  **Where the package list lives:** in the venue profiles, not here and not in a script.
61
- `skills/submit-paper/references/venues/<venue>.jsonc` carries a `tex` block — CTAN package → the
61
+ `presets/<venue>.jsonc` carries a `tex` block — CTAN package → the
62
62
  files that prove it is installed — and `tex-base.jsonc` carries what every paper gets, and all a
63
63
  paper with an unknown venue gets. The shape is `venue-profile.schema.json` in the same directory.
64
64
  A venue that needs a new package gets one line there.
@@ -97,7 +97,7 @@ accepts as input. Measured on 50 PDFs / 598 pages (2026-09-25): banal on paperli
97
97
  real `pdftohtml` agree on every field the facts file keeps, with no venue verdict changed. They
98
98
  agree only because paperlint leaves out rotated and invisible text (as `pdftohtml` does), writes each
99
99
  text's colour so banal drops light text by its own rule, and writes sizes and coordinates at the
100
- zoom and precision banal expects. `test/e2e/banal.ts` checks each of those against the real banal
100
+ zoom and precision banal expects. `test/e2e/tex/banal.e2e.ts` checks each of those against the real banal
101
101
  on the committed fixtures.
102
102
 
103
103
  **How it is installed — and the licence boundary.** banal is **GPL-2.0-or-later**; paperlint is MIT. paperlint
@@ -16,7 +16,10 @@ import { readFileSync } from "node:fs";
16
16
  import { dirname, join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { texLanguage } from "./latex-language.ts";
19
- import typography, { texVisibleRuns } from "./paper-typography.ts";
19
+ import typography, {
20
+ crefDefinitionRanges,
21
+ texVisibleRuns,
22
+ } from "./paper-typography.ts";
20
23
  import bib from "./bib-reachable-entry.ts";
21
24
 
22
25
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -118,6 +121,50 @@ describe("paper/section-word", () => {
118
121
  expect(ids(await lint(src), "paper/section-word")).toEqual([]);
119
122
  });
120
123
 
124
+ it("🔴 a cleveref name definition is not prose: silent, and --fix leaves it as written", async () => {
125
+ const src =
126
+ "\\documentclass{article}\n\\usepackage{cleveref}\n\\crefname{section}{§}{§§}\n\\Crefname{section}{§}{§§}\n" +
127
+ "\\begin{document}\nText.\n\\end{document}\n";
128
+ expect(ids(await lint(src), "paper/section-word")).toEqual([]);
129
+ expect(await fixed(src)).toBe(src);
130
+ });
131
+
132
+ it("a definition's range: its attached arguments, or the groups after a macro the parser did not sign", () => {
133
+ const signed = "\\crefname{section}{}{§§}";
134
+ expect(crefDefinitionRanges(signed)).toEqual([[0, signed.length]]);
135
+ const at = (s, e) => ({ start: { offset: s }, end: { offset: e } });
136
+ const tree = (macro, after) => ({ content: [macro, ...after] });
137
+ expect(
138
+ crefDefinitionRanges("", {
139
+ parse: () =>
140
+ tree({ type: "macro", content: "Crefname", position: at(0, 9) }, [
141
+ { type: "group", content: [], position: at(9, 14) },
142
+ { type: "string", content: "x", position: at(14, 15) },
143
+ ]),
144
+ }),
145
+ ).toEqual([[0, 14]]);
146
+ // A node with no position has no range; an argument with no content ends nothing.
147
+ expect(
148
+ crefDefinitionRanges("", {
149
+ parse: () =>
150
+ tree(
151
+ {
152
+ type: "macro",
153
+ content: "crefname",
154
+ args: [{ type: "argument" }],
155
+ },
156
+ [],
157
+ ),
158
+ }),
159
+ ).toEqual([]);
160
+ });
161
+
162
+ it("…while a § in prose next to such a definition is still reported", async () => {
163
+ const src =
164
+ "\\documentclass{article}\n\\crefname{section}{§}{§§}\n\\begin{document}\nSee §5.\n\\end{document}\n";
165
+ expect(ids(await lint(src), "paper/section-word")).toHaveLength(1);
166
+ });
167
+
121
168
  it("markdown: `§5` → `Section 5`, code untouched", async () => {
122
169
  const src = "See §5 and `§3` here.\n";
123
170
  expect(ids(await lint(src, "paper.md"), "paper/section-word")).toHaveLength(
@@ -61,6 +61,7 @@
61
61
  */
62
62
  import assert from "node:assert/strict";
63
63
  import { getParser } from "@unified-latex/unified-latex-util-parse";
64
+ import { takeWhile } from "remeda";
64
65
  import type { Nodes } from "mdast";
65
66
  import type { TexArg, TexNode, TexRoot } from "./latex-language.ts";
66
67
  import type {
@@ -121,7 +122,77 @@ function skippedRanges(
121
122
  }
122
123
  const bib = bibRange(raw);
123
124
  if (bib) out.push([bib.start, bib.end]);
124
- return out;
125
+ return [...out, ...crefDefinitionRanges(raw)];
126
+ }
127
+
128
+ /**
129
+ * cleveref's name and format definitions (`\crefname{section}{§}{§§}`): the label a reference
130
+ * prints is set there, not written as prose, so a sign in them is not a sign in the text.
131
+ */
132
+ const CREF_DEFINITIONS: ReadonlySet<string> = new Set([
133
+ "crefname",
134
+ "Crefname",
135
+ "crefformat",
136
+ "Crefformat",
137
+ "crefrangeformat",
138
+ "Crefrangeformat",
139
+ "crefmultiformat",
140
+ "Crefmultiformat",
141
+ "crefrangemultiformat",
142
+ "Crefrangemultiformat",
143
+ ]);
144
+
145
+ const isGroup = (n: TexNode): boolean => n.type === "group";
146
+
147
+ /**
148
+ * A macro's range with its arguments: the ones the parser attached (cleveref's are signed) or the
149
+ * `{…}` groups standing right after it when it was given none — from the first to the last offset
150
+ * any of them carries. An argument's closing brace is one character past its last node.
151
+ */
152
+ function withArguments(
153
+ m: TexNode & { readonly type: "macro" },
154
+ after: readonly TexNode[],
155
+ ): readonly [number, number][] {
156
+ const offsets = [
157
+ m.position?.start.offset,
158
+ m.position?.end.offset,
159
+ ...(m.args ?? []).map((a) => {
160
+ const last = a.content?.at(-1)?.position?.end.offset;
161
+ return last === undefined ? undefined : last + 1;
162
+ }),
163
+ takeWhile(after, isGroup).at(-1)?.position?.end.offset,
164
+ ].filter((o): o is number => o !== undefined);
165
+ return offsets.length === 0
166
+ ? []
167
+ : [[Math.min(...offsets), Math.max(...offsets)]];
168
+ }
169
+
170
+ /**
171
+ * The ranges of the cleveref definitions in the preamble and in the document body. `parse` is
172
+ * unified-latex's; a test hands in a tree with the fields the walk tolerates being absent.
173
+ */
174
+ export function crefDefinitionRanges(
175
+ raw: string,
176
+ {
177
+ parse = (s: string): TexRoot => getParser().parse(s),
178
+ }: { readonly parse?: (s: string) => TexRoot } = {},
179
+ ): readonly [number, number][] {
180
+ const root = parse(raw);
181
+ const doc = root.content.find(
182
+ (n): n is Extract<TexNode, { type: "environment" | "mathenv" }> =>
183
+ n.type === "environment" && n.env === "document",
184
+ );
185
+ const lists: readonly (readonly TexNode[])[] = [
186
+ root.content,
187
+ doc?.content ?? [],
188
+ ];
189
+ return lists.flatMap((list) =>
190
+ list.flatMap((n, i) =>
191
+ n.type === "macro" && CREF_DEFINITIONS.has(n.content)
192
+ ? withArguments(n, list.slice(i + 1))
193
+ : [],
194
+ ),
195
+ );
125
196
  }
126
197
  const inside = (ranges: readonly [number, number][], i: number) =>
127
198
  ranges.some(([s, e]) => i >= s && i < e);