paperlint 2.1.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -1
- package/README.md +243 -235
- package/action.yml +4 -6
- package/dist/adapters/references/index.d.ts +13 -0
- package/dist/adapters/references/index.d.ts.map +1 -0
- package/dist/adapters/references/index.js +55 -0
- package/dist/adapters/references/index.js.map +1 -0
- package/dist/adapters/references/reach.io.d.ts +3 -0
- package/dist/adapters/references/reach.io.d.ts.map +1 -0
- package/dist/adapters/references/reach.io.js +14 -0
- package/dist/adapters/references/reach.io.js.map +1 -0
- package/dist/build-engine.d.ts.map +1 -1
- package/dist/build-engine.js +6 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +60 -7
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +94 -30
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +500 -288
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +40 -72
- package/dist/doctor.js.map +1 -1
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +27 -0
- package/dist/engine.js.map +1 -1
- package/dist/facts-file.d.ts +2 -2
- package/dist/facts-file.js +2 -2
- package/dist/hooks-settings.d.ts +0 -21
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +17 -135
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +39 -29
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +144 -173
- package/dist/init.js.map +1 -1
- package/dist/link-skills.d.ts +1 -1
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +11 -29
- package/dist/link-skills.js.map +1 -1
- package/dist/new-paper.d.ts +8 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +49 -25
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +85 -0
- package/dist/paper-files.d.ts.map +1 -0
- package/dist/paper-files.js +96 -0
- package/dist/paper-files.js.map +1 -0
- package/dist/paper-settings.d.ts +13 -41
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +71 -71
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/check-references.d.ts +31 -0
- package/dist/ports/check-references.d.ts.map +1 -0
- package/dist/ports/check-references.js +7 -0
- package/dist/ports/check-references.js.map +1 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -6
- package/dist/presets.js.map +1 -1
- package/dist/reference-rules.d.ts +47 -0
- package/dist/reference-rules.d.ts.map +1 -0
- package/dist/reference-rules.js +156 -0
- package/dist/reference-rules.js.map +1 -0
- package/dist/references.d.ts +36 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +91 -0
- package/dist/references.js.map +1 -0
- package/dist/rules-config.d.ts +5 -3
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +15 -6
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.js +3 -3
- package/dist/structure.js.map +1 -1
- package/dist/tex-requirements.js +2 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/toolchain.d.ts +5 -1
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +22 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +26 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +12 -10
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +163 -104
- package/docs/e2e.md +1 -1
- package/docs/install.md +25 -40
- package/docs/optional-rules.md +5 -4
- package/docs/rules.md +65 -24
- package/docs/skills.md +80 -0
- package/eslint-rules/bib-reachable-entry.mjs +75 -0
- package/eslint-rules/latex-language.mjs +29 -1
- package/eslint-rules/paper-stages.harness.mjs +1 -117
- package/eslint-rules/paper-stages.mjs +0 -120
- package/eslint-rules/paper-stages.mutations.mjs +0 -76
- package/eslint-rules/paper-typography.mjs +306 -191
- package/eslint-rules/paper-typography.test.mjs +337 -0
- package/eslint-rules/papers.harness.mjs +8 -12
- package/eslint-rules/papers.mjs +17 -31
- package/eslint-rules/pdf-last-page-balance.mjs +4 -7
- package/eslint-rules/review-frontmatter.mjs +118 -0
- package/eslint-rules/review-frontmatter.schema.json +36 -0
- package/eslint-rules/review-frontmatter.test.mjs +118 -0
- package/eslint-rules/sibling-frontmatter.mjs +19 -0
- package/eslint-rules/sibling-frontmatter.schema.json +14 -0
- package/eslint.config.mjs +0 -15
- package/fixtures/real-markdown-paper/baseline.json +6 -7
- package/fixtures/toolchain-mirror/catalog.txt +1 -0
- package/fixtures/toolchain-mirror/install-tl +4 -1
- package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
- package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
- package/hooks/hooks.harness.mjs +39 -98
- package/hooks/hooks.mutations.mjs +6 -6
- package/hooks/paper-edit-guard.hook.d.mts +2 -3
- package/hooks/paper-edit-guard.hook.mjs +31 -42
- package/hooks/paper-skills-nudge.hook.mjs +21 -19
- package/hooks/paper-status-gates.hook.mjs +21 -19
- package/hooks/paper-status-gates.sh +8 -13
- package/lib/paper-config.d.mts +15 -25
- package/lib/paper-config.harness.mjs +18 -35
- package/lib/paper-config.mjs +83 -105
- package/lib/paper-config.mutations.mjs +2 -2
- package/lib/skill-checks.mjs +1 -1
- package/lib/skill-trigger-cases.harness.mjs +9 -8
- package/lib/skill-trigger-cases.mjs +7 -15
- package/package.json +1 -1
- package/scripts/eslint-report-guard.mjs +1 -1
- package/scripts/harness-api.frozen.json +0 -3
- package/scripts/mutation-batteries.frozen.json +5 -14
- package/skills/analyze-sibling-paper/SKILL.md +16 -12
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
- package/skills/cold-read-diff/SKILL.md +20 -1
- package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
- package/skills/grade-paper-writing/SKILL.md +20 -1
- package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
- package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
- package/skills/plan-paper-timeline/SKILL.md +2 -2
- package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
- package/skills/render-paper/check-render.sh +2 -3
- package/skills/render-paper/render-paper.harness.mjs +2 -2
- package/skills/verify-citations/SKILL.md +10 -1
- package/skills/verify-citations/SKILL.md.spec.ts +9 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
- package/src/adapters/references/index.ts +102 -0
- package/src/adapters/references/reach.io.ts +12 -0
- package/src/build-engine.ts +6 -1
- package/src/build.harness.mjs +54 -6
- package/src/build.ts +89 -11
- package/src/cli.harness.mjs +149 -294
- package/src/cli.mutations.mjs +35 -44
- package/src/cli.ts +637 -328
- package/src/doctor.harness.mjs +41 -84
- package/src/doctor.mutations.mjs +9 -9
- package/src/doctor.ts +44 -89
- package/src/engine.harness.mjs +50 -0
- package/src/engine.ts +35 -0
- package/src/facts-file.test.ts +0 -7
- package/src/facts-file.ts +2 -2
- package/src/hooks-settings.harness.mjs +7 -92
- package/src/hooks-settings.mutations.mjs +2 -17
- package/src/hooks-settings.ts +18 -162
- package/src/init.test.ts +206 -0
- package/src/init.ts +187 -231
- package/src/link-skills.harness.mjs +1 -38
- package/src/link-skills.mutations.mjs +9 -5
- package/src/link-skills.ts +10 -34
- package/src/lint-fix.test.ts +120 -0
- package/src/linted-set.test.ts +118 -0
- package/src/new-paper.harness.mjs +2 -0
- package/src/new-paper.mutations.mjs +0 -14
- package/src/new-paper.test.ts +173 -14
- package/src/new-paper.ts +82 -30
- package/src/paper-files.test.ts +395 -0
- package/src/paper-files.ts +200 -0
- package/src/paper-settings-commands.test.ts +121 -122
- package/src/paper-settings.test.ts +93 -64
- package/src/paper-settings.ts +99 -97
- package/src/parse-args.test.ts +48 -0
- package/src/ports/check-references.ts +28 -0
- package/src/presets.ts +5 -15
- package/src/reference-rules.ts +229 -0
- package/src/references.test.ts +209 -0
- package/src/references.ts +138 -0
- package/src/rules-config.harness.mjs +24 -8
- package/src/rules-config.ts +19 -7
- package/src/rules-off.test.ts +111 -0
- package/src/structure.harness.mjs +4 -2
- package/src/structure.ts +3 -3
- package/src/tex-requirements.ts +2 -2
- package/src/toolchain.harness.mjs +41 -6
- package/src/toolchain.ts +29 -5
- package/src/types.ts +36 -18
- package/src/venue-rules.test.ts +23 -11
- package/src/venue-rules.ts +12 -14
- package/templates/paper/paperlint.json +1 -1
- package/eslint-rules/doc-fields.harness.mjs +0 -336
- package/eslint-rules/doc-fields.mjs +0 -186
- package/eslint-rules/doc-fields.mutations.mjs +0 -96
- package/eslint-rules/paper-typography.harness.mjs +0 -291
- package/eslint-rules/paper-typography.mutations.mjs +0 -131
- package/eslint-rules/review-findings-cause.harness.mjs +0 -228
- package/eslint-rules/review-findings-cause.mjs +0 -135
- package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
- package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
- package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
- package/fixtures/review-findings-cause/clean.md +0 -17
- package/fixtures/review-findings-cause/defect.md +0 -14
- package/fixtures/review-findings-cause/old-debt.md +0 -14
- package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
|
@@ -1,129 +1,119 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
50
|
-
*
|
|
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
|
-
*
|
|
54
|
-
* stays: in `2310.05736` the dot follows a digit.
|
|
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.
|
|
55
53
|
*
|
|
56
|
-
*
|
|
57
|
-
* `¶¶.42`,
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
54
|
+
* 🔴 A NUMBER AFTER `¶` OR `§` IS A DESIGNATOR, NOT A DECIMAL (#102). An auditing standard is cited
|
|
55
|
+
* by paragraph as `¶¶.42`, and `--fix` rewrote it to `¶¶0.42` — a fix that changed a reference.
|
|
56
|
+
* So a lexeme right after the sign (`¶`, `§`, `\P`, `\S`, spaces or `~` between) is silent. The
|
|
57
|
+
* same sentence then goes on `and .44`: a paragraph number continuing the list, or a decimal —
|
|
58
|
+
* the text does not say which. In a paragraph that already cites `¶.nn` this way, a bare decimal
|
|
59
|
+
* is REPORTED WITHOUT A FIX and the zero is offered as a suggestion: an autofix must never be the
|
|
60
|
+
* one to decide what a number means. A real `.05` in any other paragraph is fixed as before.
|
|
61
61
|
*/
|
|
62
|
-
import { relative, dirname } from "node:path";
|
|
63
62
|
import { getParser } from "@unified-latex/unified-latex-util-parse";
|
|
64
63
|
|
|
64
|
+
/** The inline bibliography — a `filecontents` block writing a `.bib` — with its offsets. */
|
|
65
|
+
export function bibRange(text) {
|
|
66
|
+
const m =
|
|
67
|
+
/\\begin\{filecontents\*?\}(?:\[[^\]]*\])?\{[^}]*\.bib\}\r?\n([\s\S]*?)\\end\{filecontents\*?\}/d.exec(
|
|
68
|
+
text,
|
|
69
|
+
);
|
|
70
|
+
if (!m) return null;
|
|
71
|
+
return {
|
|
72
|
+
start: m.index,
|
|
73
|
+
end: m.index + m[0].length,
|
|
74
|
+
bodyStart: m.indices[1][0],
|
|
75
|
+
body: m[1],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
/**
|
|
66
|
-
* The
|
|
67
|
-
*
|
|
80
|
+
* The ranges a markup rule must not read: LaTeX comments (the language gives them as `html`
|
|
81
|
+
* nodes, with positions) and the inline bibliography.
|
|
68
82
|
*/
|
|
69
|
-
function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
function skippedRanges(sourceCode, raw) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const n of sourceCode.ast?.children ?? [])
|
|
86
|
+
if (n.type === "html" && n.position)
|
|
87
|
+
out.push([n.position.start.offset, n.position.end.offset]);
|
|
88
|
+
const bib = bibRange(raw);
|
|
89
|
+
if (bib) out.push([bib.start, bib.end]);
|
|
90
|
+
return out;
|
|
76
91
|
}
|
|
92
|
+
const inside = (ranges, i) => ranges.some(([s, e]) => i >= s && i < e);
|
|
77
93
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 };
|
|
94
|
+
/** Every match of `re` in `raw` outside the skipped ranges. */
|
|
95
|
+
function markupMatches(sourceCode, re) {
|
|
96
|
+
const raw = sourceCode.raw;
|
|
97
|
+
const skip = skippedRanges(sourceCode, raw);
|
|
98
|
+
return [...raw.matchAll(re)].filter((m) => !inside(skip, m.index));
|
|
106
99
|
}
|
|
107
100
|
|
|
108
|
-
|
|
101
|
+
const isTex = (sourceCode) => typeof sourceCode.raw === "string";
|
|
109
102
|
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
runs.reduce((n, run) => n + (run.match(BARE_DECIMAL) || []).length, 0);
|
|
103
|
+
// ── the text a READER sees, with the source offset of every character ─────────────────────
|
|
104
|
+
|
|
105
|
+
/** A run of visible text; `offs[i]` is the source offset of `text[i]`, or null when unknown. */
|
|
106
|
+
const run = () => ({ text: "", offs: [] });
|
|
115
107
|
|
|
116
108
|
// Environments whose body is not typeset as text: code, drawings, the inline bibliography.
|
|
117
109
|
const TEX_HIDDEN_ENV =
|
|
118
110
|
/^(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.
|
|
111
|
+
// Text macros whose mandatory argument IS prose. Every other macro's arguments are parameters.
|
|
121
112
|
const TEX_PROSE_ARG =
|
|
122
113
|
/^(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
|
|
124
|
-
// a link's text.
|
|
114
|
+
// Macros whose LAST mandatory argument is what the reader sees.
|
|
125
115
|
const TEX_LAST_ARG = /^(multicolumn|multirow|textcolor|href)$/;
|
|
126
|
-
// In math every macro argument is typeset
|
|
116
|
+
// In math every macro argument is typeset except these.
|
|
127
117
|
const TEX_MATH_HIDDEN =
|
|
128
118
|
/^(label|ref|eqref|autoref|cite|hspace|vspace|hskip|vskip|kern|mkern|mskip|rule|phantom|hphantom|vphantom|color|raisebox|includegraphics)$/;
|
|
129
119
|
|
|
@@ -131,11 +121,9 @@ const mandatory = (macro) =>
|
|
|
131
121
|
(macro.args || []).filter((a) => a.openMark === "{");
|
|
132
122
|
|
|
133
123
|
/**
|
|
134
|
-
* unified-latex attaches arguments only to what it has a signature for. For the rest
|
|
135
|
-
* an
|
|
136
|
-
*
|
|
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.
|
|
124
|
+
* unified-latex attaches arguments only to what it has a signature for. For the rest the
|
|
125
|
+
* arguments come back as the NEXT SIBLINGS: an optional `[…]` as bare strings, then groups.
|
|
126
|
+
* They are parameters, not prose. Returns the index of the last sibling to skip after `i`.
|
|
139
127
|
*/
|
|
140
128
|
function unparsedArgsEnd(nodes, i) {
|
|
141
129
|
let j = i + 1;
|
|
@@ -152,21 +140,32 @@ function unparsedArgsEnd(nodes, i) {
|
|
|
152
140
|
return j - 1;
|
|
153
141
|
}
|
|
154
142
|
|
|
143
|
+
/** Append a string node to the current run, keeping each character's offset. */
|
|
144
|
+
function appendString(cur, n, src) {
|
|
145
|
+
const start = n.position?.start?.offset;
|
|
146
|
+
const exact =
|
|
147
|
+
typeof start === "number" &&
|
|
148
|
+
src.slice(start, start + n.content.length) === n.content;
|
|
149
|
+
for (let i = 0; i < n.content.length; i++)
|
|
150
|
+
cur.offs.push(exact ? start + i : null);
|
|
151
|
+
cur.text += n.content;
|
|
152
|
+
}
|
|
153
|
+
|
|
155
154
|
/**
|
|
156
155
|
* Collect runs of visible text from a unified-latex node list. A run is a stretch of sibling
|
|
157
156
|
* `string` nodes with nothing between them: math splits `.05` into `.`, `0`, `5`, and prose
|
|
158
157
|
* keeps `2310.05736` whole — both come back as one word. Anything else ends the run.
|
|
159
158
|
*/
|
|
160
|
-
function texRuns(nodes, math, out) {
|
|
161
|
-
let
|
|
159
|
+
function texRuns(nodes, math, src, out) {
|
|
160
|
+
let cur = run();
|
|
162
161
|
const flush = () => {
|
|
163
|
-
if (
|
|
164
|
-
|
|
162
|
+
if (cur.text) out.push(cur);
|
|
163
|
+
cur = run();
|
|
165
164
|
};
|
|
166
165
|
for (let i = 0; i < (nodes || []).length; i++) {
|
|
167
166
|
const n = nodes[i];
|
|
168
167
|
if (n.type === "string") {
|
|
169
|
-
|
|
168
|
+
appendString(cur, n, src);
|
|
170
169
|
continue;
|
|
171
170
|
}
|
|
172
171
|
flush();
|
|
@@ -175,32 +174,28 @@ function texRuns(nodes, math, out) {
|
|
|
175
174
|
n.type === "displaymath" ||
|
|
176
175
|
n.type === "mathenv"
|
|
177
176
|
)
|
|
178
|
-
texRuns(n.content, true, out);
|
|
177
|
+
texRuns(n.content, true, src, out);
|
|
179
178
|
else if (n.type === "environment") {
|
|
180
179
|
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
180
|
if (!TEX_HIDDEN_ENV.test(env)) {
|
|
184
181
|
const body = n.content || [];
|
|
185
182
|
const from = (n.args || []).length ? 0 : unparsedArgsEnd(body, -1) + 1;
|
|
186
|
-
texRuns(body.slice(from), math, out);
|
|
183
|
+
texRuns(body.slice(from), math, src, out);
|
|
187
184
|
}
|
|
188
185
|
} else if (n.type === "group") {
|
|
189
|
-
texRuns(n.content, math, out);
|
|
186
|
+
texRuns(n.content, math, src, out);
|
|
190
187
|
} else if (n.type === "macro") {
|
|
191
188
|
const args = mandatory(n);
|
|
192
189
|
if (math) {
|
|
193
190
|
if (!TEX_MATH_HIDDEN.test(n.content))
|
|
194
|
-
for (const a of args) texRuns(a.content, true, out);
|
|
191
|
+
for (const a of args) texRuns(a.content, true, src, out);
|
|
195
192
|
} else if (TEX_PROSE_ARG.test(n.content)) {
|
|
196
|
-
for (const a of args) texRuns(a.content, false, out);
|
|
193
|
+
for (const a of args) texRuns(a.content, false, src, out);
|
|
197
194
|
} else if (TEX_LAST_ARG.test(n.content) && args.length) {
|
|
198
|
-
texRuns(args[args.length - 1].content, false, out);
|
|
195
|
+
texRuns(args[args.length - 1].content, false, src, out);
|
|
199
196
|
}
|
|
200
|
-
// In text, a macro with no parsed arguments may still have them, as siblings.
|
|
201
197
|
if (!math && !(n.args || []).length) i = unparsedArgsEnd(nodes, i);
|
|
202
198
|
}
|
|
203
|
-
// comment, verb, verbatim, whitespace, parbreak: not the reader's number, or not a word
|
|
204
199
|
}
|
|
205
200
|
flush();
|
|
206
201
|
return out;
|
|
@@ -212,11 +207,10 @@ function texVisibleRuns(raw) {
|
|
|
212
207
|
const document = root.find(
|
|
213
208
|
(n) => n.type === "environment" && n.env === "document",
|
|
214
209
|
);
|
|
215
|
-
return texRuns(document ? document.content : root, false, []);
|
|
210
|
+
return texRuns(document ? document.content : root, false, raw, []);
|
|
216
211
|
}
|
|
217
212
|
|
|
218
|
-
// Markdown
|
|
219
|
-
// reader sees, table cells included; these node types are not prose.
|
|
213
|
+
// Markdown node types that are not prose.
|
|
220
214
|
const MD_HIDDEN = new Set([
|
|
221
215
|
"code",
|
|
222
216
|
"inlineCode",
|
|
@@ -226,88 +220,209 @@ const MD_HIDDEN = new Set([
|
|
|
226
220
|
"math",
|
|
227
221
|
"inlineMath",
|
|
228
222
|
]);
|
|
229
|
-
|
|
223
|
+
/**
|
|
224
|
+
* The source offset of each character of a text node's value. The value is the source with
|
|
225
|
+
* markdown's escapes (`\*`) and character references (`<`) resolved, so the two are walked
|
|
226
|
+
* side by side; a character whose source cannot be followed gets null (reported, not fixed).
|
|
227
|
+
*/
|
|
228
|
+
function mdOffsets(value, src, start) {
|
|
229
|
+
const offs = [];
|
|
230
|
+
let j = start;
|
|
231
|
+
for (const ch of value) {
|
|
232
|
+
if (src[j] === ch) offs.push(j++);
|
|
233
|
+
else if (src[j] === "\\" && src[j + 1] === ch) {
|
|
234
|
+
offs.push(j + 1);
|
|
235
|
+
j += 2;
|
|
236
|
+
} else if (src[j] === "&" && /^&#?\w{1,8};/.test(src.slice(j, j + 10))) {
|
|
237
|
+
offs.push(null);
|
|
238
|
+
j = src.indexOf(";", j) + 1;
|
|
239
|
+
} else {
|
|
240
|
+
offs.push(null);
|
|
241
|
+
j++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return offs;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function mdVisibleRuns(node, src, out = []) {
|
|
230
248
|
if (!node || MD_HIDDEN.has(node.type)) return out;
|
|
231
|
-
if (node.type === "text")
|
|
232
|
-
|
|
249
|
+
if (node.type === "text") {
|
|
250
|
+
const start = node.position?.start?.offset;
|
|
251
|
+
out.push({
|
|
252
|
+
text: node.value,
|
|
253
|
+
offs:
|
|
254
|
+
typeof start === "number"
|
|
255
|
+
? mdOffsets(node.value, src, start)
|
|
256
|
+
: [...node.value].map(() => null),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
for (const c of node.children || []) mdVisibleRuns(c, src, out);
|
|
233
260
|
return out;
|
|
234
261
|
}
|
|
235
262
|
|
|
236
|
-
/** The TexSourceCode of this package carries `raw`; anything else is read through its AST. */
|
|
237
263
|
const visibleRuns = (sourceCode) =>
|
|
238
|
-
|
|
264
|
+
isTex(sourceCode)
|
|
239
265
|
? texVisibleRuns(sourceCode.raw)
|
|
240
|
-
: mdVisibleRuns(sourceCode.ast);
|
|
266
|
+
: mdVisibleRuns(sourceCode.ast, sourceCode.text);
|
|
267
|
+
|
|
268
|
+
/** A report at `[from, to)` of the file. */
|
|
269
|
+
const at = (context, from, to, rest) => {
|
|
270
|
+
const sc = context.sourceCode;
|
|
271
|
+
context.report({
|
|
272
|
+
loc: { start: sc.getLocFromIndex(from), end: sc.getLocFromIndex(to) },
|
|
273
|
+
...rest,
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// ── paper/section-word ─────────────────────────────────────────────────────────────────
|
|
241
278
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
279
|
+
// The sign (the glyph, or the `\S` macro not followed by a letter), the separator after it, and
|
|
280
|
+
// what follows: a reference (group 3) or a number (group 4).
|
|
281
|
+
const SECTION_TEX = /(§|\\S(?![A-Za-z]))(~|[ \t]*)(?=(\\(?:auto)?ref\b)|(\d))/g;
|
|
282
|
+
const SECTION_GLYPH = /§/g;
|
|
283
|
+
|
|
284
|
+
function sectionWord(context) {
|
|
285
|
+
const sc = context.sourceCode;
|
|
286
|
+
if (isTex(sc)) {
|
|
287
|
+
const seen = new Set();
|
|
288
|
+
for (const m of markupMatches(sc, SECTION_TEX)) {
|
|
289
|
+
seen.add(m.index);
|
|
290
|
+
const word = m[3] ? "Section~" : "Section ";
|
|
291
|
+
const range = [m.index, m.index + m[0].length];
|
|
292
|
+
at(context, range[0], range[1], {
|
|
293
|
+
messageId: "sign",
|
|
294
|
+
fix: (f) => f.replaceTextRange(range, word),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// A glyph before neither a reference nor a number: reported, not rewritten — there is no
|
|
298
|
+
// single right word for a doubled sign or a sign before prose.
|
|
299
|
+
for (const m of markupMatches(sc, SECTION_GLYPH))
|
|
300
|
+
if (!seen.has(m.index))
|
|
301
|
+
at(context, m.index, m.index + 1, { messageId: "sign" });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (const r of visibleRuns(sc))
|
|
305
|
+
for (const m of r.text.matchAll(/§([ \t]*)(\d)?/g)) {
|
|
306
|
+
const from = r.offs[m.index];
|
|
307
|
+
if (from === null || from === undefined) continue;
|
|
308
|
+
// The sign and the space after it; the number stays.
|
|
309
|
+
const range = [from, from + 1 + m[1].length];
|
|
310
|
+
at(context, range[0], range[1], {
|
|
311
|
+
messageId: "sign",
|
|
312
|
+
...(m[2] ? { fix: (f) => f.replaceTextRange(range, "Section ") } : {}),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── paper/leading-zero ─────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
// The lexeme, matched inside ONE run of visible text. The lookbehind keeps arXiv ids
|
|
320
|
+
// (2310.05736) out: there the dot follows a digit.
|
|
321
|
+
const BARE_DECIMAL = /(?<![\d.\w])\.\d{2,}\b/g;
|
|
322
|
+
|
|
323
|
+
// A paragraph or section sign — the glyph, or `\P`/`\S` not followed by a letter — and what may
|
|
324
|
+
// sit between it and the number. Both read the SOURCE, because in LaTeX `\P\P.42` reaches the
|
|
325
|
+
// visible runs as `.42` alone: the macros end the run.
|
|
326
|
+
const DESIGNATOR_BEFORE = /(?:[¶§]|\\[PS](?![A-Za-z]))[ \t~]*$/;
|
|
327
|
+
const DESIGNATED = /(?:[¶§]|\\[PS](?![A-Za-z]))[ \t~]*\.\d/;
|
|
328
|
+
|
|
329
|
+
/** The source of the paragraph holding `i`, up to `i`: from the last blank line before it. */
|
|
330
|
+
const paragraphBefore = (src, i) => {
|
|
331
|
+
const head = src.slice(0, i);
|
|
332
|
+
const blank = [...head.matchAll(/\n[ \t]*\n/g)].at(-1);
|
|
333
|
+
return head.slice(blank ? blank.index + blank[0].length : 0);
|
|
249
334
|
};
|
|
250
335
|
|
|
336
|
+
function leadingZero(context) {
|
|
337
|
+
const sc = context.sourceCode;
|
|
338
|
+
const src = isTex(sc) ? sc.raw : sc.text;
|
|
339
|
+
for (const r of visibleRuns(sc))
|
|
340
|
+
for (const m of r.text.matchAll(BARE_DECIMAL)) {
|
|
341
|
+
const dot = r.offs[m.index];
|
|
342
|
+
if (dot === null || dot === undefined) continue;
|
|
343
|
+
const before = paragraphBefore(src, dot);
|
|
344
|
+
if (DESIGNATOR_BEFORE.test(before)) continue;
|
|
345
|
+
const zero = (f) => f.insertTextBeforeRange([dot, dot], "0");
|
|
346
|
+
at(
|
|
347
|
+
context,
|
|
348
|
+
dot,
|
|
349
|
+
dot + 1,
|
|
350
|
+
DESIGNATED.test(before)
|
|
351
|
+
? {
|
|
352
|
+
messageId: "ambiguous",
|
|
353
|
+
data: { n: m[0] },
|
|
354
|
+
suggest: [
|
|
355
|
+
{ messageId: "insertZero", data: { n: m[0] }, fix: zero },
|
|
356
|
+
],
|
|
357
|
+
}
|
|
358
|
+
: { messageId: "bare", data: { n: m[0] }, fix: zero },
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── paper/figure-ref-style ─────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
const FIG_REF = /\b(Fig\.|Figure)(?=~?\\(?:ref|autoref)\b)/g;
|
|
366
|
+
|
|
367
|
+
function figureRefStyle(context) {
|
|
368
|
+
const sc = context.sourceCode;
|
|
369
|
+
if (!isTex(sc)) return;
|
|
370
|
+
const all = markupMatches(sc, FIG_REF);
|
|
371
|
+
const short = all.filter((m) => m[1] === "Fig.");
|
|
372
|
+
const long = all.filter((m) => m[1] === "Figure");
|
|
373
|
+
if (!short.length || !long.length) return;
|
|
374
|
+
// The minority form is rewritten to the majority one; on a tie, to the full word.
|
|
375
|
+
const [minority, word] =
|
|
376
|
+
short.length > long.length ? [long, "Fig."] : [short, "Figure"];
|
|
377
|
+
for (const m of minority) {
|
|
378
|
+
const range = [m.index, m.index + m[1].length];
|
|
379
|
+
at(context, range[0], range[1], {
|
|
380
|
+
messageId: "mixed",
|
|
381
|
+
data: { form: m[1], word, n: String(all.length - minority.length) },
|
|
382
|
+
fix: (f) => f.replaceTextRange(range, word),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const rule = (description, messages, check, meta = {}) => ({
|
|
388
|
+
meta: {
|
|
389
|
+
type: "suggestion",
|
|
390
|
+
fixable: "code",
|
|
391
|
+
docs: { description },
|
|
392
|
+
schema: [],
|
|
393
|
+
messages,
|
|
394
|
+
...meta,
|
|
395
|
+
},
|
|
396
|
+
create: (context) => ({ "root:exit": () => check(context) }),
|
|
397
|
+
});
|
|
398
|
+
|
|
251
399
|
export default {
|
|
252
400
|
rules: {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
},
|
|
401
|
+
"section-word": rule(
|
|
402
|
+
"`§` / `\\S` instead of the word Section (a reviewer's to-do); fixable",
|
|
403
|
+
{
|
|
404
|
+
sign: "`§` instead of the word «Section» — `paperlint lint --fix` writes it",
|
|
273
405
|
},
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
};
|
|
406
|
+
sectionWord,
|
|
407
|
+
),
|
|
408
|
+
"leading-zero": rule(
|
|
409
|
+
"a decimal below 1 written without its leading zero (IEEE / ISO 80000-1); fixable",
|
|
410
|
+
{
|
|
411
|
+
bare: "`{{n}}` has no leading zero — write `0{{n}}` (IEEE / ISO 80000-1); `paperlint lint --fix` inserts it",
|
|
412
|
+
ambiguous:
|
|
413
|
+
"`{{n}}` in a paragraph that cites `¶`/`§` numbers: a decimal without its leading zero, or another paragraph number? Not fixed automatically — write `0{{n}}` for a decimal, or put the sign before a paragraph number",
|
|
414
|
+
insertZero: "It is a decimal: write `0{{n}}`",
|
|
415
|
+
},
|
|
416
|
+
leadingZero,
|
|
417
|
+
{ hasSuggestions: true },
|
|
418
|
+
),
|
|
419
|
+
"figure-ref-style": rule(
|
|
420
|
+
"`Fig.~\\ref` and `Figure~\\ref` mixed in one document; fixable to the majority form",
|
|
421
|
+
{
|
|
422
|
+
mixed:
|
|
423
|
+
"`{{form}}` beside {{n}} × `{{word}}` in one document — use one form; `paperlint lint --fix` writes `{{word}}`",
|
|
310
424
|
},
|
|
311
|
-
|
|
425
|
+
figureRefStyle,
|
|
426
|
+
),
|
|
312
427
|
},
|
|
313
428
|
};
|