paperlint 7.0.0 → 7.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.
Files changed (75) hide show
  1. package/README.md +18 -11
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +2 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/domain/body-pages.d.ts +29 -0
  6. package/dist/domain/body-pages.d.ts.map +1 -1
  7. package/dist/domain/body-pages.js +25 -0
  8. package/dist/domain/body-pages.js.map +1 -1
  9. package/dist/paper-includes.d.ts +3 -0
  10. package/dist/paper-includes.d.ts.map +1 -1
  11. package/dist/paper-includes.js +17 -2
  12. package/dist/paper-includes.js.map +1 -1
  13. package/dist/presets.d.ts +10 -3
  14. package/dist/presets.d.ts.map +1 -1
  15. package/dist/presets.js +8 -7
  16. package/dist/presets.js.map +1 -1
  17. package/dist/submission.d.ts +8 -0
  18. package/dist/submission.d.ts.map +1 -1
  19. package/dist/submission.js +19 -7
  20. package/dist/submission.js.map +1 -1
  21. package/dist/tex-requirements.d.ts +9 -0
  22. package/dist/tex-requirements.d.ts.map +1 -1
  23. package/dist/tex-requirements.js +7 -3
  24. package/dist/tex-requirements.js.map +1 -1
  25. package/dist/tex-venue-rules.d.ts +5 -2
  26. package/dist/tex-venue-rules.d.ts.map +1 -1
  27. package/dist/tex-venue-rules.js +29 -10
  28. package/dist/tex-venue-rules.js.map +1 -1
  29. package/dist/venue-rules.d.ts +2 -1
  30. package/dist/venue-rules.d.ts.map +1 -1
  31. package/dist/venue-rules.js +55 -7
  32. package/dist/venue-rules.js.map +1 -1
  33. package/docs/configuration.md +1 -1
  34. package/docs/e2e.md +14 -12
  35. package/docs/rules/format/page-limit.md +18 -1
  36. package/docs/rules/tex/template.md +25 -5
  37. package/docs/rules/tex/venue-leftover.md +13 -0
  38. package/docs/rules.md +63 -55
  39. package/docs/submission.md +5 -1
  40. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  41. package/fixtures/build-e2e/msr/expect.json +18 -0
  42. package/fixtures/build-e2e/msr/paper.tex +49 -0
  43. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  44. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  45. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  46. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  47. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  48. package/package.json +1 -1
  49. package/presets/ieee-conference.md +1 -1
  50. package/presets/msr.jsonc +71 -0
  51. package/presets/msr.md +94 -0
  52. package/presets/venue-profile.schema.json +25 -0
  53. package/skills/camera-ready/SKILL.md +8 -1
  54. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  55. package/skills/submission-portal/SKILL.md +2 -2
  56. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  57. package/skills/submit-paper/SKILL.md +9 -1
  58. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  59. package/src/cli.ts +7 -2
  60. package/src/domain/body-pages.test.ts +144 -0
  61. package/src/domain/body-pages.ts +61 -1
  62. package/src/new-paper.test.ts +27 -0
  63. package/src/paper-includes.test.ts +26 -0
  64. package/src/paper-includes.ts +20 -2
  65. package/src/paper-settings-commands.test.ts +1 -1
  66. package/src/preset-card-rule.test.ts +1 -0
  67. package/src/presets.test.ts +66 -1
  68. package/src/presets.ts +23 -6
  69. package/src/submission.test.ts +42 -3
  70. package/src/submission.ts +28 -7
  71. package/src/tex-requirements.ts +21 -4
  72. package/src/tex-venue-rules.test.ts +161 -0
  73. package/src/tex-venue-rules.ts +45 -15
  74. package/src/venue-rules.test.ts +173 -2
  75. package/src/venue-rules.ts +71 -10
package/presets/msr.md ADDED
@@ -0,0 +1,94 @@
1
+ ---
2
+ title: "MSR — venue card"
3
+ ---
4
+
5
+ # MSR (Technical Papers)
6
+
7
+ The card of the venue preset `paperlint:msr` ([`msr.jsonc`](msr.jsonc)): what the venue asks for,
8
+ in prose. The numbers a machine checks live in the preset; the section at the end is generated from
9
+ it. The `submit-paper` skill reads this card for the venue's specifics.
10
+
11
+ **Re-verify the call for papers every year**
12
+ ([2027.msrconf.org/track/msr-2027-technical-papers](https://2027.msrconf.org/track/msr-2027-technical-papers)):
13
+ the template changed from ACM to IEEE in 2027 («Note that IEEE format is being used this year,
14
+ whereas last year it was ACM format.»), and limits and dates change between editions.
15
+
16
+ ## The venue (2027 edition)
17
+
18
+ - **Venue**: the Technical Papers track of **MSR 2027**, the International Conference on Mining
19
+ Software Repositories.
20
+ - **Portal**: HotCRP, `https://msr2027.hotcrp.com/` («Papers must be submitted through HotCRP»).
21
+ - **Dates** (the call, «AoE (UTC-12h)»): abstract **2026-10-20**, paper **2026-10-23**; early
22
+ reject notification 2026-12-03; author response 2026-12-04 to 2026-12-08; notification
23
+ 2027-01-08; camera-ready 2027-01-26. The call's prose names the weekdays «Mon, 20 Oct 2026» and
24
+ «Thurs, 23 Oct 2026», its date table «Tue 20 Oct 2026» and «Fri 23 Oct 2026»; the calendar sides
25
+ with the table (2026-10-20 is a Tuesday).
26
+ - 🔴 **The portal closes earlier than the call says.** Read 2026-10-05 on
27
+ `https://msr2027.hotcrp.com/deadlines`: «Submission deadline: Tuesday Oct 20, 2026, 12 AM EDT»
28
+ (`"sub":1792468800`, 2026-10-20 04:00 UTC) and «Resubmission deadline: Friday Oct 23, 2026, 12 AM
29
+ EDT» («Completed submissions may be updated until this deadline»). Midnight EDT at the start of
30
+ each day is about a day and a half before the end of that day AoE. The portal is what refuses the
31
+ upload: plan for its times, and re-read the page, since chairs change them.
32
+ - **Format**, verbatim from the call: «LaTeX users must use \documentclass[10pt,conference]{IEEEtran}
33
+ without including the compsoc or compsocconf options», «title in 24pt font and full text in 10pt
34
+ type», and «Alterations of spacing, font size, and other changes that deviate from the
35
+ instructions may result in desk rejection without further review.»
36
+ - **Double-anonymous**: «The MSR 2027 Technical Track will employ a double-anonymous review process.
37
+ Thus, no submission may reveal its authors' identities.» The call adds: names omitted, prior work
38
+ cited in the third person, and a preprint must not say it was submitted to MSR. The portal's data
39
+ says `"blind":true`.
40
+ - **Attendance**: «If a submission is accepted, at least one author of the paper is required to
41
+ register for MSR 2027 and present the paper.» The call does not say whether a remote presentation
42
+ is allowed.
43
+ - **New in 2027**, from the call: a submission may be desk rejected for fabricated references
44
+ («citations to papers, authors, venues, datasets, tools, or other artifacts that do not exist»),
45
+ clear signs of unverified AI-generated text, or hidden instructions aimed at automated tools.
46
+
47
+ ## Paper kinds and limits
48
+
49
+ | kind | main-text pages | pages of only references | what the call says |
50
+ | ----------- | --------------: | -----------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `technical` | 10 | 2 | «All submissions must not exceed 10 pages for the main text, inclusive of all figures, tables, appendices, etc. Two more pages containing only references are permitted.» |
52
+
53
+ «Accepted papers will be allowed one extra page for the main text of the camera-ready version.» —
54
+ not the submission's limit, so not in the preset.
55
+
56
+ 🔴 **Appendices count in the ten pages**, and a page that holds anything but references is a
57
+ main-text page. banal's count gets both wrong on an IEEEtran build (an appendix before the
58
+ references turns its pages into «bibliography»; one after them into «references»), so the preset
59
+ counts from the text (`format.appendix_in_body`): the main text is every page up to the references —
60
+ that page too when text stands above the heading — and every page from an appendix that follows the
61
+ references; the pages between hold only references. **Load hyperref**: an appendix after the
62
+ references is found by hyperref's anchor, and without it is counted as reference pages. See
63
+ `docs/rules/format/page-limit.md`.
64
+
65
+ ## What else the preset encodes
66
+
67
+ - **The IEEE template**, through `extends: paperlint:ieee-conference` — letter paper, two columns,
68
+ Times at 10 pt, IEEEtran's TeX packages ([`ieee-conference.md`](ieee-conference.md)) — with
69
+ `10pt` and `conference` required in the class line and `compsoc` and `compsocconf` forbidden
70
+ (`tex/template`, `template_forbids`).
71
+ - **Double-blind review** (`blind`), so `anonymity/identity` checks the PDF against the paper's
72
+ declared `identity`.
73
+ - **Where submissions go** (`portal`): `paperlint submission` reads and updates a paper's
74
+ submission through HotCRP's API on that site.
75
+ - **Names**: `MSR`, what `tex/venue-leftover` looks for in another venue's paper — only with a year
76
+ beside it (`mentions: with-year`: «MSR 2027», «MSR'27»), since «MSR» is also Microsoft Research
77
+ and an acronym papers define for themselves.
78
+ - **Not encoded**: the capitalization of the title and headings (`tex/heading-case` stays off: the
79
+ IEEE guidelines' rule has not been read and quoted here), and the register of the body (no
80
+ accepted MSR paper in the IEEE template exists yet to anchor `tex/contrast-frames` and its
81
+ siblings).
82
+
83
+ ## What the preset resolves to
84
+
85
+ <!-- paperlint:preset-rules -->
86
+ <!-- prettier-ignore-start -->
87
+ <!-- Generated from msr.jsonc by `npx eslint --fix` (rule preset/card-rules). Edit the preset, not this section. -->
88
+
89
+ **`paperlint:msr`** — venue preset for MSR · call for papers: <https://2027.msrconf.org/track/msr-2027-technical-papers> · extends `paperlint:ieee-conference`
90
+
91
+ No preset of this chain sets a rule: under it, every rule keeps paperlint's default.
92
+
93
+ <!-- prettier-ignore-end -->
94
+ <!-- /paperlint:preset-rules -->
@@ -34,6 +34,16 @@
34
34
  "type": "string",
35
35
  "minLength": 1
36
36
  },
37
+ "template_forbids": {
38
+ "description": "Class options the venue's call forbids in the \\documentclass line (`[\"compsoc\", \"compsocconf\"]`: MSR's «without including the compsoc or compsocconf options»). `tex/template` reports each one a paper's class line carries. Read with the `template` of the same file, and only with it: a child's `template` replaces both.",
39
+ "type": "array",
40
+ "minItems": 1,
41
+ "uniqueItems": true,
42
+ "items": {
43
+ "type": "string",
44
+ "minLength": 1
45
+ }
46
+ },
37
47
  "blind": {
38
48
  "description": "The venue reviews double-blind: its call says submissions must be anonymized. Read by `anonymity/identity`, which then requires the paper's `identity` and finds none of it in the PDF. Set only where the venue says so, with the quote. A child's value replaces its parent's.",
39
49
  "type": "boolean"
@@ -47,6 +57,11 @@
47
57
  "minLength": 1
48
58
  }
49
59
  },
60
+ "mentions": {
61
+ "description": "When the venue's `name` and `aliases` count as naming it in a paper's text (`tex/venue-leftover`). Absent: any whole-word use. `with-year`: only with a year beside the name — `MSR 2027`, `MSR'27`, `MSR~2027` — for a name that is also a common acronym (MSR: Microsoft Research, or an acronym a paper defines), where a bare use names something else far more often than the venue. A child's value replaces its parent's.",
62
+ "type": "string",
63
+ "enum": ["with-year"]
64
+ },
50
65
  "portal": {
51
66
  "description": "Where the venue takes submissions, read by `paperlint submission`: the portal's kind (an adapter exists for `hotcrp`) and the site's base URL, the one its pages and `/api/` hang off. A paper says which submission is its own with `submission: { id }` in its paperlint.json. A child's value replaces its parent's.",
52
67
  "type": "object",
@@ -86,6 +101,9 @@
86
101
  "description": "What the venue's call for papers sets. Every key is optional; a child preset's key replaces its parent's, and a child's kind replaces that kind.",
87
102
  "type": "object",
88
103
  "additionalProperties": false,
104
+ "dependencies": {
105
+ "appendix_in_body": ["body_ends_at"]
106
+ },
89
107
  "properties": {
90
108
  "page_size": {
91
109
  "type": "string",
@@ -140,6 +158,10 @@
140
158
  "type": "string",
141
159
  "enum": ["references"]
142
160
  },
161
+ "appendix_in_body": {
162
+ "description": "With `body_ends_at: references`: the venue counts its appendices in the body limit and allows reference pages that hold only references (MSR: «10 pages for the main text, inclusive of all figures, tables, appendices, etc. Two more pages containing only references are permitted.»). `format/page-limit` then counts as body the pages up to the references and, when the appendix starts after them, every page from the appendix (found by hyperref's anchor) to the last; the pages between are the reference pages, judged against `ref_pages_max`. An appendix before the references is body and ends nothing.",
163
+ "type": "boolean"
164
+ },
143
165
  "page_limits_not_gated": {
144
166
  "type": "object"
145
167
  },
@@ -347,6 +369,9 @@
347
369
  }
348
370
  },
349
371
  "required": ["type"],
372
+ "dependencies": {
373
+ "template_forbids": ["template"]
374
+ },
350
375
  "allOf": [
351
376
  {
352
377
  "if": {
@@ -4,7 +4,7 @@ description: On acceptance, turn the anonymized-for-review paper into the de-ano
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:5c85ef4004c31956 compiled from skills/camera-ready/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:1c9ad89e55c3e89a compiled from skills/camera-ready/SKILL.md.spec.ts -->
8
8
 
9
9
  # camera-ready — de-anonymize an accepted paper into the final proceedings version
10
10
 
@@ -94,6 +94,13 @@ and going over is a hard reject at the proceedings stage. Recompile
94
94
  If tight, ACM allows references to spill into the reference-only extra pages.
95
95
 
96
96
  ## 5. Ship
97
+ - **Before the final PDF goes up, hand the last check to the author, in plain words.** Tell them that
98
+ paperlint passing is not the venue or the publisher accepting the format, and ask them to open the
99
+ exact PDF they are about to upload and check it against the venue's call for papers and its
100
+ camera-ready instructions themselves — the page count and what counts toward the limit (the
101
+ camera-ready's extra page, if any), the template and its class options, the author block and
102
+ acknowledgements now de-anonymized, and that every figure, table and reference renders. Upload once
103
+ they say they have looked.
97
104
  - Rebuild the final PDF, upload to the camera-ready system (often the same HotCRP, or an ACM eRights /
98
105
  TAPS pipeline — follow the copyright email).
99
106
  - Complete the **ACM/IEEE eRights** copyright form → paste the returned copyright block + DOI into the
@@ -109,6 +109,13 @@ and going over is a hard reject at the proceedings stage. Recompile
109
109
  If tight, ACM allows references to spill into the reference-only extra pages.
110
110
 
111
111
  ## 5. Ship
112
+ - **Before the final PDF goes up, hand the last check to the author, in plain words.** Tell them that
113
+ paperlint passing is not the venue or the publisher accepting the format, and ask them to open the
114
+ exact PDF they are about to upload and check it against the venue's call for papers and its
115
+ camera-ready instructions themselves — the page count and what counts toward the limit (the
116
+ camera-ready's extra page, if any), the template and its class options, the author block and
117
+ acknowledgements now de-anonymized, and that every figure, table and reference renders. Upload once
118
+ they say they have looked.
112
119
  - Rebuild the final PDF, upload to the camera-ready system (often the same HotCRP, or an ACM eRights /
113
120
  TAPS pipeline — follow the copyright email).
114
121
  - Complete the **ACM/IEEE eRights** copyright form → paste the returned copyright block + DOI into the
@@ -4,7 +4,7 @@ description: "Read or change a paper's submission on the venue's HotCRP portal v
4
4
  allowed-tools: [Read, Grep, Glob, Bash]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:deb3d74f5c3f93a8 compiled from skills/submission-portal/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:4b3a8c0b51ebe206 compiled from skills/submission-portal/SKILL.md.spec.ts -->
8
8
 
9
9
  # submission-portal — read and update the submission on the portal, then prove it landed
10
10
 
@@ -14,7 +14,7 @@ supported; for any other portal the command says so and the upload is done by ha
14
14
  ## What must be in place
15
15
 
16
16
  - **The venue preset declares the portal**: `"portal": { "kind": "hotcrp", "url": "<the site>" }`
17
- (`paperlint:aidc` has it). For your own venue preset, add it there.
17
+ (`paperlint:aidc` and `paperlint:msr` have it). For your own venue preset, add it there.
18
18
  - **The paper's `paperlint.json` names its submission**: `"submission": { "id": <number> }` — the
19
19
  number HotCRP shows for the paper.
20
20
  - **A token in `HOTCRP_TOKEN`**, created by the author on the HotCRP site under Account settings →
@@ -15,7 +15,7 @@ supported; for any other portal the command says so and the upload is done by ha
15
15
  ## What must be in place
16
16
 
17
17
  - **The venue preset declares the portal**: \`"portal": { "kind": "hotcrp", "url": "<the site>" }\`
18
- (\`paperlint:aidc\` has it). For your own venue preset, add it there.
18
+ (\`paperlint:aidc\` and \`paperlint:msr\` have it). For your own venue preset, add it there.
19
19
  - **The paper's \`paperlint.json\` names its submission**: \`"submission": { "id": <number> }\` — the
20
20
  number HotCRP shows for the paper.
21
21
  - **A token in \`HOTCRP_TOKEN\`**, created by the author on the HotCRP site under Account settings →
@@ -4,7 +4,7 @@ description: End-to-end playbook for submitting a peer-reviewed paper to a doubl
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:8f53cae32633b002 compiled from skills/submit-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:81d5777962c2864a compiled from skills/submit-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # submit-paper — get a reviewed paper from "done" to "ready for review"
10
10
 
@@ -20,6 +20,7 @@ paper against:
20
20
  - `presets/agenticdev.md` — AgenticDev @ ASE (workshop; ASE workshop proceedings).
21
21
  - `presets/aisec.md` — AISec @ ACM CCS (security workshop; ACM DL; harder bar).
22
22
  - `presets/aidc.md` — AIDC @ IEEE ACSAC (workshop; IEEE template, LLM Usage Statement).
23
+ - `presets/msr.md` — MSR Technical Papers (conference; IEEE template from 2027, appendices count in the page limit).
23
24
  - `presets/realm.md` — REALM @ EMNLP (ACL family, OpenReview).
24
25
 
25
26
  **The card is prose; the machine-checked format is the PRESET beside it.** A paper declares its venue
@@ -110,6 +111,13 @@ Many workshop HotCRPs have **no supplementary-upload field** — check first; if
110
111
  An independent researcher with no ties usually checks **none** — that's honest.
111
112
 
112
113
  ## 5. Submit
114
+ - **Before the PDF goes up — and before every Replace — hand the last check to the author, in plain
115
+ words.** Tell them that paperlint passing is not the venue accepting the format: the preset is
116
+ transcribed from a call that can change, and any check can miss something. Ask them to open the
117
+ exact PDF they are about to upload and check it against the venue's call for papers themselves —
118
+ the page count and what counts toward the limit (appendices, references), the template and its
119
+ class options, anonymity, and that every figure, table and reference renders. Upload once they say
120
+ they have looked. (`paperlint submission update` prints the same request before it sends.)
113
121
  - **Save and submit**, then **mark it "ready for review."** A saved draft alone is NOT evaluated — this
114
122
  is the step people miss.
115
123
  - You can edit until the deadline: use **Replace** on the Submission field to swap the PDF for a late
@@ -36,6 +36,7 @@ paper against:
36
36
  - \`presets/agenticdev.md\` — AgenticDev @ ASE (workshop; ASE workshop proceedings).
37
37
  - \`presets/aisec.md\` — AISec @ ACM CCS (security workshop; ACM DL; harder bar).
38
38
  - \`presets/aidc.md\` — AIDC @ IEEE ACSAC (workshop; IEEE template, LLM Usage Statement).
39
+ - \`presets/msr.md\` — MSR Technical Papers (conference; IEEE template from 2027, appendices count in the page limit).
39
40
  - \`presets/realm.md\` — REALM @ EMNLP (ACL family, OpenReview).
40
41
 
41
42
  **The card is prose; the machine-checked format is the PRESET beside it.** A paper declares its venue
@@ -126,6 +127,13 @@ Many workshop HotCRPs have **no supplementary-upload field** — check first; if
126
127
  An independent researcher with no ties usually checks **none** — that's honest.
127
128
 
128
129
  ## 5. Submit
130
+ - **Before the PDF goes up — and before every Replace — hand the last check to the author, in plain
131
+ words.** Tell them that paperlint passing is not the venue accepting the format: the preset is
132
+ transcribed from a call that can change, and any check can miss something. Ask them to open the
133
+ exact PDF they are about to upload and check it against the venue's call for papers themselves —
134
+ the page count and what counts toward the limit (appendices, references), the template and its
135
+ class options, anonymity, and that every figure, table and reference renders. Upload once they say
136
+ they have looked. (\`paperlint submission update\` prints the same request before it sends.)
129
137
  - **Save and submit**, then **mark it "ready for review."** A saved draft alone is NOT evaluated — this
130
138
  is the step people miss.
131
139
  - You can edit until the deadline: use **Replace** on the Submission field to swap the PDF for a late
package/src/cli.ts CHANGED
@@ -42,7 +42,12 @@ import {
42
42
  asEslintResults,
43
43
  } from "./structure.ts";
44
44
  import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
45
- import { includeBlocks, paperBodies, unreadLines } from "./paper-includes.ts";
45
+ import {
46
+ FRAGMENT_FILES,
47
+ includeBlocks,
48
+ paperBodies,
49
+ unreadLines,
50
+ } from "./paper-includes.ts";
46
51
  import { prepareEngine } from "./build-engine.ts";
47
52
  import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
48
53
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
@@ -462,7 +467,7 @@ export function buildConfig(
462
467
  const fragments: readonly ConfigBlock[] = texLanguage
463
468
  ? [
464
469
  {
465
- files: ["**/*.tex"],
470
+ files: [...FRAGMENT_FILES],
466
471
  ignores: ["**/paper.tex"],
467
472
  plugins: {
468
473
  tex: {
@@ -3,6 +3,8 @@ import {
3
3
  bodyEnd,
4
4
  isAppendixHeading,
5
5
  isReferencesHeading,
6
+ pageSplit,
7
+ type Anchors,
6
8
  } from "./body-pages.ts";
7
9
 
8
10
  describe("isReferencesHeading", () => {
@@ -298,3 +300,145 @@ describe("bodyEnd: where the appendix is not taken, or not found", () => {
298
300
  });
299
301
  });
300
302
  });
303
+
304
+ describe("pageSplit: the appendix counts as body, the reference pages are the ones holding only references", () => {
305
+ it("no appendix: the body up to the references, the rest references", () => {
306
+ expect(
307
+ pageSplit(["body", "end\nReferences\n[1] A.", "[2] B.", "[3] C."], {
308
+ bib: 2,
309
+ appendix: null,
310
+ }),
311
+ ).toEqual({
312
+ kind: "found",
313
+ page: 2,
314
+ bodyPages: 2,
315
+ refPages: 2,
316
+ appendixFrom: null,
317
+ });
318
+ });
319
+
320
+ it("🔴 an appendix before the references is body: it does not end the body", () => {
321
+ expect(
322
+ pageSplit(
323
+ [
324
+ "body",
325
+ "Appendix A.\nDetails",
326
+ "more details\nReferences\n[1] A.",
327
+ "[2] B.",
328
+ ],
329
+ { bib: 3, appendix: 2 },
330
+ ),
331
+ ).toEqual({
332
+ kind: "found",
333
+ page: 3,
334
+ bodyPages: 3,
335
+ refPages: 1,
336
+ appendixFrom: null,
337
+ });
338
+ });
339
+
340
+ it("🔴 an appendix after the references is body: the pages from it to the end", () => {
341
+ expect(
342
+ pageSplit(
343
+ [
344
+ "body",
345
+ "end\nReferences\n[1] A.",
346
+ "[2] B.",
347
+ "[3] C.\nAppendix A.\nx",
348
+ "y",
349
+ ],
350
+ { bib: 2, appendix: 4 },
351
+ ),
352
+ ).toEqual({
353
+ kind: "found",
354
+ page: 2,
355
+ bodyPages: 4,
356
+ refPages: 1,
357
+ appendixFrom: 4,
358
+ });
359
+ });
360
+ });
361
+
362
+ describe("pageSplit: the appendix and the references on one page", () => {
363
+ it("an appendix after the references on their own page: that page is counted once", () => {
364
+ expect(
365
+ pageSplit(["body", "end\nReferences\n[1] A.\nAppendix A.\nx"], {
366
+ bib: 2,
367
+ appendix: 2,
368
+ }),
369
+ ).toEqual({
370
+ kind: "found",
371
+ page: 2,
372
+ bodyPages: 2,
373
+ refPages: 0,
374
+ appendixFrom: 2,
375
+ });
376
+ });
377
+
378
+ it("an appendix above the references on their page is body before them, like any text", () => {
379
+ expect(
380
+ pageSplit(["body", "end\nAppendix A.\nx\nReferences\n[1] A.", "[2] B."], {
381
+ bib: 2,
382
+ appendix: 2,
383
+ }),
384
+ ).toEqual({
385
+ kind: "found",
386
+ page: 2,
387
+ bodyPages: 2,
388
+ refPages: 1,
389
+ appendixFrom: null,
390
+ });
391
+ });
392
+
393
+ it("the references at the top of their page: that page holds only references", () => {
394
+ expect(
395
+ pageSplit(["body", "References\n[1] A.", "Appendix A.\nx"], {
396
+ bib: 2,
397
+ appendix: 3,
398
+ }),
399
+ ).toEqual({
400
+ kind: "found",
401
+ page: 2,
402
+ bodyPages: 2,
403
+ refPages: 1,
404
+ appendixFrom: 3,
405
+ });
406
+ });
407
+ });
408
+
409
+ describe("pageSplit: what is said rather than counted, and what is not looked for", () => {
410
+ it.each<[string, readonly string[], Anchors, string]>([
411
+ [
412
+ "no references heading",
413
+ ["body", "Appendix\nx"],
414
+ { bib: null, appendix: 2 },
415
+ "missing",
416
+ ],
417
+ [
418
+ "an appendix anchor with no heading",
419
+ ["body", "References\n[1] A.", "x"],
420
+ { bib: 2, appendix: 3 },
421
+ "disagree",
422
+ ],
423
+ [
424
+ "a references anchor with no heading",
425
+ ["body", "x"],
426
+ { bib: 2, appendix: null },
427
+ "disagree",
428
+ ],
429
+ ])("%s: said, not counted", (_, pages, anchors, kind) => {
430
+ expect(pageSplit(pages, anchors).kind).toBe(kind);
431
+ });
432
+
433
+ it("without anchors the references heading is found in the text, and no appendix is looked for", () => {
434
+ expect(
435
+ pageSplit(["body", "end\nReferences\n[1] A.", "Appendix A.\nx"]),
436
+ ).toEqual({
437
+ kind: "found",
438
+ page: 2,
439
+ bodyPages: 2,
440
+ refPages: 1,
441
+ appendixFrom: null,
442
+ });
443
+ });
444
+ });
@@ -186,8 +186,11 @@ function appendixAt(
186
186
  };
187
187
  }
188
188
 
189
+ /** A body end that was found. */
190
+ type Found = Extract<BodyEnd, { readonly kind: "found" }>;
191
+
189
192
  /** The body's end at a heading: the heading's page counts when body text stands above it. */
190
- function endAt(pages: readonly PageReading[], at: At): BodyEnd {
193
+ function endAt(pages: readonly PageReading[], at: At): Found {
191
194
  const running = runningLines(pages);
192
195
  const body = at.above.some((l) => /\p{L}/u.test(l) && !running.has(shape(l)));
193
196
  return {
@@ -221,3 +224,60 @@ export function bodyEnd(
221
224
  .toSorted((a, b) => a.page - b.page || a.line - b.line)[0];
222
225
  return first === undefined ? { kind: "missing" } : endAt(pages, first);
223
226
  }
227
+
228
+ /**
229
+ * The pages of a paper split as a venue counts them when its body limit includes the appendices
230
+ * and only the pages holding nothing but references are reference pages (MSR: «10 pages for the
231
+ * main text, inclusive of all figures, tables, appendices, etc. Two more pages containing only
232
+ * references are permitted.»):
233
+ *
234
+ * body the pages up to the references — their page too when body text stands above the
235
+ * heading — and, when the appendix starts after the references, every page from the
236
+ * appendix's to the last, which holds appendix text
237
+ * references the pages between, which hold only references
238
+ *
239
+ * An appendix before the references is body like any other text, and ends nothing. The appendix is
240
+ * found by hyperref's anchor only (`appendixAt`): without hyperref, an appendix after the references
241
+ * is not told from them and is counted with them. Pure.
242
+ */
243
+ export type PageSplit =
244
+ | {
245
+ readonly kind: "found";
246
+ /** 1-based page holding the references heading. */
247
+ readonly page: number;
248
+ readonly bodyPages: number;
249
+ readonly refPages: number;
250
+ /** 1-based page the appendix starts on, when it follows the references; else null. */
251
+ readonly appendixFrom: number | null;
252
+ }
253
+ | { readonly kind: "missing" }
254
+ | Extract<BodyEnd, { readonly kind: "disagree" }>;
255
+
256
+ /** Whether heading `a` stands after heading `b` in the document. */
257
+ const after = (a: At, b: At): boolean =>
258
+ a.page > b.page || (a.page === b.page && a.line > b.line);
259
+
260
+ export function pageSplit(
261
+ texts: readonly string[],
262
+ anchors: Anchors = { bib: null, appendix: null },
263
+ ): PageSplit {
264
+ const pages = texts.map(readPage);
265
+ const refs = referencesAt(pages, anchors.bib);
266
+ const appendix = appendixAt(pages, anchors.appendix);
267
+ if (refs.kind === "disagree") return refs;
268
+ if (appendix.kind === "disagree") return appendix;
269
+ if (refs.kind === "missing") return { kind: "missing" };
270
+ const upTo = endAt(pages, refs).bodyPages;
271
+ const tail =
272
+ appendix.kind === "at" && after(appendix, refs) ? appendix.page : null;
273
+ // The pages from the appendix to the end, less the ones already counted up to the references.
274
+ const appendixPages = tail === null ? 0 : pages.length - Math.max(tail, upTo);
275
+ const bodyPages = upTo + appendixPages;
276
+ return {
277
+ kind: "found",
278
+ page: refs.page + 1,
279
+ bodyPages,
280
+ refPages: pages.length - bodyPages,
281
+ appendixFrom: tail === null ? null : tail + 1,
282
+ };
283
+ }
@@ -234,6 +234,33 @@ describe("paperlint new --venue / --kind", () => {
234
234
  });
235
235
  });
236
236
 
237
+ describe("paperlint new --venue msr", () => {
238
+ it("`new --venue msr` writes MSR's class line, and once the paper declares who wrote it, lint is clean", async () => {
239
+ const r = await newIn(["--venue", "msr", "--kind", "technical"]);
240
+ const tex = readFileSync(join(r.dir, "paper.tex"), "utf8");
241
+ expect(tex).toContain("\\documentclass[10pt,conference]{IEEEtran}\n");
242
+ expect(r.out).not.toMatch(/tex\/template|format\/page-limit|pdf\/profile/);
243
+ // MSR reviews double-anonymously: the one thing the stub lacks is the authors' identity.
244
+ expect(r.out).toMatch(/anonymity\/identity/);
245
+ expect(r.code).toBe(1);
246
+ writeFileSync(
247
+ join(r.dir, "paperlint.json"),
248
+ JSON.stringify({ ...r.settings(), identity: ["Ada Example"] }),
249
+ );
250
+ const out: string[] = [];
251
+ const code = await run(["lint", r.dir], {
252
+ cwd: r.dir,
253
+ log: (s: string) => out.push(s),
254
+ err: () => {},
255
+ });
256
+ // Guards: what remains is the reminder that the PDF is not built yet — a warning, not a finding
257
+ // about the paper.
258
+ expect(out.join("\n")).toMatch(/\(0 errors, 1 warning\)/);
259
+ expect(out.join("\n")).toMatch(/pdf\/measured/);
260
+ expect(code).toBe(0);
261
+ });
262
+ });
263
+
237
264
  describe("paperlint new --venue — a venue it cannot use", () => {
238
265
  it("an unknown venue exits 2, lists the shipped presets, and creates nothing", async () => {
239
266
  const r = await newIn(["--venue", "icse"]);
@@ -102,6 +102,32 @@ describe("lint reads the files paper.tex includes from its body", () => {
102
102
  ]);
103
103
  });
104
104
 
105
+ it("🔴 an included file that is not `.tex` — a `.bbl` — is not handed to ESLint, which parsed it as JavaScript (#153)", async () => {
106
+ const { byFile, err } = await lint(
107
+ project({
108
+ "papers/a/paper.tex": doc(
109
+ "{article}",
110
+ "",
111
+ "Text, as Figure~\\ref{f1} shows.\n\\input{paper.bbl}",
112
+ ),
113
+ // What bibtex writes: a comment first, which ESLint's JavaScript parser stops at.
114
+ "papers/a/paper.bbl":
115
+ "% Generated by IEEEtran.bst\n\\begin{thebibliography}{1}\n" +
116
+ "\\bibitem{a} A. Author, ``A result with p < .05,'' 2024.\n" +
117
+ "\\end{thebibliography}\n",
118
+ }),
119
+ );
120
+ // Guards: the `.bbl` was the one file of the run with a parse error («Unexpected token %»).
121
+ // It is bibtex's output, not prose the author edits: no fragment rule reports in it, and
122
+ // paper.tex — which reads it, spliced, as TeX typesets it — draws no finding from its `.05`.
123
+ expect(byFile).toEqual({
124
+ "papers/a/PIPELINE-STATUS.md": [],
125
+ "papers/a/paper.tex": [],
126
+ });
127
+ // Read, not missing: no «lint did not read» note names it.
128
+ expect(err).not.toMatch(/paper\.bbl/);
129
+ });
130
+
105
131
  it("--fix rewrites the included file, to the paper's majority form", async () => {
106
132
  const root = project({});
107
133
  const before = readFileSync(join(root, "papers/a/paper.tex"), "utf8");
@@ -14,18 +14,35 @@
14
14
  * `paper.tex`, so only the files named here are ever enumerated;
15
15
  * 2. `settings.paperlint.paper` for `paper.tex` and its body files: the main file and the whole set,
16
16
  * for the rules whose verdict is the paper's (eslint-rules/paper-context.ts).
17
+ *
18
+ * ── ONLY `.tex` FILES ARE HANDED TO ESLint (#153) ────────────────────────────────
19
+ * An include may resolve to a file of another extension: `\input{paper.bbl}` in a paper that ships
20
+ * bibtex's output instead of a `.bib`, a plot exported as `.pgf`. Handed to ESLint, such a file got
21
+ * no language — the fragment block claims `.tex` only — and the JavaScript parser stopped at its
22
+ * first `%`. It is not given the LaTeX language either: the fragment rules judge and `--fix` the
23
+ * prose an author writes in that file, and a file of another extension is in practice a tool's
24
+ * output, whose words are someone else's (the titles of cited works) and whose fix the next run of
25
+ * the tool overwrites. TeX still reads it, and so does `readPaper`: the rules over the whole paper
26
+ * see it spliced into `paper.tex`, where TeX typesets it.
17
27
  */
18
- import { join, relative } from "node:path";
28
+ import { extname, join, relative } from "node:path";
19
29
  import { PAPERLINT_SETTINGS } from "#eslint-rules/paper-context";
20
30
  import { callerPath } from "./caller-path.ts";
21
31
  import type { AbsolutePath } from "./domain/paths.ts";
22
32
  import type { ConfigBlock } from "./rules-config.ts";
23
33
  import { bodyFiles, type PaperDeps, type Unread } from "./tex-paper.ts";
24
34
 
35
+ /** The files the fragment block (`buildConfig`) claims: the only body files ESLint is handed. */
36
+ export const FRAGMENT_FILES: readonly string[] = ["**/*.tex"];
37
+
38
+ /** Whether a body file is one the fragment block claims — the extension its glob names. */
39
+ const isFragment = (file: string): boolean => extname(file) === ".tex";
40
+
25
41
  /** One paper's main file and body files, and the includes that resolved nowhere. */
26
42
  export interface PaperBody {
27
43
  readonly dir: string;
28
44
  readonly main: AbsolutePath;
45
+ /** The body files ESLint lints, each as a fragment: the `.tex` ones. */
29
46
  readonly files: readonly AbsolutePath[];
30
47
  readonly missing: readonly Unread[];
31
48
  }
@@ -40,7 +57,8 @@ export function paperBodies(
40
57
  const bytes = deps.files.readBytes(main);
41
58
  if (bytes === null) return [];
42
59
  const body = bodyFiles(main, new TextDecoder().decode(bytes), deps);
43
- return [{ dir, main, files: body.files, missing: body.missing }];
60
+ const files = body.files.filter(isFragment);
61
+ return [{ dir, main, files, missing: body.missing }];
44
62
  });
45
63
  }
46
64
 
@@ -299,7 +299,7 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
299
299
  const r = await lint(root);
300
300
  expect(r.code).toBe(1);
301
301
  expect(r.out).toMatch(
302
- /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
302
+ /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, msr, realm/,
303
303
  );
304
304
  });
305
305
  });