paperlint 6.2.3 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +10 -3
  3. package/dist/adapters/hotcrp/index.d.ts +7 -0
  4. package/dist/adapters/hotcrp/index.d.ts.map +1 -0
  5. package/dist/adapters/hotcrp/index.js +7 -0
  6. package/dist/adapters/hotcrp/index.js.map +1 -0
  7. package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
  8. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
  9. package/dist/adapters/hotcrp/portal.io.js +90 -0
  10. package/dist/adapters/hotcrp/portal.io.js.map +1 -0
  11. package/dist/adapters/hotcrp/response.d.ts +13 -0
  12. package/dist/adapters/hotcrp/response.d.ts.map +1 -0
  13. package/dist/adapters/hotcrp/response.js +138 -0
  14. package/dist/adapters/hotcrp/response.js.map +1 -0
  15. package/dist/adapters/latex/conditionals.d.ts +21 -0
  16. package/dist/adapters/latex/conditionals.d.ts.map +1 -0
  17. package/dist/adapters/latex/conditionals.js +75 -0
  18. package/dist/adapters/latex/conditionals.js.map +1 -0
  19. package/dist/adapters/latex/headings.d.ts +10 -0
  20. package/dist/adapters/latex/headings.d.ts.map +1 -0
  21. package/dist/adapters/latex/headings.js +129 -0
  22. package/dist/adapters/latex/headings.js.map +1 -0
  23. package/dist/adapters/latex/index.d.ts +1 -0
  24. package/dist/adapters/latex/index.d.ts.map +1 -1
  25. package/dist/adapters/latex/index.js +3 -0
  26. package/dist/adapters/latex/index.js.map +1 -1
  27. package/dist/adapters/latex/parse.d.ts +1 -1
  28. package/dist/adapters/latex/parse.d.ts.map +1 -1
  29. package/dist/adapters/latex/parse.js +3 -2
  30. package/dist/adapters/latex/parse.js.map +1 -1
  31. package/dist/build.d.ts +1 -1
  32. package/dist/build.d.ts.map +1 -1
  33. package/dist/build.js +1 -1
  34. package/dist/build.js.map +1 -1
  35. package/dist/cli.d.ts +4 -2
  36. package/dist/cli.d.ts.map +1 -1
  37. package/dist/cli.js +126 -37
  38. package/dist/cli.js.map +1 -1
  39. package/dist/domain/heading-case.d.ts +100 -0
  40. package/dist/domain/heading-case.d.ts.map +1 -0
  41. package/dist/domain/heading-case.js +365 -0
  42. package/dist/domain/heading-case.js.map +1 -0
  43. package/dist/domain/result.d.ts +4 -0
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +2 -0
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/domain/submission.d.ts +106 -0
  48. package/dist/domain/submission.d.ts.map +1 -0
  49. package/dist/domain/submission.js +39 -0
  50. package/dist/domain/submission.js.map +1 -0
  51. package/dist/domain/tex-document.d.ts +32 -0
  52. package/dist/domain/tex-document.d.ts.map +1 -1
  53. package/dist/domain/tex-document.js +11 -0
  54. package/dist/domain/tex-document.js.map +1 -1
  55. package/dist/domain/venue-name.d.ts +38 -0
  56. package/dist/domain/venue-name.d.ts.map +1 -0
  57. package/dist/domain/venue-name.js +71 -0
  58. package/dist/domain/venue-name.js.map +1 -0
  59. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  60. package/dist/eslint-rules/latex-language.js +16 -3
  61. package/dist/eslint-rules/latex-language.js.map +1 -1
  62. package/dist/facts-file.d.ts +3 -3
  63. package/dist/facts-file.d.ts.map +1 -1
  64. package/dist/facts-file.js +3 -3
  65. package/dist/facts-file.js.map +1 -1
  66. package/dist/folder-venue-rule.d.ts +51 -0
  67. package/dist/folder-venue-rule.d.ts.map +1 -0
  68. package/dist/folder-venue-rule.js +96 -0
  69. package/dist/folder-venue-rule.js.map +1 -0
  70. package/dist/heading-case.d.ts +48 -0
  71. package/dist/heading-case.d.ts.map +1 -0
  72. package/dist/heading-case.js +191 -0
  73. package/dist/heading-case.js.map +1 -0
  74. package/dist/init.d.ts +6 -0
  75. package/dist/init.d.ts.map +1 -1
  76. package/dist/init.js +3 -3
  77. package/dist/init.js.map +1 -1
  78. package/dist/lib/paper-config.d.ts.map +1 -1
  79. package/dist/lib/paper-config.js +5 -1
  80. package/dist/lib/paper-config.js.map +1 -1
  81. package/dist/new-paper.d.ts +19 -5
  82. package/dist/new-paper.d.ts.map +1 -1
  83. package/dist/new-paper.js +23 -4
  84. package/dist/new-paper.js.map +1 -1
  85. package/dist/paper-settings.d.ts +3 -0
  86. package/dist/paper-settings.d.ts.map +1 -1
  87. package/dist/paper-settings.js +20 -4
  88. package/dist/paper-settings.js.map +1 -1
  89. package/dist/ports/latex.d.ts +7 -1
  90. package/dist/ports/latex.d.ts.map +1 -1
  91. package/dist/ports/submission-portal.d.ts +19 -0
  92. package/dist/ports/submission-portal.d.ts.map +1 -0
  93. package/dist/ports/submission-portal.js +2 -0
  94. package/dist/ports/submission-portal.js.map +1 -0
  95. package/dist/preset-card-rule.d.ts +100 -0
  96. package/dist/preset-card-rule.d.ts.map +1 -0
  97. package/dist/preset-card-rule.js +221 -0
  98. package/dist/preset-card-rule.js.map +1 -0
  99. package/dist/presets.d.ts +43 -2
  100. package/dist/presets.d.ts.map +1 -1
  101. package/dist/presets.js +130 -63
  102. package/dist/presets.js.map +1 -1
  103. package/dist/rules-config.js +1 -1
  104. package/dist/submission.d.ts +61 -0
  105. package/dist/submission.d.ts.map +1 -0
  106. package/dist/submission.js +211 -0
  107. package/dist/submission.js.map +1 -0
  108. package/dist/tex-requirements.d.ts +36 -3
  109. package/dist/tex-requirements.d.ts.map +1 -1
  110. package/dist/tex-requirements.js +30 -2
  111. package/dist/tex-requirements.js.map +1 -1
  112. package/dist/tex-venue-rules.d.ts +7 -8
  113. package/dist/tex-venue-rules.d.ts.map +1 -1
  114. package/dist/tex-venue-rules.js +10 -14
  115. package/dist/tex-venue-rules.js.map +1 -1
  116. package/dist/types.d.ts +13 -0
  117. package/dist/types.d.ts.map +1 -1
  118. package/docs/configuration.md +30 -11
  119. package/docs/optional-rules.md +4 -3
  120. package/docs/rules/format/page-limit.md +1 -1
  121. package/docs/rules/paper/folder-venue-leftover.md +104 -0
  122. package/docs/rules/talk/captions-cover.md +1 -1
  123. package/docs/rules/talk/duration-floor.md +1 -1
  124. package/docs/rules/talk/profile.md +1 -1
  125. package/docs/rules/tex/heading-case.md +424 -0
  126. package/docs/rules.md +64 -53
  127. package/docs/skills.md +5 -4
  128. package/docs/submission.md +64 -0
  129. package/docs/talk.md +1 -1
  130. package/eslint-rules/latex-language.test.mjs +12 -0
  131. package/eslint-rules/latex-language.ts +17 -4
  132. package/eslint-rules/paper-typography.test.mjs +12 -0
  133. package/eslint.config.mjs +59 -1
  134. package/fixtures/build-e2e/acmart/paper.tex +1 -1
  135. package/fixtures/build-e2e/fallback/expect.json +3 -3
  136. package/lib/paper-config.ts +6 -1
  137. package/lib/skill-checks.d.mts +7 -0
  138. package/lib/skill-checks.mjs +31 -0
  139. package/lib/skill-trigger-cases.mjs +11 -0
  140. package/package.json +8 -2
  141. package/presets/acm-sigconf.jsonc +5 -0
  142. package/presets/acm-sigconf.md +43 -0
  143. package/presets/agenticdev.jsonc +27 -6
  144. package/presets/agenticdev.md +151 -0
  145. package/presets/aidc.jsonc +12 -0
  146. package/presets/aidc.md +75 -0
  147. package/presets/aisec.jsonc +5 -1
  148. package/presets/aisec.md +117 -0
  149. package/presets/ieee-conference.jsonc +5 -0
  150. package/presets/ieee-conference.md +38 -0
  151. package/presets/realm.jsonc +7 -2
  152. package/presets/realm.md +166 -0
  153. package/presets/tex/agenticdev.tex +1 -1
  154. package/presets/tex-base.jsonc +6 -0
  155. package/presets/tex-base.md +30 -0
  156. package/presets/venue-profile.schema.json +57 -2
  157. package/skills/find-venue/SKILL.md +6 -5
  158. package/skills/find-venue/SKILL.md.spec.ts +5 -4
  159. package/skills/paper-pipeline/SKILL.md +5 -6
  160. package/skills/paper-pipeline/SKILL.md.spec.ts +4 -5
  161. package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
  162. package/skills/paper-pipeline/references/acl-venue-rules.md +1 -1
  163. package/skills/study-accepted-papers/SKILL.md +3 -3
  164. package/skills/study-accepted-papers/SKILL.md.spec.ts +2 -2
  165. package/skills/submission-portal/SKILL.md +69 -0
  166. package/skills/submission-portal/SKILL.md.spec.ts +71 -0
  167. package/skills/submission-portal/submission-portal.eval.mjs +15 -0
  168. package/skills/submission-portal/submission-portal.test.ts +81 -0
  169. package/skills/submit-paper/SKILL.md +27 -22
  170. package/skills/submit-paper/SKILL.md.spec.ts +26 -21
  171. package/skills/submit-paper/references/publishers/acm.md +1 -1
  172. package/src/adapters/hotcrp/cassette/README.md +24 -0
  173. package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
  174. package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
  175. package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
  176. package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
  177. package/src/adapters/hotcrp/index.ts +11 -0
  178. package/src/adapters/hotcrp/portal.io.ts +130 -0
  179. package/src/adapters/hotcrp/portal.test.ts +503 -0
  180. package/src/adapters/hotcrp/response.ts +181 -0
  181. package/src/adapters/latex/conditionals.ts +130 -0
  182. package/src/adapters/latex/headings.ts +163 -0
  183. package/src/adapters/latex/index.test.ts +65 -0
  184. package/src/adapters/latex/index.ts +3 -0
  185. package/src/adapters/latex/parse.ts +3 -2
  186. package/src/build.ts +2 -2
  187. package/src/cli.test.ts +155 -3
  188. package/src/cli.ts +161 -25
  189. package/src/domain/heading-case.test.ts +660 -0
  190. package/src/domain/heading-case.ts +538 -0
  191. package/src/domain/result.test.ts +9 -1
  192. package/src/domain/result.ts +5 -0
  193. package/src/domain/submission.test.ts +45 -0
  194. package/src/domain/submission.ts +141 -0
  195. package/src/domain/tex-document.ts +36 -0
  196. package/src/domain/venue-name.test.ts +121 -0
  197. package/src/domain/venue-name.ts +129 -0
  198. package/src/facts-file.test.ts +4 -4
  199. package/src/facts-file.ts +6 -6
  200. package/src/folder-venue-rule.test.ts +182 -0
  201. package/src/folder-venue-rule.ts +141 -0
  202. package/src/heading-case.test.ts +500 -0
  203. package/src/heading-case.ts +275 -0
  204. package/src/init.test.ts +22 -0
  205. package/src/init.ts +9 -2
  206. package/src/new-paper.harness.mjs +11 -9
  207. package/src/new-paper.test.ts +135 -10
  208. package/src/new-paper.ts +38 -7
  209. package/src/paper-settings-commands.test.ts +25 -10
  210. package/src/paper-settings.test.ts +12 -1
  211. package/src/paper-settings.ts +25 -5
  212. package/src/parse-args.test.ts +19 -0
  213. package/src/ports/latex.ts +7 -0
  214. package/src/ports/submission-portal.ts +26 -0
  215. package/src/preset-card-rule.test.ts +266 -0
  216. package/src/preset-card-rule.ts +384 -0
  217. package/src/presets.test.ts +212 -37
  218. package/src/presets.ts +183 -71
  219. package/src/rule-cases.test.ts +147 -42
  220. package/src/rules-config.ts +1 -1
  221. package/src/submission.test.ts +497 -0
  222. package/src/submission.ts +311 -0
  223. package/src/talk-rules.test.ts +6 -3
  224. package/src/tex-requirements.harness.ts +25 -11
  225. package/src/tex-requirements.test.ts +127 -1
  226. package/src/tex-requirements.ts +78 -8
  227. package/src/tex-venue-rules.test.ts +36 -22
  228. package/src/tex-venue-rules.ts +14 -26
  229. package/src/types.ts +13 -0
  230. package/src/venue-rules.test.ts +7 -6
  231. package/templates/paper/paper.tex +1 -1
  232. package/skills/submit-paper/references/venues/agenticdev.md +0 -171
  233. package/skills/submit-paper/references/venues/aisec.md +0 -105
  234. package/skills/submit-paper/references/venues/realm.md +0 -155
package/docs/rules.md CHANGED
@@ -3,47 +3,49 @@
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/section-word` | warn | `paper.tex` and its body files, `paper.md` | `§` or `\S\ref` instead of the word Section. **Fixable:** `--fix` writes `Section~\ref{…}` / `Section 5` |
12
- | `paper/leading-zero` | warn | `paper.tex` and its body files, `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** |
13
- | `paper/figure-ref-style` | warn | `paper.tex` and its body files | `Fig.~\ref` and `Figure~\ref` mixed in one document; each minority occurrence is reported. **Fixable:** `--fix` writes the majority form |
14
- | `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 |
15
- | `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 |
16
- | `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` |
17
- | `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 |
18
- | `tex/future-promise` | warn | `paper.tex` and its body files | a camera-ready build still says "will be released" about something already handed over |
19
- | `tex/acm-frontmatter-override` | error | `paper.tex` | an `acmart` build overrides ACM's front-matter commands and drops template elements from page 1 |
20
- | [`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) |
21
- | [`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) |
22
- | [`format/layout-override`](rules/format/layout-override.md) | error | `paper.tex` → `paperlint.json` | the source changes the page layout the venue's template sets for the whole document — `geometry`, a layout `\setlength`, `\linespread`, `\baselinestretch` — [page](rules/format/layout-override.md) |
23
- | [`anonymity/identity`](rules/anonymity/identity.md) | error | `paper.tex` → `_build/paper.facts.json` | a double-blind venue (`"blind": true`) and no `identity` declared, or a declared name, handle, email or project in the PDF's text, metadata or link targets — [page](rules/anonymity/identity.md) |
24
- | [`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) |
25
- | [`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) |
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
- | [`tex/contrast-frames`](rules/tex/contrast-frames.md) | warn | `paper.tex` and the files it includes → `paperlint.json` | contrast frames (_X, not Y_, _not X but Y_, _rather than_, _instead of_, _as opposed to_) per 10,000 words fall outside the band the venue preset's anchors set (AIDC: 0–10.4, from five accepted ACSAC papers); above it, each frame is reported too — [page](rules/tex/contrast-frames.md) |
28
- | [`tex/claim-emphasis`](rules/tex/claim-emphasis.md) | warn | `paper.tex` and the files it includes → `paperlint.json` | claims set in bold inside a sentence (six words or more, or holding a number; not a term, not a label opening a paragraph) per 10,000 words fall outside the preset's band (AIDC: 0–17.8); above it, each is reported too — [page](rules/tex/claim-emphasis.md) |
29
- | [`tex/relation-markers`](rules/tex/relation-markers.md) | warn | `paper.tex` and the files it includes → `paperlint.json` | words that name how a sentence relates to the one before (_because_, _therefore_, _however_, _for example_ …) per 10,000 words fall outside the preset's band (AIDC: 25.4–135.0) — the floor that catches relations left to infer — [page](rules/tex/relation-markers.md) |
30
- | `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 |
31
- | `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`) |
32
- | `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 3, or they describe a PDF that is gone or differs from the one on disk (its SHA-256) |
33
- | `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 |
34
- | `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`) |
35
- | `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 |
36
- | [`format/page-limit`](rules/format/page-limit.md) | error | `paper.tex` → `_build/paper.facts.json` | body or reference pages exceed the limits of the paper's kind. With `body_ends_at: "references"` (AIDC) the body ends at the references or the appendix, whichever comes first — found by hyperref's anchors, then the heading line — and the page it ends on counts when body text stands above the heading; an end it cannot find, or signals that disagree, are reported — [page](rules/format/page-limit.md) |
37
- | [`pdf/body-size`](rules/pdf/body-size.md) | error | `paper.tex` → `_build/paper.facts.json` | the body font size is more than `body_pt_tol` off the preset's, or the reference font size is outside the preset's range widened by `body_pt_tol`. banal reads 0.3 pt above the size the glyphs are set in; checked on five built accepted papers (none reported) and three variants one size off (each reported) — [page](rules/pdf/body-size.md) |
38
- | `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 |
39
- | [`talk/profile`](rules/talk/profile.md) | error | `paper.tex` → `paperlint.json` | the paper declares `talk` and names no preset, the preset has no `talk` block, the mode is not one the venue allows, or the venue sets no talk slot for the paper's kind — [page](rules/talk/profile.md) |
40
- | [`talk/required-files`](rules/talk/required-files.md) | error | `paper.tex` → `paperlint.json`, the talk folder | a file the venue requires for the paper's talk mode (video, one-slide, captions…) is not in the talk folder — [page](rules/talk/required-files.md) |
41
- | [`talk/undeclared`](rules/talk/undeclared.md) | warn | `paper.tex` → the paper's `talk/` folder | an `.mp4` sits in `talk/` and the paper declares no `talk`, so nothing checks it — [page](rules/talk/undeclared.md) |
42
- | [`talk/duration`](rules/talk/duration.md) | error | `paper.tex` → the talk video | the video runs longer than the slot's `talk_s_max`, or its audio track is more than 1 s off the video's length — [page](rules/talk/duration.md) |
43
- | [`talk/duration-floor`](rules/talk/duration-floor.md) | warn | `paper.tex` → the talk video | the video runs shorter than the slot's `talk_s_min` — [page](rules/talk/duration-floor.md) |
44
- | [`talk/video-format`](rules/talk/video-format.md) | error | `paper.tex` → the talk video | the video is not ISO media, is QuickTime where the venue asks for MP4, is under `min_height_px`, or over `max_bytes` — [page](rules/talk/video-format.md) |
45
- | [`talk/one-slide-size`](rules/talk/one-slide-size.md) | error | `paper.tex` → the one-slide PNG | the one-slide image is not the size the paper or the preset states, or not a PNG — [page](rules/talk/one-slide-size.md) |
46
- | [`talk/captions-cover`](rules/talk/captions-cover.md) | warn | `paper.tex` → the captions and the video | the captions start late, end early or leave a long gap (uncalibrated thresholds) — [page](rules/talk/captions-cover.md) |
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/folder-venue-leftover`](rules/paper/folder-venue-leftover.md) | warn | the folder name → `paperlint.json` | the paper's folder is named after another shipped venue than the one it extends (`papers/aisec-2026/` for a paper now set for AIDC); reported on `PIPELINE-STATUS.md` — [page](rules/paper/folder-venue-leftover.md) |
11
+ | `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 |
12
+ | `paper/section-word` | warn | `paper.tex` and its body files, `paper.md` | `§` or `\S\ref` instead of the word Section. **Fixable:** `--fix` writes `Section~\ref{…}` / `Section 5` |
13
+ | `paper/leading-zero` | warn | `paper.tex` and its body files, `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` and its body files | `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` and its body files | 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/heading-case`](rules/tex/heading-case.md) | optional; the AgenticDev preset sets `error` | `paper.tex` and the files it includes | a word in the paper's `\title` or in a `\section`, `\subsection`, `\subsubsection`, `\paragraph` or `\subparagraph` title is not in the style its scope asks: `{ "title", "headings", "levels"? }`, each `chicago-headline` (Conference Publishing's quote of the Chicago Manual of Style), `sentence` (first word only) or `any`. Words that are a preposition in one title and a particle in another (`up`, `on`, `in`) are not read mid-title. **Fixable:** `--fix` — [page](rules/tex/heading-case.md) |
24
+ | [`format/layout-override`](rules/format/layout-override.md) | error | `paper.tex` → `paperlint.json` | the source changes the page layout the venue's template sets for the whole document — `geometry`, a layout `\setlength`, `\linespread`, `\baselinestretch` — [page](rules/format/layout-override.md) |
25
+ | [`anonymity/identity`](rules/anonymity/identity.md) | error | `paper.tex` → `_build/paper.facts.json` | a double-blind venue (`"blind": true`) and no `identity` declared, or a declared name, handle, email or project in the PDF's text, metadata or link targets — [page](rules/anonymity/identity.md) |
26
+ | [`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) |
27
+ | [`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) |
28
+ | [`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) |
29
+ | [`tex/contrast-frames`](rules/tex/contrast-frames.md) | warn | `paper.tex` and the files it includes → `paperlint.json` | contrast frames (_X, not Y_, _not X but Y_, _rather than_, _instead of_, _as opposed to_) per 10,000 words fall outside the band the venue preset's anchors set (AIDC: 0–10.4, from five accepted ACSAC papers); above it, each frame is reported too — [page](rules/tex/contrast-frames.md) |
30
+ | [`tex/claim-emphasis`](rules/tex/claim-emphasis.md) | warn | `paper.tex` and the files it includes → `paperlint.json` | claims set in bold inside a sentence (six words or more, or holding a number; not a term, not a label opening a paragraph) per 10,000 words fall outside the preset's band (AIDC: 0–17.8); above it, each is reported too — [page](rules/tex/claim-emphasis.md) |
31
+ | [`tex/relation-markers`](rules/tex/relation-markers.md) | warn | `paper.tex` and the files it includes → `paperlint.json` | words that name how a sentence relates to the one before (_because_, _therefore_, _however_, _for example_ …) per 10,000 words fall outside the preset's band (AIDC: 25.4–135.0) — the floor that catches relations left to infer — [page](rules/tex/relation-markers.md) |
32
+ | `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 |
33
+ | `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`) |
34
+ | `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 3, or they describe a PDF that is gone or differs from the one on disk (its SHA-256) |
35
+ | `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 |
36
+ | `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`) |
37
+ | `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 |
38
+ | [`format/page-limit`](rules/format/page-limit.md) | error | `paper.tex` → `_build/paper.facts.json` | body or reference pages exceed the limits of the paper's kind. With `body_ends_at: "references"` (AIDC) the body ends at the references or the appendix, whichever comes first — found by hyperref's anchors, then the heading line — and the page it ends on counts when body text stands above the heading; an end it cannot find, or signals that disagree, are reported — [page](rules/format/page-limit.md) |
39
+ | [`pdf/body-size`](rules/pdf/body-size.md) | error | `paper.tex` → `_build/paper.facts.json` | the body font size is more than `body_pt_tol` off the preset's, or the reference font size is outside the preset's range widened by `body_pt_tol`. banal reads 0.3 pt above the size the glyphs are set in; checked on five built accepted papers (none reported) and three variants one size off (each reported) — [page](rules/pdf/body-size.md) |
40
+ | `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 |
41
+ | [`talk/profile`](rules/talk/profile.md) | error | `paper.tex` → `paperlint.json` | the paper declares `talk` and names no preset, the preset has no `talk` block, the mode is not one the venue allows, or the venue sets no talk slot for the paper's kind — [page](rules/talk/profile.md) |
42
+ | [`talk/required-files`](rules/talk/required-files.md) | error | `paper.tex` → `paperlint.json`, the talk folder | a file the venue requires for the paper's talk mode (video, one-slide, captions…) is not in the talk folder — [page](rules/talk/required-files.md) |
43
+ | [`talk/undeclared`](rules/talk/undeclared.md) | warn | `paper.tex` → the paper's `talk/` folder | an `.mp4` sits in `talk/` and the paper declares no `talk`, so nothing checks it — [page](rules/talk/undeclared.md) |
44
+ | [`talk/duration`](rules/talk/duration.md) | error | `paper.tex` → the talk video | the video runs longer than the slot's `talk_s_max`, or its audio track is more than 1 s off the video's length — [page](rules/talk/duration.md) |
45
+ | [`talk/duration-floor`](rules/talk/duration-floor.md) | warn | `paper.tex` → the talk video | the video runs shorter than the slot's `talk_s_min` — [page](rules/talk/duration-floor.md) |
46
+ | [`talk/video-format`](rules/talk/video-format.md) | error | `paper.tex` → the talk video | the video is not ISO media, is QuickTime where the venue asks for MP4, is under `min_height_px`, or over `max_bytes` — [page](rules/talk/video-format.md) |
47
+ | [`talk/one-slide-size`](rules/talk/one-slide-size.md) | error | `paper.tex` → the one-slide PNG | the one-slide image is not the size the paper or the preset states, or not a PNG — [page](rules/talk/one-slide-size.md) |
48
+ | [`talk/captions-cover`](rules/talk/captions-cover.md) | warn | `paper.tex` → the captions and the video | the captions start late, end early or leave a long gap (uncalibrated thresholds) — [page](rules/talk/captions-cover.md) |
47
49
 
48
50
  **Body files** are the files `paper.tex` includes from its document body (`\input`, `\include`,
49
51
  `\subfile`, nested ones too), found in the paper's directory — not a preamble include, not a file
@@ -60,8 +62,8 @@ The `talk/*` rules judge a talk's finished files and are silent for a paper that
60
62
  `talk` (but `talk/undeclared`): [`talk.md`](talk.md).
61
63
 
62
64
  Optional rules — off unless you turn them on in the `rules` setting, because only some venues need
63
- them — are on their own page: [`optional-rules.md`](optional-rules.md). Today there is one,
64
- `pdf/last-page-balance`.
65
+ them — are on their own page: [`optional-rules.md`](optional-rules.md). Today there are two,
66
+ `pdf/last-page-balance` and `tex/heading-case`.
65
67
 
66
68
  ## A deliberate exception: a disable directive, with the reason
67
69
 
@@ -110,14 +112,14 @@ for papers, each with the quote it came from, the TeX packages its template need
110
112
  the venue implies. A preset may itself extend another: the ACM venues extend the `acm-sigconf`
111
113
  family, which holds everything the ACM template decides.
112
114
 
113
- | preset | extends | template | kinds | page limit checked | rules it turns on |
114
- | --------------------------- | --------------------------- | ------------------------------------- | -------------------------------------------------------- | -------------------------------------- | ----------------------- |
115
- | `paperlint:acm-sigconf` | — | ACM `acmart` sigconf | none | no (no kinds) | — |
116
- | `paperlint:agenticdev` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `short` (5 + 2 refs), `full` (10 + 2), `demo` (5 + 2) | yes | `pdf/last-page-balance` |
117
- | `paperlint:aisec` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `research`, `benchmark`, `position`, `sok` (10 + 2 each) | yes | — |
118
- | `paperlint:realm` | — | ACL | `long`, `short` | no — see below | — |
119
- | `paperlint:ieee-conference` | — | IEEE `IEEEtran` conference | none | no (no kinds) | — |
120
- | `paperlint:aidc` | `paperlint:ieee-conference` | IEEE `IEEEtran` conference, `compsoc` | `regular` (12), `short` (6) | yes, before the references — see below | — (`blind`) |
115
+ | preset | extends | template | kinds | page limit checked | rules it turns on |
116
+ | --------------------------- | --------------------------- | ------------------------------------- | -------------------------------------------------------- | -------------------------------------- | ------------------------------------------- |
117
+ | `paperlint:acm-sigconf` | — | ACM `acmart` sigconf | none | no (no kinds) | — |
118
+ | `paperlint:agenticdev` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `short` (5 + 2 refs), `full` (10 + 2), `demo` (5 + 2) | yes | `pdf/last-page-balance`, `tex/heading-case` |
119
+ | `paperlint:aisec` | `paperlint:acm-sigconf` | ACM `acmart` sigconf | `research`, `benchmark`, `position`, `sok` (10 + 2 each) | yes | — |
120
+ | `paperlint:realm` | — | ACL | `long`, `short` | no — see below | — |
121
+ | `paperlint:ieee-conference` | — | IEEE `IEEEtran` conference | none | no (no kinds) | — |
122
+ | `paperlint:aidc` | `paperlint:ieee-conference` | IEEE `IEEEtran` conference, `compsoc` | `regular` (12), `short` (6) | yes, before the references — see below | — (`blind`) |
121
123
 
122
124
  That is all that ships today. There is no NeurIPS, USENIX or Springer preset. A paper for an ACM or
123
125
  IEEE conference venue nobody has profiled can extend `paperlint:acm-sigconf` or
@@ -193,7 +195,10 @@ papers/
193
195
  ```jsonc
194
196
  // venues/usenix-sec.jsonc
195
197
  {
198
+ // a venue says so, names itself, and links the call for papers its numbers are quoted from
199
+ "type": "venue",
196
200
  "name": "USENIX Security",
201
+ "url": "https://www.usenix.org/conference/usenixsecurity27/call-for-papers",
197
202
  // a standalone preset needs its template's TeX packages; one that extends a family inherits them
198
203
  "template": "article",
199
204
  // TeX Live packages only, each with a file that proves it is installed. The USENIX style file
@@ -218,10 +223,13 @@ The numbers and packages above illustrate the shape; take yours from the venue's
218
223
 
219
224
  | key | what it is |
220
225
  | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
226
+ | `type` | what the file is, required: `venue` (one venue's call for papers), `family` (a publisher's template that venues extend; it names no venue, so it has no `name` and no `url`), or `base` (the TeX base set, `tex` only, extended by nothing). A family may extend only a family; a venue a family or another venue |
221
227
  | `extends` | the preset this one builds on: `paperlint:<name>` or `./path` / `../path`, relative to this file |
222
- | `name` | how messages name the venue; the file name otherwise |
228
+ | `name` | a venue's name, required on a venue: how messages name it, and one of the names `tex/venue-leftover` looks for. A family's messages use its file name |
229
+ | `url` | a venue's call for papers or author instructions (`https://`), required on a venue — the page its numbers are quoted from |
223
230
  | `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 |
224
- | `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` |
231
+ | `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` and `paper/folder-venue-leftover` |
232
+ | `portal` | where the venue takes submissions, `{ "kind": "hotcrp", "url": "https://…" }` — read by [`paperlint submission`](submission.md); a child's value replaces its parent's |
225
233
  | `blind` | the venue reviews double-blind — set only where its call says so, with the quote. Turns on `anonymity/identity`; a child's value replaces its parent's |
226
234
  | `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 |
227
235
  | `format` | page size, columns, fonts, font sizes, and `kinds` (page limits per kind of paper) |
@@ -235,6 +243,9 @@ package; `format` keys are replaced one by one, and a child's kind replaces that
235
243
  presets long, and a cycle is refused by name. `paperlint toolchain` installs the packages of every
236
244
  shipped preset and of every preset your papers extend.
237
245
 
246
+ Keep a card beside your preset, `venues/usenix-sec.md`, for what the call says that no rule
247
+ checks — as the shipped presets do (`presets/<name>.md`, [configuration](configuration.md)).
248
+
238
249
  Presets from npm packages are not supported yet.
239
250
 
240
251
  ## The paper is LaTeX
package/docs/skills.md CHANGED
@@ -62,10 +62,11 @@ the skill whose description matches starts. `paperlint init` installs them into
62
62
 
63
63
  ## 6 · Submit
64
64
 
65
- | you get | skill |
66
- | ------------------------------------------------------------------- | --------------------- |
67
- | the submission, step by step: anonymization, the form, the artifact | `submit-paper` |
68
- | the reproduction artifact uploaded to OSF for anonymous review | `osf-artifact-upload` |
65
+ | you get | skill |
66
+ | ------------------------------------------------------------------------------ | --------------------- |
67
+ | the submission, step by step: anonymization, the form, the artifact | `submit-paper` |
68
+ | the reproduction artifact uploaded to OSF for anonymous review | `osf-artifact-upload` |
69
+ | the PDF and abstract replaced on HotCRP, and proof the portal holds your build | `submission-portal` |
69
70
 
70
71
  ## 7 · Camera-ready
71
72
 
@@ -0,0 +1,64 @@
1
+ # The submission on the venue's portal
2
+
3
+ `paperlint submission` reads and updates a paper's submission on the venue's submission portal,
4
+ through the portal's API. Only [HotCRP](https://github.com/kohler/hotcrp) is supported.
5
+
6
+ ```sh
7
+ npx paperlint submission show papers/my-paper
8
+ npx paperlint submission update papers/my-paper # a dry run: nothing is saved
9
+ npx paperlint submission update papers/my-paper --save # changes the real submission
10
+ ```
11
+
12
+ ## What it needs
13
+
14
+ Two keys, each in the file that owns the fact, and a token:
15
+
16
+ | where | key | what it says |
17
+ | ---------------------------- | ---------------------------------------------------- | ----------------------------------------------- |
18
+ | the venue preset | `"portal": { "kind": "hotcrp", "url": "https://…" }` | where the venue takes submissions |
19
+ | the paper's `paperlint.json` | `"submission": { "id": 7 }` | which submission on that portal is this paper's |
20
+ | the environment | `HOTCRP_TOKEN` | an API token for your HotCRP account |
21
+
22
+ `paperlint:aidc` declares its portal. For another venue, add `portal` to your own preset (see
23
+ [`rules.md`](rules.md)) — or upload by hand: a kind paperlint has no adapter for is refused with
24
+ `portal kind "<kind>" is not supported; upload by hand`.
25
+
26
+ ```jsonc
27
+ // papers/my-paper/paperlint.json
28
+ { "extends": "paperlint:aidc", "kind": "regular", "submission": { "id": 7 } }
29
+ ```
30
+
31
+ **The token** is read only from `HOTCRP_TOKEN`. Create it on the HotCRP site under **Account
32
+ settings → Developer**; `show` needs the scopes `submeta:read` and `document:read`, `update` also
33
+ `submeta:write` and `document:write`. paperlint sends it as `Authorization: bearer <token>` and
34
+ never prints it or writes it anywhere.
35
+
36
+ A missing key or token stops the command with exit 2 and a line naming the file and key, or the
37
+ variable, to set.
38
+
39
+ ## `show`
40
+
41
+ Prints the submission's status (and when it was submitted), its id, title, paper type, topics, the
42
+ abstract's length in words, and the PDF the portal holds — its hash, size and upload time — then
43
+ whether that PDF is the local one: HotCRP stores a PDF's sha256 as `sha2-<hex>`, and paperlint
44
+ compares it with the sha256 of the paper's `paper.pdf` (or its `pdf` setting, or `--pdf <file>`).
45
+ `match yes` means the portal holds exactly the file you built. Exit 0 whenever the portal answered.
46
+
47
+ ## `update`
48
+
49
+ Sends the PDF — the paper's own unless `--pdf <file>` names another — and, when asked, a new
50
+ abstract (`--abstract <file>`, plain text) and the status "submitted" (`--submit`).
51
+
52
+ **Without `--save` it is a dry run**: HotCRP's `dry_run=1` checks the change and keeps nothing. With
53
+ `--save` the real submission changes. Either way it prints HotCRP's verdict — `valid`, the fields the
54
+ change touched (`change_list`), HotCRP's messages — and the local file's sha256. Exit 1 when the
55
+ change is not valid or the HTTP status is not 2xx.
56
+
57
+ The safe order: `show`, `update` (dry run), read what it says, `update --save`, then `show` again and
58
+ check `match yes`.
59
+
60
+ ## What it does not do
61
+
62
+ - It does not create a submission, edit authors, topics or other form fields, or upload
63
+ supplementary files.
64
+ - It does not check the PDF against the venue; that is `paperlint lint`.
package/docs/talk.md CHANGED
@@ -41,7 +41,7 @@ A video named after the paper is declared once:
41
41
  ```json
42
42
  "talk": {
43
43
  "mode": "remote-video",
44
- "files": { "video": "agenticdev-2026-talk.mp4", "captions": "agenticdev-2026-talk.srt" }
44
+ "files": { "video": "rule-drift-talk.mp4", "captions": "rule-drift-talk.srt" }
45
45
  }
46
46
  ```
47
47
 
@@ -204,3 +204,15 @@ test("a source code made from a parse result without the raw text reads the proj
204
204
  [true, undefined, true],
205
205
  );
206
206
  });
207
+
208
+ test("🔴 a heading written over several lines keeps every newline, so the projection's lines are the source's", () => {
209
+ const src = "\\section{Related\nwork in\nthe field}\nText.\n";
210
+ const { text } = texToMdast(src);
211
+ // Guards: a line number computed on the projection (ESLint's getLocFromIndex) is the source's.
212
+ assert.equal(text.length, src.length);
213
+ assert.deepEqual(
214
+ [...text].flatMap((c, i) => (c === "\n" ? [i] : [])),
215
+ [...src].flatMap((c, i) => (c === "\n" ? [i] : [])),
216
+ );
217
+ assert.equal(text.split("\n")[3], "Text.");
218
+ });
@@ -567,10 +567,23 @@ export function texToMdast(
567
567
  // project to ATX: `##` where `\se` was, the rest spaces; the closing `}` too
568
568
  blank(pos.start.offset, end.offset + 1);
569
569
  for (let i = 0; i < depth; i++) chars[pos.start.offset + i] = "#";
570
- const tStart = end.offset - title.length;
571
- title.split("").forEach((ch, i) => {
572
- chars[tStart + i] = ch;
573
- });
570
+ // 🔴 The title is written right-aligned against the closing `}`, into the cells that are
571
+ // not a newline: a heading written over several lines keeps every `\n` where it was. The
572
+ // projection's lines must be the source's — ESLint turns an offset into a line on this
573
+ // text (`getLocFromIndex`), and a rule that reports a raw offset after a two-line heading
574
+ // was reported one line up, outside the reach of a disable directive. Measured 2026-10-04.
575
+ const cells = [...Array(end.offset - pos.start.offset - depth).keys()]
576
+ .map((k) => end.offset - 1 - k)
577
+ .filter((o) => src[o] !== "\n")
578
+ .slice(0, title.length)
579
+ .reverse();
580
+ const tStart = cells[0] ?? end.offset;
581
+ title
582
+ .slice(title.length - cells.length)
583
+ .split("")
584
+ .forEach((ch, i) => {
585
+ chars[nth(cells, i)] = ch;
586
+ });
574
587
  const tPos = { start: loc(tStart), end: loc(end.offset) };
575
588
  children.push({
576
589
  type: "heading",
@@ -250,6 +250,18 @@ describe("paper/leading-zero", () => {
250
250
  });
251
251
  });
252
252
 
253
+ describe("a finding after a heading written over several lines", () => {
254
+ it("🔴 is reported on its own line, with CRLF line ends too", async () => {
255
+ for (const nl of ["\n", "\r\n"]) {
256
+ const text = doc(
257
+ ["\\section{Related", "work}", "We use .05 here."].join(nl),
258
+ );
259
+ const [m] = ids(await lint(text), "paper/leading-zero");
260
+ expect([m?.line, m?.column]).toEqual([5, 8]);
261
+ }
262
+ });
263
+ });
264
+
253
265
  describe("paper/leading-zero — a number after ¶ or § is not a decimal (#102)", () => {
254
266
  // From a consumer's bibliography: `--fix` rewrote the paragraph number `¶¶.42` to `¶¶0.42`.
255
267
  const CITE =
package/eslint.config.mjs CHANGED
@@ -21,7 +21,13 @@ import tseslint from "typescript-eslint";
21
21
  import boundaries from "eslint-plugin-boundaries";
22
22
  import functional from "eslint-plugin-functional";
23
23
  import sonarjs from "eslint-plugin-sonarjs";
24
- import { dirname } from "node:path";
24
+ import {
25
+ projectStructureParser,
26
+ projectStructurePlugin,
27
+ } from "eslint-plugin-project-structure";
28
+ import { presetCardRules } from "#src/preset-card-rule";
29
+ import { nodeFiles } from "#src/adapters/node/index";
30
+ import { dirname, join } from "node:path";
25
31
  import { fileURLToPath } from "node:url";
26
32
 
27
33
  // The complexity set, shared by the TypeScript block and the ratchet below. Every function
@@ -407,6 +413,57 @@ export const IO_GLOBALS = {
407
413
  },
408
414
  };
409
415
 
416
+ /**
417
+ * EVERY PRESET HAS A CARD, AND EVERY CARD'S RULES TABLE IS THE PRESET'S. Two checks, one block each:
418
+ *
419
+ * - `project-structure/folder-structure` (its `enforceExistence`): each `presets/<name>.jsonc` needs
420
+ * `presets/<name>.md` beside it, and is named in kebab case. Its rule listens for a `Program` node,
421
+ * so the presets are read with the plugin's own parser, which gives every file an empty one.
422
+ * Measured 2026-10-04: under `@eslint/json`'s `json/jsonc` language (root node `Document`) the same
423
+ * rule never ran — RC 0 with seven cards missing.
424
+ * - `preset/card-rules` (src/preset-card-rule.ts): the section between the card's markers is the
425
+ * resolved preset — what it is, its call for papers, its rules and where each was set. `--fix`
426
+ * writes it.
427
+ *
428
+ * `root` is a parameter only so a test can lint a copy of the presets: the plugin resolves
429
+ * `structureRoot` against `projectRoot`, and the card rule reads the presets and docs under it.
430
+ * On an error the plugin writes `projectStructure.cache.json` into `projectRoot` (gitignored).
431
+ */
432
+ export const presetCards = (root) => [
433
+ {
434
+ files: ["presets/*.jsonc"],
435
+ plugins: { "project-structure": projectStructurePlugin },
436
+ languageOptions: { parser: projectStructureParser },
437
+ rules: {
438
+ "project-structure/folder-structure": [
439
+ "error",
440
+ {
441
+ projectRoot: root,
442
+ structureRoot: "presets",
443
+ structure: [
444
+ { name: "{kebab-case}.jsonc", enforceExistence: "{node-name}.md" },
445
+ ],
446
+ },
447
+ ],
448
+ },
449
+ },
450
+ {
451
+ files: ["presets/*.md"],
452
+ plugins: {
453
+ markdown,
454
+ preset: {
455
+ rules: presetCardRules({
456
+ files: nodeFiles,
457
+ venuesDir: join(root, "presets"),
458
+ docsRoot: root,
459
+ }),
460
+ },
461
+ },
462
+ language: "markdown/gfm",
463
+ rules: { "preset/card-rules": "error" },
464
+ },
465
+ ];
466
+
410
467
  const ceiling = (rule, n) =>
411
468
  rule === "max-lines-per-function"
412
469
  ? ["error", { ...MAX_LINES, max: n }]
@@ -667,6 +724,7 @@ export default [
667
724
  languageOptions: { frontmatter: "yaml" },
668
725
  rules: { "port/md-install-path": "warn" },
669
726
  },
727
+ ...presetCards(dirname(fileURLToPath(import.meta.url))),
670
728
  {
671
729
  files: ["**/*.tex"],
672
730
  plugins: {
@@ -3,7 +3,7 @@
3
3
  % without it the fixture would fail for a reason unrelated to the subject of the test.
4
4
  \documentclass[sigconf,nonacm]{acmart}
5
5
  \begin{document}
6
- \title{A fixture that must come out in the class's own fonts}
6
+ \title{A Fixture That Must Come Out in the Class's Own Fonts}
7
7
  \author{A. Author}
8
8
  \affiliation{\institution{X University}\city{Almaty}\country{Kazakhstan}}
9
9
  \maketitle
@@ -32,19 +32,19 @@
32
32
  {
33
33
  "severity": 2,
34
34
  "says": [
35
- "page width is 8.264 in, agenticdev requires 8.5 in"
35
+ "page width is 8.264 in, AgenticDev requires 8.5 in"
36
36
  ]
37
37
  },
38
38
  {
39
39
  "severity": 2,
40
40
  "says": [
41
- "page height is 11.694 in, agenticdev requires 11 in"
41
+ "page height is 11.694 in, AgenticDev requires 11 in"
42
42
  ]
43
43
  },
44
44
  {
45
45
  "severity": 2,
46
46
  "says": [
47
- "column(s), agenticdev requires 2"
47
+ "column(s), AgenticDev requires 2"
48
48
  ]
49
49
  }
50
50
  ]