paperlint 2.1.0 → 3.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 (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -1,129 +1,111 @@
1
1
  /**
2
- * `paper/typography` — four MECHANICAL conventions a reviewer already complained about,
3
- * counted against a declared debt so that a legacy paper does not drown the new finding.
2
+ * Three mechanical conventions a reviewer already raised, ONE RULE EACH, reported where each
3
+ * occurrence is and fixed by `paperlint lint --fix`:
4
4
  *
5
- * ── PROVENANCE: every one is a real review finding, not an invented style ───────────────
6
- * From HotCRP #20 (2026-08-23). Reviewer B listed three as literal to-dos, Reviewer A the
7
- * fourth:
8
- * B#11 "§ -> Section" one paper in the source corpus carried 246
9
- * B#12 ".05 -> 0.05" four in the submitted text, fixed by hand
10
- * A#2 "not every entry has a DOI/link" one paper: 0 of 47 entries reachable
11
- * (own) `Fig.` and `Figure` side by side
5
+ * paper/section-word `§5`, `\S\ref{…}` → `Section 5`, `Section~\ref{…}`
6
+ * paper/leading-zero `.05` → `0.05`
7
+ * paper/figure-ref-style `Fig.~\ref` beside `Figure~\ref` in one document → the majority form
12
8
  *
13
- * ── RATCHET, and it is the load-bearing part ───────────────────────────────────────────
14
- * 🔴 A check that reports 246 findings on its first run is a check that gets muted, and the
15
- * next REAL instance then hides among the old ones. The consumer's repo has already killed a
16
- * check exactly that way. So the rule takes a `debt` option — what each paper owes today —
17
- * and speaks only when a paper is ABSENT from it (a new paper owes nothing) or when its count
18
- * GREW. Lowering a count is free and silently re-baselines.
9
+ * The fourth check of the old `paper/typography`, an unreachable bibliography entry, is
10
+ * `bib/reachable-entry` (bib-reachable-entry.mjs): it is about the bibliography, not the prose,
11
+ * and it has no fix.
19
12
  *
20
- * The debt itself is DATA about one corpus, so it does not live here: the consumer passes it
21
- * in options. The mechanism is general, the numbers are not.
13
+ * ── PROVENANCE: every one is a real review finding, not an invented style ───────────────
14
+ * From HotCRP #20 (2026-08-23). Reviewer B listed two as literal to-dos:
15
+ * B#11 "§ -> Section" one paper in the source corpus carried 246
16
+ * B#12 ".05 -> 0.05" four in the submitted text, fixed by hand
17
+ * (own) `Fig.` and `Figure` side by side
22
18
  *
23
- * ── WHY THREE COUNTS READ `sourceCode.raw` AND NOT THE PROSE PROJECTION ────────────────
24
- * `sectionSign`, `figMixed` and `unreachable` are about MARKUP. The projection blanks macros —
25
- * that is its purpose, it is what lets thirty prose rules read LaTeX without knowing LaTeX — so
26
- * `\S\ref{}` and `Fig.~\ref{}` reach a normal rule as spaces. Offsets coincide between the
27
- * two (blanking preserves length), so scanning `raw` and reporting the offset found there
28
- * still points at the right byte.
19
+ * ── WHY THERE IS NO DEBT OPTION ANY MORE (3.0.0) ──────────────────────────────────────────
20
+ * Until 3.0.0 this was ONE rule, `paper/typography`, that counted all four per paper against a
21
+ * declared `debt` and spoke only when a count grew. A ratchet was needed because the rule could
22
+ * only COUNT: 246 section signs meant 246 edits by hand, so the only way to turn it on was to
23
+ * freeze the backlog. Every check here now has a FIX, so the backlog costs one command
24
+ * (`paperlint lint --fix`), and the ratchet — per-paper numbers in the project config, keyed by
25
+ * a path — has nothing left to protect. A deliberate exception is an ESLint disable directive on
26
+ * the line (`% eslint-disable-next-line paper/section-word -- reason`), the standard escape
27
+ * hatch every ESLint user already knows, not a counter.
29
28
  *
30
- * ── WHY `bareDecimal` READS NEITHER, BUT THE PARSED TREE (paperlint#44) ─────────────────────────
31
- * Origin. On a workshop paper (HotCRP #20) Reviewer B wrote: `Numbers should be completed:
32
- * e.g. ".05" -> 0.05`. The submitted PDF really carried `p < .05`, `p=.002`, `p=.006` and
33
- * `p=.037` in its text (measured with pdftotext on the submitted version); the camera-ready
34
- * fixed them by hand.
29
+ * ── WHY section-word AND figure-ref-style READ `sourceCode.raw` ───────────────────────────
30
+ * They are about MARKUP. The prose projection of the LaTeX language blanks macros — that is its
31
+ * purpose — so `\S\ref{}` and `Fig.~\ref{}` reach a normal rule as spaces. Offsets coincide
32
+ * between the two (blanking preserves length), so scanning `raw` and reporting the offset found
33
+ * there points at the right byte. Comments and the inline bibliography are skipped.
35
34
  *
36
- * The convention. ISO 80000-1 ("the decimal sign shall be preceded by a zero" for magnitudes
37
- * below 1) and the IEEE Editorial Style Manual ("0.25, not .25") require the zero. Other styles
38
- * (APA, AMA) drop it for p-values. The rule measures against IEEE/ISO, because that is what the
39
- * venues this package targets expect.
35
+ * 🔴 BOTH forms of the section sign, and the second one is why this check was ever wrong. The
36
+ * first version counted only the literal `§` and returned ZERO on the very paper whose reviewer
37
+ * raised it: the source writes `\S\ref{sec:threats}`, which RENDERS as `§5`.
40
38
  *
41
- * Why parsed, not raw. The count used to be one regex over the raw source. An independent
42
- * review ran it on every real input available: 22 findings on the current corpus, 0 true
43
- * positives. That is survivorship — the real cases had been fixed by hand — and synthetic LaTeX
44
- * showed where the 22 come from: the regex fires on `[width=.48\columnwidth]`,
45
- * `p{.25\linewidth}`, comments, code listings and tikz coordinates. The projection is no
46
- * answer either: it blanks math (where p-values live) and whole tables (where they are
47
- * reported). So this count walks the TREE and keeps only what the reader sees as a number:
39
+ * ── WHY leading-zero READS THE PARSED TREE (paperlint#44) ──────────────────────────────────
40
+ * ISO 80000-1 ("the decimal sign shall be preceded by a zero" for magnitudes below 1) and the
41
+ * IEEE Editorial Style Manual ("0.25, not .25") require the zero. A regex over the raw source
42
+ * found 22 decimals on a real corpus and none was a defect: every one sat in markup (an option,
43
+ * a column spec, a comment, a listing, a tikz coordinate). So this walks the TREE and keeps only
44
+ * what the reader sees as a number:
48
45
  * LaTeX prose, inline and display math, table cells, and the text arguments of a known
49
- * set of text macros (`\emph`, `\caption`, `\footnote`, headings…). Not: any other
50
- * macro's arguments, environment arguments (column specs, widths), comments,
51
- * verbatim/listings/`\lstinline`, tikz, the preamble, the inline bibliography.
46
+ * set of text macros. Not: any other macro's arguments, environment arguments,
47
+ * comments, verbatim/listings/`\lstinline`, tikz, the preamble, the bibliography.
52
48
  * Markdown text nodes, table cells included. Not: code, inline code, html, front matter.
53
- * Only then is the lexeme matched, one run of visible text at a time. The arXiv lookbehind
54
- * stays: in `2310.05736` the dot follows a digit.
55
- *
56
- * What still gets through. A decimal inside a quotation, or a paragraph number such as
57
- * `¶¶.42`, is prose to the parser and is counted. The per-paper debt below absorbs known cases.
58
- * And the other direction, chosen on purpose: a text macro outside the known set (`\hl{.05}`, an
59
- * author's `\pval{.05}`) is read as a parameter and NOT counted — for a warning that people
60
- * learn to mute, a miss costs less than a finding on markup.
49
+ * Each character of a run keeps its source offset, so the finding points at the dot and the fix
50
+ * inserts the zero there. The arXiv lookbehind stays: in `2310.05736` the dot follows a digit.
51
+ * A text macro outside the known set (`\hl{.05}`) is read as a parameter and NOT reported — a
52
+ * miss costs less than a finding on markup.
61
53
  */
62
- import { relative, dirname } from "node:path";
63
54
  import { getParser } from "@unified-latex/unified-latex-util-parse";
64
55
 
56
+ /** The inline bibliography — a `filecontents` block writing a `.bib` — with its offsets. */
57
+ export function bibRange(text) {
58
+ const m =
59
+ /\\begin\{filecontents\*?\}(?:\[[^\]]*\])?\{[^}]*\.bib\}\r?\n([\s\S]*?)\\end\{filecontents\*?\}/d.exec(
60
+ text,
61
+ );
62
+ if (!m) return null;
63
+ return {
64
+ start: m.index,
65
+ end: m.index + m[0].length,
66
+ bodyStart: m.indices[1][0],
67
+ body: m[1],
68
+ };
69
+ }
70
+
65
71
  /**
66
- * The inline bibliography is counted differently from the prose, and in this corpus it lives
67
- * INSIDE the `.tex` in a `filecontents` environment rather than in a separate `.bib`.
72
+ * The ranges a markup rule must not read: LaTeX comments (the language gives them as `html`
73
+ * nodes, with positions) and the inline bibliography.
68
74
  */
69
- function splitPaperText(text) {
70
- const m = text.match(
71
- /\\begin\{filecontents\*?\}(?:\[[^\]]*\])?\{[^}]*\.bib\}\r?\n([\s\S]*?)\\end\{filecontents\*?\}/,
72
- );
73
- return m
74
- ? { body: text.replace(m[0], ""), bib: m[1] }
75
- : { body: text, bib: "" };
75
+ function skippedRanges(sourceCode, raw) {
76
+ const out = [];
77
+ for (const n of sourceCode.ast?.children ?? [])
78
+ if (n.type === "html" && n.position)
79
+ out.push([n.position.start.offset, n.position.end.offset]);
80
+ const bib = bibRange(raw);
81
+ if (bib) out.push([bib.start, bib.end]);
82
+ return out;
76
83
  }
84
+ const inside = (ranges, i) => ranges.some(([s, e]) => i >= s && i < e);
77
85
 
78
- function typographyCounts(text, bareDecimal) {
79
- const { body, bib } = splitPaperText(text);
80
-
81
- // 🔴 BOTH forms, and the second one is why this check was ever wrong. The first version
82
- // counted only the literal `§` and returned ZERO on the very paper whose reviewer raised it:
83
- // the source writes `\S\ref{sec:threats}`, which RENDERS as `§5`. Measured on the held-out
84
- // submitted build: source hits 0, rendered PDF hits 10. A source-level check calibrated
85
- // against a rendered complaint reports clean on the exact defect it was written for.
86
- const sectionSign =
87
- (body.match(/§/g) || []).length +
88
- (body.match(/\\S(?=\s*\\ref|~\\ref|\d)/g) || []).length;
89
-
90
- // Mixed `Fig.~\ref` and `Figure~\ref` in ONE document. Consistent use of either is fine, so
91
- // this counts only when both appear; an absolute rule here would be taste, not a defect.
92
- const figShort = (body.match(/\bFig\.~?\\(?:ref|autoref)/g) || []).length;
93
- const figLong = (body.match(/\bFigure~?\\(?:ref|autoref)/g) || []).length;
94
- const figMixed =
95
- figShort > 0 && figLong > 0 ? Math.min(figShort, figLong) : 0;
96
-
97
- // A bibliography entry a reader cannot follow: no doi, no url, no arXiv id.
98
- // ⚠️ NOT "no doi". Measured 2026-08-24: ICLR/NeurIPS/TMLR issue no DOIs at all, so a
99
- // doi-only rule would demand something that does not exist and get muted for lying.
100
- let unreachable = 0;
101
- for (const e of bib.split(/^@/m).slice(1)) {
102
- if (!/\b(doi|url)\s*=/.test(e) && !/arxiv[:\s]*\d{4}\.\d{4,5}/i.test(e))
103
- unreachable++;
104
- }
105
- return { sectionSign, bareDecimal, figMixed, unreachable };
86
+ /** Every match of `re` in `raw` outside the skipped ranges. */
87
+ function markupMatches(sourceCode, re) {
88
+ const raw = sourceCode.raw;
89
+ const skip = skippedRanges(sourceCode, raw);
90
+ return [...raw.matchAll(re)].filter((m) => !inside(skip, m.index));
106
91
  }
107
92
 
108
- // ── bareDecimal: the text a READER sees, taken from the tree ─────────────────────────────
93
+ const isTex = (sourceCode) => typeof sourceCode.raw === "string";
109
94
 
110
- // The lexeme, matched inside ONE run of visible text — never over the source. The lookbehind
111
- // keeps arXiv ids (2310.05736) out: there the dot follows a digit.
112
- const BARE_DECIMAL = /(?<![\d.\w])\.\d{2,}\b/g;
113
- const countBareDecimals = (runs) =>
114
- runs.reduce((n, run) => n + (run.match(BARE_DECIMAL) || []).length, 0);
95
+ // ── the text a READER sees, with the source offset of every character ─────────────────────
96
+
97
+ /** A run of visible text; `offs[i]` is the source offset of `text[i]`, or null when unknown. */
98
+ const run = () => ({ text: "", offs: [] });
115
99
 
116
100
  // Environments whose body is not typeset as text: code, drawings, the inline bibliography.
117
101
  const TEX_HIDDEN_ENV =
118
102
  /^(verbatim|Verbatim|lstlisting|minted|tikzpicture|filecontents\*?|comment)$/;
119
- // Text macros whose mandatory argument IS prose. Every other macro's arguments are parameters
120
- // (`\includegraphics[width=…]`, `\hspace{…}`, `\label{…}`) and are not read.
103
+ // Text macros whose mandatory argument IS prose. Every other macro's arguments are parameters.
121
104
  const TEX_PROSE_ARG =
122
105
  /^(emph|textbf|textit|textsl|textsc|textup|textmd|textrm|textsf|textnormal|underline|text|mbox|caption|footnote|footnotetext|thanks|title|textsuperscript|textsubscript|part|chapter|section|subsection|subsubsection|paragraph|subparagraph)$/;
123
- // Macros whose LAST mandatory argument is what the reader sees: a table cell, coloured text,
124
- // a link's text.
106
+ // Macros whose LAST mandatory argument is what the reader sees.
125
107
  const TEX_LAST_ARG = /^(multicolumn|multirow|textcolor|href)$/;
126
- // In math every macro argument is typeset (`\frac{.05}{2}`, `\sqrt{…}`) except these.
108
+ // In math every macro argument is typeset except these.
127
109
  const TEX_MATH_HIDDEN =
128
110
  /^(label|ref|eqref|autoref|cite|hspace|vspace|hskip|vskip|kern|mkern|mskip|rule|phantom|hphantom|vphantom|color|raisebox|includegraphics)$/;
129
111
 
@@ -131,11 +113,9 @@ const mandatory = (macro) =>
131
113
  (macro.args || []).filter((a) => a.openMark === "{");
132
114
 
133
115
  /**
134
- * unified-latex attaches arguments only to what it has a signature for. For the rest — `\def\x`,
135
- * an author's `\foo[width=.48]{…}`, `\begin{subfigure}{.48\textwidth}`, the column spec of
136
- * `longtable` — the arguments come back as the NEXT SIBLINGS: an optional `[…]` as bare strings,
137
- * then groups. They are parameters, not prose. Returns the index of the last sibling to skip,
138
- * starting after `i`; nothing is skipped past whitespace, and an unclosed `[` skips nothing.
116
+ * unified-latex attaches arguments only to what it has a signature for. For the rest the
117
+ * arguments come back as the NEXT SIBLINGS: an optional `[…]` as bare strings, then groups.
118
+ * They are parameters, not prose. Returns the index of the last sibling to skip after `i`.
139
119
  */
140
120
  function unparsedArgsEnd(nodes, i) {
141
121
  let j = i + 1;
@@ -152,21 +132,32 @@ function unparsedArgsEnd(nodes, i) {
152
132
  return j - 1;
153
133
  }
154
134
 
135
+ /** Append a string node to the current run, keeping each character's offset. */
136
+ function appendString(cur, n, src) {
137
+ const start = n.position?.start?.offset;
138
+ const exact =
139
+ typeof start === "number" &&
140
+ src.slice(start, start + n.content.length) === n.content;
141
+ for (let i = 0; i < n.content.length; i++)
142
+ cur.offs.push(exact ? start + i : null);
143
+ cur.text += n.content;
144
+ }
145
+
155
146
  /**
156
147
  * Collect runs of visible text from a unified-latex node list. A run is a stretch of sibling
157
148
  * `string` nodes with nothing between them: math splits `.05` into `.`, `0`, `5`, and prose
158
149
  * keeps `2310.05736` whole — both come back as one word. Anything else ends the run.
159
150
  */
160
- function texRuns(nodes, math, out) {
161
- let run = "";
151
+ function texRuns(nodes, math, src, out) {
152
+ let cur = run();
162
153
  const flush = () => {
163
- if (run) out.push(run);
164
- run = "";
154
+ if (cur.text) out.push(cur);
155
+ cur = run();
165
156
  };
166
157
  for (let i = 0; i < (nodes || []).length; i++) {
167
158
  const n = nodes[i];
168
159
  if (n.type === "string") {
169
- run += n.content;
160
+ appendString(cur, n, src);
170
161
  continue;
171
162
  }
172
163
  flush();
@@ -175,32 +166,28 @@ function texRuns(nodes, math, out) {
175
166
  n.type === "displaymath" ||
176
167
  n.type === "mathenv"
177
168
  )
178
- texRuns(n.content, true, out);
169
+ texRuns(n.content, true, src, out);
179
170
  else if (n.type === "environment") {
180
171
  const env = typeof n.env === "string" ? n.env : "";
181
- // The body only: an environment's own arguments are a column spec, a width, a placement —
182
- // and when unified-latex has no signature for it, they lead the body as bare siblings.
183
172
  if (!TEX_HIDDEN_ENV.test(env)) {
184
173
  const body = n.content || [];
185
174
  const from = (n.args || []).length ? 0 : unparsedArgsEnd(body, -1) + 1;
186
- texRuns(body.slice(from), math, out);
175
+ texRuns(body.slice(from), math, src, out);
187
176
  }
188
177
  } else if (n.type === "group") {
189
- texRuns(n.content, math, out);
178
+ texRuns(n.content, math, src, out);
190
179
  } else if (n.type === "macro") {
191
180
  const args = mandatory(n);
192
181
  if (math) {
193
182
  if (!TEX_MATH_HIDDEN.test(n.content))
194
- for (const a of args) texRuns(a.content, true, out);
183
+ for (const a of args) texRuns(a.content, true, src, out);
195
184
  } else if (TEX_PROSE_ARG.test(n.content)) {
196
- for (const a of args) texRuns(a.content, false, out);
185
+ for (const a of args) texRuns(a.content, false, src, out);
197
186
  } else if (TEX_LAST_ARG.test(n.content) && args.length) {
198
- texRuns(args[args.length - 1].content, false, out);
187
+ texRuns(args[args.length - 1].content, false, src, out);
199
188
  }
200
- // In text, a macro with no parsed arguments may still have them, as siblings.
201
189
  if (!math && !(n.args || []).length) i = unparsedArgsEnd(nodes, i);
202
190
  }
203
- // comment, verb, verbatim, whitespace, parbreak: not the reader's number, or not a word
204
191
  }
205
192
  flush();
206
193
  return out;
@@ -212,11 +199,10 @@ function texVisibleRuns(raw) {
212
199
  const document = root.find(
213
200
  (n) => n.type === "environment" && n.env === "document",
214
201
  );
215
- return texRuns(document ? document.content : root, false, []);
202
+ return texRuns(document ? document.content : root, false, raw, []);
216
203
  }
217
204
 
218
- // Markdown: the ESLint markdown language already hands over the mdast. Text nodes are what the
219
- // reader sees, table cells included; these node types are not prose.
205
+ // Markdown node types that are not prose.
220
206
  const MD_HIDDEN = new Set([
221
207
  "code",
222
208
  "inlineCode",
@@ -226,88 +212,177 @@ const MD_HIDDEN = new Set([
226
212
  "math",
227
213
  "inlineMath",
228
214
  ]);
229
- function mdVisibleRuns(node, out = []) {
215
+ /**
216
+ * The source offset of each character of a text node's value. The value is the source with
217
+ * markdown's escapes (`\*`) and character references (`&lt;`) resolved, so the two are walked
218
+ * side by side; a character whose source cannot be followed gets null (reported, not fixed).
219
+ */
220
+ function mdOffsets(value, src, start) {
221
+ const offs = [];
222
+ let j = start;
223
+ for (const ch of value) {
224
+ if (src[j] === ch) offs.push(j++);
225
+ else if (src[j] === "\\" && src[j + 1] === ch) {
226
+ offs.push(j + 1);
227
+ j += 2;
228
+ } else if (src[j] === "&" && /^&#?\w{1,8};/.test(src.slice(j, j + 10))) {
229
+ offs.push(null);
230
+ j = src.indexOf(";", j) + 1;
231
+ } else {
232
+ offs.push(null);
233
+ j++;
234
+ }
235
+ }
236
+ return offs;
237
+ }
238
+
239
+ function mdVisibleRuns(node, src, out = []) {
230
240
  if (!node || MD_HIDDEN.has(node.type)) return out;
231
- if (node.type === "text") out.push(node.value);
232
- for (const c of node.children || []) mdVisibleRuns(c, out);
241
+ if (node.type === "text") {
242
+ const start = node.position?.start?.offset;
243
+ out.push({
244
+ text: node.value,
245
+ offs:
246
+ typeof start === "number"
247
+ ? mdOffsets(node.value, src, start)
248
+ : [...node.value].map(() => null),
249
+ });
250
+ }
251
+ for (const c of node.children || []) mdVisibleRuns(c, src, out);
233
252
  return out;
234
253
  }
235
254
 
236
- /** The TexSourceCode of this package carries `raw`; anything else is read through its AST. */
237
255
  const visibleRuns = (sourceCode) =>
238
- typeof sourceCode.raw === "string"
256
+ isTex(sourceCode)
239
257
  ? texVisibleRuns(sourceCode.raw)
240
- : mdVisibleRuns(sourceCode.ast);
258
+ : mdVisibleRuns(sourceCode.ast, sourceCode.text);
241
259
 
242
- const LABEL = {
243
- sectionSign: "`§` instead of «Section» (reviewer B)",
244
- bareDecimal:
245
- "a decimal without a leading zero, `.05` instead of `0.05` (IEEE / ISO 80000-1 style)",
246
- figMixed: "`Fig.` and `Figure` mixed in one document",
247
- unreachable:
248
- "bibliography entries with no doi/url/arXiv id — a reader has nothing to follow (reviewer A)",
260
+ /** A report at `[from, to)` of the file. */
261
+ const at = (context, from, to, rest) => {
262
+ const sc = context.sourceCode;
263
+ context.report({
264
+ loc: { start: sc.getLocFromIndex(from), end: sc.getLocFromIndex(to) },
265
+ ...rest,
266
+ });
249
267
  };
250
268
 
269
+ // ── paper/section-word ─────────────────────────────────────────────────────────────────
270
+
271
+ // The sign (the glyph, or the `\S` macro not followed by a letter), the separator after it, and
272
+ // what follows: a reference (group 3) or a number (group 4).
273
+ const SECTION_TEX = /(§|\\S(?![A-Za-z]))(~|[ \t]*)(?=(\\(?:auto)?ref\b)|(\d))/g;
274
+ const SECTION_GLYPH = /§/g;
275
+
276
+ function sectionWord(context) {
277
+ const sc = context.sourceCode;
278
+ if (isTex(sc)) {
279
+ const seen = new Set();
280
+ for (const m of markupMatches(sc, SECTION_TEX)) {
281
+ seen.add(m.index);
282
+ const word = m[3] ? "Section~" : "Section ";
283
+ const range = [m.index, m.index + m[0].length];
284
+ at(context, range[0], range[1], {
285
+ messageId: "sign",
286
+ fix: (f) => f.replaceTextRange(range, word),
287
+ });
288
+ }
289
+ // A glyph before neither a reference nor a number: reported, not rewritten — there is no
290
+ // single right word for a doubled sign or a sign before prose.
291
+ for (const m of markupMatches(sc, SECTION_GLYPH))
292
+ if (!seen.has(m.index))
293
+ at(context, m.index, m.index + 1, { messageId: "sign" });
294
+ return;
295
+ }
296
+ for (const r of visibleRuns(sc))
297
+ for (const m of r.text.matchAll(/§([ \t]*)(\d)?/g)) {
298
+ const from = r.offs[m.index];
299
+ if (from === null || from === undefined) continue;
300
+ // The sign and the space after it; the number stays.
301
+ const range = [from, from + 1 + m[1].length];
302
+ at(context, range[0], range[1], {
303
+ messageId: "sign",
304
+ ...(m[2] ? { fix: (f) => f.replaceTextRange(range, "Section ") } : {}),
305
+ });
306
+ }
307
+ }
308
+
309
+ // ── paper/leading-zero ─────────────────────────────────────────────────────────────────
310
+
311
+ // The lexeme, matched inside ONE run of visible text. The lookbehind keeps arXiv ids
312
+ // (2310.05736) out: there the dot follows a digit.
313
+ const BARE_DECIMAL = /(?<![\d.\w])\.\d{2,}\b/g;
314
+
315
+ function leadingZero(context) {
316
+ for (const r of visibleRuns(context.sourceCode))
317
+ for (const m of r.text.matchAll(BARE_DECIMAL)) {
318
+ const dot = r.offs[m.index];
319
+ if (dot === null || dot === undefined) continue;
320
+ at(context, dot, dot + 1, {
321
+ messageId: "bare",
322
+ data: { n: m[0] },
323
+ fix: (f) => f.insertTextBeforeRange([dot, dot], "0"),
324
+ });
325
+ }
326
+ }
327
+
328
+ // ── paper/figure-ref-style ─────────────────────────────────────────────────────────────
329
+
330
+ const FIG_REF = /\b(Fig\.|Figure)(?=~?\\(?:ref|autoref)\b)/g;
331
+
332
+ function figureRefStyle(context) {
333
+ const sc = context.sourceCode;
334
+ if (!isTex(sc)) return;
335
+ const all = markupMatches(sc, FIG_REF);
336
+ const short = all.filter((m) => m[1] === "Fig.");
337
+ const long = all.filter((m) => m[1] === "Figure");
338
+ if (!short.length || !long.length) return;
339
+ // The minority form is rewritten to the majority one; on a tie, to the full word.
340
+ const [minority, word] =
341
+ short.length > long.length ? [long, "Fig."] : [short, "Figure"];
342
+ for (const m of minority) {
343
+ const range = [m.index, m.index + m[1].length];
344
+ at(context, range[0], range[1], {
345
+ messageId: "mixed",
346
+ data: { form: m[1], word, n: String(all.length - minority.length) },
347
+ fix: (f) => f.replaceTextRange(range, word),
348
+ });
349
+ }
350
+ }
351
+
352
+ const rule = (description, messages, check) => ({
353
+ meta: {
354
+ type: "suggestion",
355
+ fixable: "code",
356
+ docs: { description },
357
+ schema: [],
358
+ messages,
359
+ },
360
+ create: (context) => ({ "root:exit": () => check(context) }),
361
+ });
362
+
251
363
  export default {
252
364
  rules: {
253
- typography: {
254
- meta: {
255
- type: "suggestion",
256
- docs: {
257
- description:
258
- "mechanical conventions a reviewer already raised, counted against a declared debt: silent on what was already there, loud on what grew",
259
- },
260
- schema: [
261
- {
262
- type: "object",
263
- properties: {
264
- // { "<path to the paper directory from repo root>": { sectionSign: 54, unreachable: 18 } }
265
- debt: { type: "object", additionalProperties: true },
266
- },
267
- additionalProperties: false,
268
- },
269
- ],
270
- messages: {
271
- grew: "{{n}} × {{label}}{{grew}}. Paying the debt down is silent; growth is reported",
272
- },
365
+ "section-word": rule(
366
+ "`§` / `\\S` instead of the word Section (a reviewer's to-do); fixable",
367
+ {
368
+ sign: "`§` instead of the word «Section» — `paperlint lint --fix` writes it",
369
+ },
370
+ sectionWord,
371
+ ),
372
+ "leading-zero": rule(
373
+ "a decimal below 1 written without its leading zero (IEEE / ISO 80000-1); fixable",
374
+ {
375
+ bare: "`{{n}}` has no leading zero — write `0{{n}}` (IEEE / ISO 80000-1); `paperlint lint --fix` inserts it",
273
376
  },
274
- create(context) {
275
- const debt = context.options[0]?.debt ?? {};
276
- return {
277
- "root:exit"(node) {
278
- // `raw` on the LaTeX language, `text` everywhere else. A paper in this corpus may be
279
- // written in markdown rather than LaTeX, and three of the four counts apply there
280
- // unchanged; only the macro spellings never match, which is correct rather than a
281
- // gap. Measured 2026-09-16: the check this rule replaces silently covered a markdown
282
- // paper carrying 246 section signs, so a `.tex`-only rule would have LOST it.
283
- const raw = context.sourceCode.raw ?? context.sourceCode.text;
284
- if (typeof raw !== "string") return;
285
- const bareDecimal = countBareDecimals(
286
- visibleRuns(context.sourceCode),
287
- );
288
- const counts = typographyCounts(raw, bareDecimal);
289
- const key = relative(context.cwd, dirname(context.filename));
290
- const owed = debt[key] ?? {};
291
- for (const [field, n] of Object.entries(counts)) {
292
- if (n === 0) continue;
293
- const before = owed[field] ?? 0;
294
- if (n <= before) continue; // known debt, unchanged or paid down
295
- context.report({
296
- node,
297
- messageId: "grew",
298
- data: {
299
- n: String(n),
300
- label: LABEL[field],
301
- grew:
302
- before > 0
303
- ? ` (was ${String(before)}, now ${String(n)})`
304
- : "",
305
- },
306
- });
307
- }
308
- },
309
- };
377
+ leadingZero,
378
+ ),
379
+ "figure-ref-style": rule(
380
+ "`Fig.~\\ref` and `Figure~\\ref` mixed in one document; fixable to the majority form",
381
+ {
382
+ mixed:
383
+ "`{{form}}` beside {{n}} × `{{word}}` in one document — use one form; `paperlint lint --fix` writes `{{word}}`",
310
384
  },
311
- },
385
+ figureRefStyle,
386
+ ),
312
387
  },
313
388
  };