paperlint 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +55 -11
  3. package/CONTRIBUTING.md +30 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +23 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +110 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/includes.d.ts +12 -0
  15. package/dist/adapters/latex/includes.d.ts.map +1 -0
  16. package/dist/adapters/latex/includes.js +42 -0
  17. package/dist/adapters/latex/includes.js.map +1 -0
  18. package/dist/adapters/latex/index.d.ts +33 -0
  19. package/dist/adapters/latex/index.d.ts.map +1 -0
  20. package/dist/adapters/latex/index.js +25 -0
  21. package/dist/adapters/latex/index.js.map +1 -0
  22. package/dist/adapters/latex/nodes.d.ts +58 -0
  23. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  24. package/dist/adapters/latex/nodes.js +97 -0
  25. package/dist/adapters/latex/nodes.js.map +1 -0
  26. package/dist/adapters/latex/outline.d.ts +10 -0
  27. package/dist/adapters/latex/outline.d.ts.map +1 -0
  28. package/dist/adapters/latex/outline.js +39 -0
  29. package/dist/adapters/latex/outline.js.map +1 -0
  30. package/dist/adapters/latex/parse.d.ts +42 -0
  31. package/dist/adapters/latex/parse.d.ts.map +1 -0
  32. package/dist/adapters/latex/parse.js +95 -0
  33. package/dist/adapters/latex/parse.js.map +1 -0
  34. package/dist/adapters/latex/prose.d.ts +8 -0
  35. package/dist/adapters/latex/prose.d.ts.map +1 -0
  36. package/dist/adapters/latex/prose.js +353 -0
  37. package/dist/adapters/latex/prose.js.map +1 -0
  38. package/dist/adapters/latex/rendered.d.ts +10 -0
  39. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  40. package/dist/adapters/latex/rendered.js +39 -0
  41. package/dist/adapters/latex/rendered.js.map +1 -0
  42. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  43. package/dist/adapters/pdfjs/fill.js +12 -12
  44. package/dist/adapters/pdfjs/fill.js.map +1 -1
  45. package/dist/build.js +4 -4
  46. package/dist/build.js.map +1 -1
  47. package/dist/claim-provenance.d.ts +34 -0
  48. package/dist/claim-provenance.d.ts.map +1 -0
  49. package/dist/claim-provenance.js +146 -0
  50. package/dist/claim-provenance.js.map +1 -0
  51. package/dist/cli.d.ts.map +1 -1
  52. package/dist/cli.js +49 -5
  53. package/dist/cli.js.map +1 -1
  54. package/dist/domain/paper-source.d.ts +58 -0
  55. package/dist/domain/paper-source.d.ts.map +1 -0
  56. package/dist/domain/paper-source.js +109 -0
  57. package/dist/domain/paper-source.js.map +1 -0
  58. package/dist/domain/sentences.d.ts +26 -0
  59. package/dist/domain/sentences.d.ts.map +1 -0
  60. package/dist/domain/sentences.js +48 -0
  61. package/dist/domain/sentences.js.map +1 -0
  62. package/dist/domain/tex-document.d.ts +119 -0
  63. package/dist/domain/tex-document.d.ts.map +1 -0
  64. package/dist/domain/tex-document.js +43 -0
  65. package/dist/domain/tex-document.js.map +1 -0
  66. package/dist/eslint-rules/paper-typography.d.ts +7 -0
  67. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  68. package/dist/eslint-rules/paper-typography.js +53 -1
  69. package/dist/eslint-rules/paper-typography.js.map +1 -1
  70. package/dist/facts-file.d.ts.map +1 -1
  71. package/dist/facts-file.js +2 -2
  72. package/dist/facts-file.js.map +1 -1
  73. package/dist/new-paper.d.ts +5 -0
  74. package/dist/new-paper.d.ts.map +1 -1
  75. package/dist/new-paper.js +3 -0
  76. package/dist/new-paper.js.map +1 -1
  77. package/dist/package-dirs.d.ts +5 -0
  78. package/dist/package-dirs.d.ts.map +1 -0
  79. package/dist/package-dirs.js +14 -0
  80. package/dist/package-dirs.js.map +1 -0
  81. package/dist/ports/latex.d.ts +35 -0
  82. package/dist/ports/latex.d.ts.map +1 -0
  83. package/dist/ports/latex.js +2 -0
  84. package/dist/ports/latex.js.map +1 -0
  85. package/dist/presets.d.ts +15 -2
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -6
  88. package/dist/presets.js.map +1 -1
  89. package/dist/register.d.ts +40 -0
  90. package/dist/register.d.ts.map +1 -0
  91. package/dist/register.js +134 -0
  92. package/dist/register.js.map +1 -0
  93. package/dist/tex-paper.d.ts +19 -0
  94. package/dist/tex-paper.d.ts.map +1 -0
  95. package/dist/tex-paper.js +105 -0
  96. package/dist/tex-paper.js.map +1 -0
  97. package/dist/tex-requirements.d.ts +11 -0
  98. package/dist/tex-requirements.d.ts.map +1 -1
  99. package/dist/tex-requirements.js +14 -8
  100. package/dist/tex-requirements.js.map +1 -1
  101. package/dist/tex-venue-rules.d.ts +88 -0
  102. package/dist/tex-venue-rules.d.ts.map +1 -0
  103. package/dist/tex-venue-rules.js +268 -0
  104. package/dist/tex-venue-rules.js.map +1 -0
  105. package/dist/venue-rules.d.ts +1 -1
  106. package/dist/venue-rules.d.ts.map +1 -1
  107. package/docs/configuration.md +3 -2
  108. package/docs/e2e.md +69 -40
  109. package/docs/install.md +1 -1
  110. package/docs/prior-art/README.md +1 -1
  111. package/docs/prior-art/package-location.md +5 -5
  112. package/docs/prior-art/repro/README.md +1 -1
  113. package/docs/prior-art/test-tooling.md +21 -10
  114. package/docs/rules/tex/claim-provenance.md +83 -0
  115. package/docs/rules/tex/missing-input.md +58 -0
  116. package/docs/rules/tex/register.md +77 -0
  117. package/docs/rules/tex/required-section.md +75 -0
  118. package/docs/rules/tex/template.md +74 -0
  119. package/docs/rules/tex/venue-leftover.md +73 -0
  120. package/docs/rules.md +73 -48
  121. package/docs/testing.md +17 -4
  122. package/docs/texlive-install-decision.md +1 -1
  123. package/docs/toolchain.md +2 -2
  124. package/eslint-rules/paper-typography.test.mjs +48 -1
  125. package/eslint-rules/paper-typography.ts +72 -1
  126. package/eslint.config.mjs +156 -15
  127. package/fixtures/build-e2e/acmart/expect.json +26 -0
  128. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  129. package/fixtures/build-e2e/aidc/expect.json +14 -0
  130. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  131. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  132. package/fixtures/build-e2e/broken/expect.json +11 -0
  133. package/fixtures/build-e2e/cite/expect.json +27 -0
  134. package/fixtures/build-e2e/empty/expect.json +12 -0
  135. package/fixtures/build-e2e/fallback/expect.json +53 -0
  136. package/fixtures/build-e2e/guards/expect.json +6 -0
  137. package/fixtures/build-e2e/no-source/expect.json +10 -0
  138. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  139. package/fixtures/pdf-facts/README.md +1 -1
  140. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  141. package/fixtures/render-paper/build-clean.sh +3 -5
  142. package/package.json +14 -2
  143. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  144. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  145. package/presets/aidc.jsonc +73 -0
  146. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  147. package/presets/ieee-conference.jsonc +66 -0
  148. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  149. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  150. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  151. package/scripts/check.harness.ts +64 -10
  152. package/scripts/check.test.ts +60 -13
  153. package/scripts/check.ts +83 -30
  154. package/scripts/test.test.ts +1 -1
  155. package/scripts/test.ts +1 -1
  156. package/skills/cold-read-diff/SKILL.md +31 -10
  157. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  158. package/skills/draft-paper/SKILL.md +2 -2
  159. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  160. package/skills/grade-paper-writing/SKILL.md +109 -44
  161. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  162. package/skills/harden-paper/SKILL.md +35 -36
  163. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  164. package/skills/paper-adversarial-review/SKILL.md +2 -2
  165. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  166. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  167. package/skills/paper-pipeline/SKILL.md +4 -4
  168. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -3
  169. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  170. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  171. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  172. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  173. package/skills/pc-panel-review/SKILL.md +36 -11
  174. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  175. package/skills/render-paper/SKILL.md +2 -2
  176. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  177. package/skills/render-paper/check-render.sh +16 -63
  178. package/skills/render-paper/render-paper.harness.mjs +17 -59
  179. package/src/adapters/banal/index.test.ts +1 -1
  180. package/src/adapters/banal/invocation.ts +1 -1
  181. package/src/adapters/banal/output.test.ts +1 -1
  182. package/src/adapters/banal/run.test.ts +1 -1
  183. package/src/adapters/banal/xml.test.ts +1 -1
  184. package/src/adapters/latex/document-class.ts +155 -0
  185. package/src/adapters/latex/hidden.ts +70 -0
  186. package/src/adapters/latex/includes.ts +66 -0
  187. package/src/adapters/latex/index.test.ts +535 -0
  188. package/src/adapters/latex/index.ts +65 -0
  189. package/src/adapters/latex/nodes.ts +150 -0
  190. package/src/adapters/latex/outline.ts +64 -0
  191. package/src/adapters/latex/parse.ts +137 -0
  192. package/src/adapters/latex/prose.ts +484 -0
  193. package/src/adapters/latex/rendered.ts +59 -0
  194. package/src/adapters/pdfjs/fill.test.ts +1 -1
  195. package/src/adapters/pdfjs/fill.ts +18 -10
  196. package/src/build.harness.mjs +5 -7
  197. package/src/build.ts +4 -4
  198. package/src/claim-provenance.test.ts +352 -0
  199. package/src/claim-provenance.ts +217 -0
  200. package/src/cli.harness.mjs +1 -1
  201. package/src/cli.test.ts +2 -8
  202. package/src/cli.ts +53 -8
  203. package/src/domain/paper-source.test.ts +156 -0
  204. package/src/domain/paper-source.ts +233 -0
  205. package/src/domain/sentences.ts +67 -0
  206. package/src/domain/tex-document.test.ts +80 -0
  207. package/src/domain/tex-document.ts +143 -0
  208. package/src/engine.harness.mjs +1 -1
  209. package/src/facts-file.test.ts +4 -7
  210. package/src/facts-file.ts +2 -2
  211. package/src/latex-loop.harness.ts +1 -1
  212. package/src/new-paper.test.ts +28 -3
  213. package/src/new-paper.ts +8 -0
  214. package/src/package-dirs.ts +17 -0
  215. package/src/paper-settings-commands.test.ts +6 -1
  216. package/src/ports/latex.ts +42 -0
  217. package/src/presets.test.ts +65 -4
  218. package/src/presets.ts +38 -15
  219. package/src/register.test.ts +165 -0
  220. package/src/register.ts +177 -0
  221. package/src/rule-cases.test.ts +524 -0
  222. package/src/rule-docs.test.ts +159 -0
  223. package/src/tex-paper.test.ts +131 -0
  224. package/src/tex-paper.ts +152 -0
  225. package/src/tex-requirements.harness.ts +13 -23
  226. package/src/tex-requirements.ts +35 -8
  227. package/src/tex-venue-rules.test.ts +741 -0
  228. package/src/tex-venue-rules.ts +461 -0
  229. package/src/toolchain.harness.mjs +1 -1
  230. package/src/venue-rules.test.ts +96 -4
  231. package/src/venue-rules.ts +1 -1
  232. package/templates/paper/paper.tex +2 -1
  233. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  234. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  235. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  236. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
package/eslint.config.mjs CHANGED
@@ -19,6 +19,8 @@ import portRules from "#eslint-rules/install-path-literals";
19
19
  import n from "eslint-plugin-n";
20
20
  import tseslint from "typescript-eslint";
21
21
  import boundaries from "eslint-plugin-boundaries";
22
+ import functional from "eslint-plugin-functional";
23
+ import sonarjs from "eslint-plugin-sonarjs";
22
24
  import { dirname } from "node:path";
23
25
  import { fileURLToPath } from "node:url";
24
26
 
@@ -27,6 +29,21 @@ import { fileURLToPath } from "node:url";
27
29
  // or pinned at its current maximum in RATCHET.
28
30
  const TS_FILES = ["**/*.ts", "**/*.mts"];
29
31
 
32
+ /** Collection helpers come from Remeda; these would be a second library for the same job. */
33
+ const ONE_COLLECTION_LIBRARY = ["lodash", "lodash-es", "ramda"].map((name) => ({
34
+ name,
35
+ message: "use remeda (see CLAUDE.md)",
36
+ }));
37
+
38
+ /** An import of a skill's file, from code of the package itself (#133). */
39
+ const NO_SKILLS = [
40
+ {
41
+ regex: "(^|/)skills/",
42
+ message:
43
+ "The package's own code does not import from skills/: a skill is a consumer, not storage (#133). Move what you need into src/ or lib/.",
44
+ },
45
+ ];
46
+
30
47
  /** `x as unknown as T` tells the checker to look away (#76). Shared: a block REPLACES a rule's options. */
31
48
  const AS_UNKNOWN_AS = {
32
49
  selector:
@@ -54,33 +71,99 @@ const GIT_IN_A_RULE = [
54
71
  "A rule may not run git: a sha is deleted by routine maintenance and absent from a shallow CI checkout. Read the file content ESLint passes the rule.",
55
72
  }));
56
73
 
57
- const MAX_LINES = { max: 60, skipComments: true, skipBlankLines: true };
74
+ /** Source code: a function is one job, and thirty lines is where a second job starts to hide. */
75
+ const MAX_LINES = { max: 30, skipComments: true, skipBlankLines: true };
76
+ /**
77
+ * Tests keep sixty: a `describe`/`it` callback is a list of cases, not a function to split, and
78
+ * cutting it by line count scatters one scenario across helpers nobody reads twice.
79
+ */
80
+ const MAX_LINES_TESTS = { ...MAX_LINES, max: 60 };
81
+ const TEST_FILES = [
82
+ "**/*.test.{ts,mts}",
83
+ "**/*.harness.{ts,mts}",
84
+ "**/*.e2e.{ts,mts}",
85
+ "test/**/*.{ts,mts}",
86
+ ];
58
87
 
59
- // 🔴 A CEILING, NOT A PERMISSION: each number is the file's measured maximum on 2026-09-24, so a
60
- // function in these files can get simpler and cannot get worse. Lower a number when a refactor
61
- // lowers the maximum; never raise one. Paying the debt down is issue #72. A file leaves this table
62
- // when it passes the shared limits.
88
+ // 🔴 A CEILING, NOT A PERMISSION: each number is the file's measured maximum on the day its limit
89
+ // arrived (complexity and depth on 2026-09-24, the files #78 moved to TypeScript on 2026-09-27,
90
+ // cognitive complexity and the 30-line source limit on 2026-09-28), so a function in these files
91
+ // can get simpler and cannot get worse. Lower a number when a refactor lowers the maximum; never
92
+ // raise one. Paying the debt down is issue #137. A file leaves this table when it passes the
93
+ // shared limits.
63
94
  const RATCHET = {
64
- "src/init.ts": { complexity: 59, "max-lines-per-function": 219 },
65
- "src/cli.ts": { complexity: 43, "max-lines-per-function": 152 },
66
- "src/doctor.ts": { complexity: 38, "max-lines-per-function": 125 },
67
- "src/hooks-settings.ts": { complexity: 12, "max-depth": 4 },
68
- "src/structure.ts": { complexity: 12, "max-depth": 4 },
69
- "src/new-paper.ts": { complexity: 11 },
70
- // Moved from JavaScript on 2026-09-27 (#78) and measured that day: the move changed their
71
- // language, not their shape, so their debt is pinned here like the rest rather than paid in it.
72
95
  "eslint-rules/latex-language.ts": {
73
96
  complexity: 68,
97
+ "sonarjs/cognitive-complexity": 98,
74
98
  "max-lines-per-function": 314,
75
99
  "max-depth": 4,
76
100
  },
77
- "eslint-rules/paper-typography.ts": { complexity: 29, "max-depth": 5 },
78
101
  "eslint-rules/paper-stages.ts": {
79
102
  complexity: 15,
103
+ "sonarjs/cognitive-complexity": 21,
80
104
  "max-lines-per-function": 105,
81
105
  },
106
+ "eslint-rules/paper-typography.ts": {
107
+ complexity: 29,
108
+ "sonarjs/cognitive-complexity": 41,
109
+ "max-lines-per-function": 50,
110
+ "max-depth": 5,
111
+ },
112
+ "eslint-rules/pdf-last-page-balance.ts": { "max-lines-per-function": 31 },
113
+ "eslint-rules/review-frontmatter.ts": { "max-lines-per-function": 46 },
82
114
  "eslint-rules/temp-root-realpath.ts": { complexity: 13 },
115
+ "eslint-rules/tex-build.ts": { "max-lines-per-function": 34 },
116
+ "lib/agent-cli-version.ts": { "max-lines-per-function": 42 },
83
117
  "lib/markdown.ts": { complexity: 12 },
118
+ "scripts/check.ts": {
119
+ "sonarjs/cognitive-complexity": 13,
120
+ "max-lines-per-function": 39,
121
+ },
122
+ "skills/plan-paper-timeline/fixtures/fake-google-calendar.ts": {
123
+ "max-lines-per-function": 31,
124
+ },
125
+ "src/adapters/curl/download.io.ts": { "max-lines-per-function": 39 },
126
+ "src/adapters/references/index.ts": { "max-lines-per-function": 36 },
127
+ "src/build-engine.ts": { "max-lines-per-function": 33 },
128
+ "src/build.ts": {
129
+ "sonarjs/cognitive-complexity": 13,
130
+ "max-lines-per-function": 41,
131
+ },
132
+ "src/cli.ts": {
133
+ complexity: 43,
134
+ "sonarjs/cognitive-complexity": 30,
135
+ "max-lines-per-function": 152,
136
+ },
137
+ "src/doctor.ts": {
138
+ complexity: 38,
139
+ "sonarjs/cognitive-complexity": 20,
140
+ "max-lines-per-function": 125,
141
+ },
142
+ "src/domain/lookup-cache.ts": { "max-lines-per-function": 31 },
143
+ "src/hooks-settings.ts": {
144
+ complexity: 12,
145
+ "max-lines-per-function": 42,
146
+ "max-depth": 4,
147
+ },
148
+ "src/init.ts": {
149
+ complexity: 59,
150
+ "sonarjs/cognitive-complexity": 44,
151
+ "max-lines-per-function": 219,
152
+ },
153
+ "src/latex-log.ts": { "sonarjs/cognitive-complexity": 12 },
154
+ "src/link-skills.ts": { "max-lines-per-function": 32 },
155
+ "src/new-paper.ts": { complexity: 11, "max-lines-per-function": 38 },
156
+ "src/pdf-facts.ts": { "max-lines-per-function": 33 },
157
+ "src/presets.ts": { "max-lines-per-function": 50 },
158
+ "src/reference-rules.ts": { "max-lines-per-function": 36 },
159
+ "src/references.ts": { "max-lines-per-function": 43 },
160
+ "src/structure.ts": {
161
+ complexity: 12,
162
+ "sonarjs/cognitive-complexity": 24,
163
+ "max-lines-per-function": 39,
164
+ "max-depth": 4,
165
+ },
166
+ "src/toolchain.ts": { "max-lines-per-function": 50 },
84
167
  };
85
168
 
86
169
  // ── Hexagonal layers (src/CLAUDE.md, issue #76) ─────────────────────────────────────────
@@ -345,6 +428,7 @@ const TYPESCRIPT_ONLY = [
345
428
  "test/*.{js,mjs,cjs}",
346
429
  "test/e2e/**/*.{js,mjs,cjs}",
347
430
  "test/fixtures/layers/src/**/*.{js,mjs,cjs}",
431
+ "fixtures/accepted-papers/**/*.{js,mjs,cjs}",
348
432
  "fixtures/real-markdown-paper/**/*.{js,mjs,cjs}",
349
433
  "skills/paper-pipeline/scripts/fixtures/**/*.{js,mjs,cjs}",
350
434
  "skills/plan-paper-timeline/fixtures/**/*.{js,mjs,cjs}",
@@ -407,7 +491,11 @@ export default [
407
491
  tsconfigRootDir: dirname(fileURLToPath(import.meta.url)),
408
492
  },
409
493
  },
410
- plugins: { "@typescript-eslint": tseslint.plugin, local: localRules },
494
+ plugins: {
495
+ "@typescript-eslint": tseslint.plugin,
496
+ local: localRules,
497
+ sonarjs,
498
+ },
411
499
  rules: {
412
500
  // The same macOS-only defect as in the `.mjs` block; clean here, so it opens at `error`.
413
501
  "local/temp-root-realpath": "error",
@@ -437,6 +525,10 @@ export default [
437
525
  // Branches per function. Ten is the rule's long-standing default; the worst function
438
526
  // measured was 59, and one that size cannot be read, only re-run.
439
527
  complexity: ["error", 10],
528
+ // `complexity` counts branches; cognitive complexity also charges NESTING, which is what makes
529
+ // a function hard to read. Only this one rule of the plugin: its recommended set is a style
530
+ // guide of its own, and each rule here is a decision.
531
+ "sonarjs/cognitive-complexity": ["error", 10],
440
532
  // `x as unknown as T` tells the checker to look away: two spellings of one port were once
441
533
  // reconciled that way (#76). A real conversion is a function; a real subset needs no cast.
442
534
  "no-restricted-syntax": ["error", AS_UNKNOWN_AS],
@@ -448,8 +540,15 @@ export default [
448
540
  "max-lines-per-function": ["error", MAX_LINES],
449
541
  // Three levels of callbacks is the ceiling before a promise chain or a named step is due.
450
542
  "max-nested-callbacks": ["error", 3],
543
+ // A `switch` over a union names every member (or says `default:` on purpose): a state added
544
+ // to the type is then a compile-time finding at every place that must handle it.
545
+ "@typescript-eslint/switch-exhaustiveness-check": "error",
451
546
  },
452
547
  },
548
+ {
549
+ files: TEST_FILES,
550
+ rules: { "max-lines-per-function": ["error", MAX_LINES_TESTS] },
551
+ },
453
552
  // Hexagonal layers, both axes (src/CLAUDE.md): knowledge by element, purity by file category.
454
553
  IO_GLOBALS,
455
554
  layerBoundaries(dirname(fileURLToPath(import.meta.url))),
@@ -601,6 +700,48 @@ export default [
601
700
  // one whose firing path nothing exercises, which is rule 4 wearing a different hat. The
602
701
  // pass/fail signal lives in `npm test`, not in the warning count of `npm run lint`.
603
702
  },
703
+ {
704
+ files: ["**/*.{ts,mts,mjs,js,cjs}"],
705
+ rules: {
706
+ "no-restricted-imports": ["error", { paths: ONE_COLLECTION_LIBRARY }],
707
+ },
708
+ },
709
+ // 🔴 THE LINTER DOES NOT READ A SKILL. Skills are consumers of the package, not its storage: the
710
+ // venue presets once lived in a skill and were located through a skill's script, so renaming the
711
+ // skill broke `paperlint lint` (#133). The package's own code — src/, eslint-rules/, lib/ — may
712
+ // not import from skills/. Tests and harnesses may: they check the skills.
713
+ {
714
+ files: [
715
+ "src/**/*.{ts,mts,mjs,js}",
716
+ "eslint-rules/**/*.{ts,mts,mjs,js}",
717
+ "lib/**/*.{ts,mts,mjs,js}",
718
+ ],
719
+ ignores: ["**/*.test.*", "**/*.harness.*"],
720
+ rules: {
721
+ "no-restricted-imports": [
722
+ "error",
723
+ { paths: ONE_COLLECTION_LIBRARY, patterns: NO_SKILLS },
724
+ ],
725
+ },
726
+ },
727
+ // 🔴 NEW CODE IS FUNCTIONAL: no `let`, no mutation, no loops, readonly types. Old code is listed
728
+ // in eslint-suppressions.json (ESLint's own bulk suppressions), which may only shrink: a fixed
729
+ // site that is not pruned fails `npm run lint`, and #134 burns the list down. Tests may mutate
730
+ // their fixtures.
731
+ {
732
+ files: ["src/**/*.ts", "eslint-rules/**/*.ts", "lib/**/*.ts"],
733
+ ignores: ["**/*.test.*", "**/*.harness.*"],
734
+ plugins: { functional },
735
+ rules: {
736
+ "functional/no-let": "error",
737
+ "functional/immutable-data": "error",
738
+ "functional/no-loop-statements": "error",
739
+ "functional/prefer-immutable-types": [
740
+ "error",
741
+ { enforcement: "ReadonlyShallow", ignoreInferredTypes: true },
742
+ ],
743
+ },
744
+ },
604
745
  // Rule sources, whatever their extension; tests and harnesses plant git calls on purpose.
605
746
  {
606
747
  files: ["eslint-rules/*.ts", "eslint-rules/*.mjs"],
@@ -0,0 +1,26 @@
1
+ {
2
+ "$comment": "acmart in its own fonts: the class silently falls back to Computer Modern when one of its font packages is missing, and that is what this fixture exists to catch. Only the rules whose answer does not depend on banal's page classification are named: the paper is one sentence long, banal calls such a page a one-column cover, and pdf/geometry rightly reports that — so neither it nor the exit code is part of the claim.",
3
+ "build": {
4
+ "outcome": "pdf"
5
+ },
6
+ "fonts": {
7
+ "all": "^(LinLibertine|LinBiolinum)",
8
+ "none": "^(CMR|CMBX|CMTI|CMTT|CMSS|LMRoman)",
9
+ "matchLog": true
10
+ },
11
+ "lint": {
12
+ "paperlintJson": {
13
+ "extends": "paperlint:agenticdev",
14
+ "kind": "short",
15
+ "rules": {
16
+ "pdf/last-page-balance": "error"
17
+ }
18
+ },
19
+ "rules": {
20
+ "pdf/last-page-balance": [],
21
+ "pdf/profile": [],
22
+ "pdf/fresh": [],
23
+ "pdf/fonts": []
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,3 @@
1
+ # Fixture
2
+
3
+ Stage: draft
@@ -0,0 +1,14 @@
1
+ {
2
+ "$comment": "A real IEEEtran [conference,compsoc] paper for AIDC, built on paperlint's TeX Live: IEEE's Times, and not one finding from any rule — which is what judges pdf/limits, pdf/body-size and the rest against a real PDF of the class.",
3
+ "build": {
4
+ "outcome": "pdf"
5
+ },
6
+ "fonts": {
7
+ "some": "NimbusRomNo9L",
8
+ "none": "^(CMR|CMBX|CMTI|CMTT|CMSS|LMRoman)"
9
+ },
10
+ "lint": {
11
+ "exit": 0,
12
+ "findings": []
13
+ }
14
+ }
@@ -0,0 +1,46 @@
1
+ % A small real paper in the class AIDC requires, built by the e2e on paperlint's own TeX Live, so
2
+ % `paperlint toolchain` is proved to install what the ieee-conference preset declares, and every
3
+ % rule is judged against what a real build of the class measures. It conforms: the LLM Usage
4
+ % Statement closes it, and not one rule has anything to say.
5
+ \documentclass[conference,compsoc]{IEEEtran}
6
+ \begin{filecontents*}{refs.bib}
7
+ @article{knuth84,
8
+ title = {Literate Programming},
9
+ author = {Knuth, Donald E.},
10
+ journal = {The Computer Journal},
11
+ volume = {27},
12
+ number = {2},
13
+ pages = {97--111},
14
+ year = {1984},
15
+ doi = {10.1093/comjnl/27.2.97}
16
+ }
17
+ \end{filecontents*}
18
+ % banal calls a page with a few lines on it a one-column "cover", and pdf/geometry would rightly
19
+ % report that. So the body fills two columns of a page: one paragraph, set several times.
20
+ \newcommand{\filler}{This paragraph is ordinary prose, set in the two columns of the conference
21
+ format so that the page geometry is measured on a real body of text. It says nothing new, and it
22
+ does not need to: the fixture is about the class, the fonts and the page, not about its content.
23
+ A reviewer would find it dull, which is exactly what a measurement fixture should be.\par}
24
+ \begin{document}
25
+ \title{A Fixture Typeset in the Class the Venue Requires}
26
+ \author{\IEEEauthorblockN{A. Author}
27
+ \IEEEauthorblockA{Anonymous for double-blind review}}
28
+ \maketitle
29
+ \begin{abstract}
30
+ The abstract exists so the build has the front matter an IEEE paper carries.
31
+ \end{abstract}
32
+ \section{Introduction}
33
+ This paper has a body of ordinary prose, set in the two columns of the conference format, and
34
+ it cites one article~\cite{knuth84}.
35
+ \filler\filler\filler\filler
36
+ \section{Method}
37
+ The method is to build this file with pdflatex and bibtex, then read the facts of the PDF.
38
+ \filler\filler\filler\filler\filler\filler
39
+ \section{Conclusion}
40
+ The conclusion restates that the fixture builds.
41
+ \filler\filler\filler\filler
42
+ \bibliographystyle{IEEEtran}
43
+ \bibliography{refs}
44
+ \section*{LLM Usage Statement}
45
+ No LLMs were used in writing this fixture.
46
+ \end{document}
@@ -0,0 +1 @@
1
+ { "extends": "paperlint:aidc", "kind": "short" }
@@ -0,0 +1,11 @@
1
+ {
2
+ "$comment": "Does not compile: an undefined control sequence on line 6. The build names pdflatex and quotes the log.",
3
+ "build": {
4
+ "outcome": "failed"
5
+ },
6
+ "says": [
7
+ "✗ compile: pdflatex exited with 1",
8
+ "./paper.tex:6: Undefined control sequence.",
9
+ "l.6 Text before, then \\undefinedmacro"
10
+ ]
11
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$comment": "The bibtex path: the citation and the \\ref resolve, and the leftover build.sh is ignored, not run.",
3
+ "build": {
4
+ "outcome": "pdf",
5
+ "pages": 1
6
+ },
7
+ "says": [
8
+ "build.sh is ignored — paperlint builds the paper itself"
9
+ ],
10
+ "text": {
11
+ "contains": [
12
+ "[1]"
13
+ ],
14
+ "lacks": [
15
+ "[?]",
16
+ "??"
17
+ ]
18
+ },
19
+ "files": {
20
+ "exist": [
21
+ "paper.bbl"
22
+ ],
23
+ "absent": [
24
+ "RAN"
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$comment": "No pages: pdflatex exits 0 and writes no PDF, which is a failure, not a green paper.pdf.",
3
+ "build": {
4
+ "outcome": "failed"
5
+ },
6
+ "says": [
7
+ "✗ compile: pdflatex exited 0 but wrote no paper.pdf — does the document have any pages?"
8
+ ],
9
+ "saysNot": [
10
+ "✓"
11
+ ]
12
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "$comment": "Computer Modern standing in for acmart's silent fallback. It builds green; declared an agenticdev paper afterwards, the venue rules reject it.",
3
+ "build": {
4
+ "outcome": "pdf"
5
+ },
6
+ "fonts": {
7
+ "some": "^(CMR|CMBX|CMTI|CMTT|CMSS|LMRoman)",
8
+ "matchLog": true
9
+ },
10
+ "lint": {
11
+ "paperlintJson": {
12
+ "extends": "paperlint:agenticdev",
13
+ "kind": "short"
14
+ },
15
+ "exit": 1,
16
+ "rules": {
17
+ "pdf/fonts": [
18
+ {
19
+ "severity": 2,
20
+ "says": [
21
+ "LinLibertine"
22
+ ]
23
+ },
24
+ {
25
+ "severity": 2,
26
+ "says": [
27
+ "LinBiolinum"
28
+ ]
29
+ }
30
+ ],
31
+ "pdf/geometry": [
32
+ {
33
+ "severity": 2,
34
+ "says": [
35
+ "page width is 8.264 in, agenticdev requires 8.5 in"
36
+ ]
37
+ },
38
+ {
39
+ "severity": 2,
40
+ "says": [
41
+ "page height is 11.694 in, agenticdev requires 11 in"
42
+ ]
43
+ },
44
+ {
45
+ "severity": 2,
46
+ "says": [
47
+ "column(s), agenticdev requires 2"
48
+ ]
49
+ }
50
+ ]
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$comment": "\\input{paper-guards} resolves with no configuration: paperlint's venues directory is on TEXINPUTS.",
3
+ "build": {
4
+ "outcome": "pdf"
5
+ }
6
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$comment": "No paper.tex: a refusal, named separately, and a stale paper.pdf is removed.",
3
+ "build": {
4
+ "outcome": "failed"
5
+ },
6
+ "says": [
7
+ "✗ nothing to compile: no paper.tex",
8
+ "paper.pdf removed — a stale PDF must not pass for this build"
9
+ ]
10
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "$comment": "A two-column acmart paper whose last page ends unbalanced. The build measures it without judging; the optional lint rule reports it.",
3
+ "build": {
4
+ "outcome": "pdf"
5
+ },
6
+ "says": [
7
+ "✓ paper.pdf",
8
+ "facts: _build/paper.facts.json, last page 621.5 / 264.8 pt"
9
+ ],
10
+ "saysNot": [
11
+ " balance:"
12
+ ],
13
+ "files": {
14
+ "lacks": {
15
+ "paper.bbl": [
16
+ "\\balance"
17
+ ]
18
+ }
19
+ },
20
+ "facts": {
21
+ "schema": 2,
22
+ "lastPageColumnsPt": [
23
+ 621.5,
24
+ 264.8
25
+ ]
26
+ },
27
+ "lint": {
28
+ "paperlintJson": {
29
+ "rules": {
30
+ "pdf/last-page-balance": "error"
31
+ }
32
+ },
33
+ "exit": 1,
34
+ "rules": {
35
+ "pdf/last-page-balance": [
36
+ {
37
+ "severity": 2,
38
+ "says": [
39
+ "621.5 and 264.8 pt",
40
+ "pbalance"
41
+ ]
42
+ }
43
+ ],
44
+ "pdf/fresh": [],
45
+ "pdf/profile": [],
46
+ "pdf/fonts": [],
47
+ "pdf/geometry": [],
48
+ "pdf/limits": [],
49
+ "pdf/body-size": [],
50
+ "pdf/measured": [
51
+ {
52
+ "severity": 1,
53
+ "says": [
54
+ "names no venue preset yet"
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ }
@@ -13,7 +13,7 @@ poppler's numbers, so a pass means agreement with the tool pdf.js replaced.
13
13
  | `encrypted.pdf` | `t3-all.pdf` saved with an AES-256 user password (PyMuPDF) | pdf.js refuses it with `PasswordException`; the reader reports `encrypted` |
14
14
  | `hidden-text.pdf` | `hidden-text.tex`, pdflatex | two-column body text beside near-white, rotated and render-mode-3 text; page 2 holds only hidden text, so banal calls it blank |
15
15
 
16
- `test/e2e/banal.ts` also reads these files, with the real banal: it pins what banal 1.2 printed for
16
+ `test/e2e/tex/banal.e2e.ts` also reads these files, with the real banal: it pins what banal 1.2 printed for
17
17
  each one on poppler's `pdftohtml` 24.02.0 (2026-09-25), so a pass there means paperlint's pdf.js-written
18
18
  XML measures the same as poppler's. `hidden-text.pdf` exists for that test: each kind of hidden text
19
19
  changes banal's answer if it is counted.
@@ -1,8 +1,9 @@
1
1
  /**
2
- * ONE reader of `baseline.json`, shared by the two runs that compare against it: the harness
3
- * beside this file (the repository's own `bin/paperlint.mjs`) and `test/e2e/install.ts` (the binary
4
- * a consumer actually got). Two copies of "growth fails, a drop never does" would drift the first
5
- * time one of them is tightened, and the two runs would then disagree about the same article.
2
+ * ONE reader of `baseline.json`, shared by every run that compares against one: the harness beside
3
+ * this file (the repository's own `bin/paperlint.mjs`), `test/e2e/install/install.e2e.ts` (the
4
+ * binary a consumer actually got), and `fixtures/accepted-papers/accepted-papers.test.ts` (one
5
+ * baseline per accepted paper). Two copies of "growth fails, a drop never does" would drift the
6
+ * first time one of them is tightened, and the runs would then disagree about the same rules.
6
7
  */
7
8
  import { readFileSync } from "node:fs";
8
9
  import { dirname, join } from "node:path";
@@ -21,10 +22,10 @@ const LintOutput = z.array(
21
22
 
22
23
  const HERE = dirname(fileURLToPath(import.meta.url));
23
24
 
24
- /** The recorded counts, `{ruleId: n}`. */
25
- export function recordedFindings(): Counts {
25
+ /** The counts recorded in `dir`'s `baseline.json` (this article's by default), `{ruleId: n}`. */
26
+ export function recordedFindings(dir: string = HERE): Counts {
26
27
  return Baseline.parse(
27
- JSON.parse(readFileSync(join(HERE, "baseline.json"), "utf8")),
28
+ JSON.parse(readFileSync(join(dir, "baseline.json"), "utf8")),
28
29
  ).findings;
29
30
  }
30
31
 
@@ -5,18 +5,16 @@
5
5
  # A paper's own build script must not hard-code a path into the agent's skills directory:
6
6
  # once the skills move into a package that address points at nothing, and LaTeX's reaction to
7
7
  # a missing \input is SILENCE, not an error. So the script resolves the venues directory
8
- # through the package first, falls back to the in-repo copy, and then VERIFIES that kpsewhich
8
+ # through the installed package, and then VERIFIES that kpsewhich
9
9
  # can actually see the file before handing anything to pdflatex.
10
10
  set -euo pipefail
11
11
  ROOT=$(git rev-parse --show-toplevel)
12
12
 
13
- VENUES_PKG=$(node -e 'try{process.stdout.write(require.resolve("paperlint/venues/paper-guards.tex"))}catch{}' 2>/dev/null || true)
13
+ VENUES_PKG=$(cd "$ROOT" && node -e 'try{process.stdout.write(require.resolve("paperlint/presets/tex/paper-guards.tex"))}catch{}' 2>/dev/null || true)
14
14
  if [ -n "$VENUES_PKG" ] && [ -f "$VENUES_PKG" ]; then
15
15
  VENUES_DIR=$(dirname "$VENUES_PKG")
16
- elif [ -f "$ROOT/.claude/skills/submit-paper/references/venues/paper-guards.tex" ]; then
17
- VENUES_DIR="$ROOT/.claude/skills/submit-paper/references/venues"
18
16
  else
19
- echo "✗ paper-guards.tex found neither in the package nor in the repository" >&2
17
+ echo "✗ paper-guards.tex not found in the installed paperlint package" >&2
20
18
  exit 1
21
19
  fi
22
20
  export TEXINPUTS="$VENUES_DIR:"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,21 +8,26 @@
8
8
  "build": "tsc -p tsconfig.pkg.json && tsc",
9
9
  "prepare": "tsc -p tsconfig.pkg.json && tsc",
10
10
  "test": "node scripts/test.ts",
11
+ "test:e2e": "vitest run --project 'e2e-*'",
12
+ "test:e2e:install": "vitest run --project e2e-install",
13
+ "test:e2e:tex": "vitest run --project e2e-tex",
11
14
  "coverage": "NODE_OPTIONS=\"--import=./test/coverage-src.ts $NODE_OPTIONS\" c8 --check-coverage npm test --",
12
15
  "lint": "eslint .",
13
16
  "check": "node scripts/check.ts",
14
- "test:e2e": "node test/e2e/install.ts && node test/e2e/build.ts",
15
17
  "fmt": "prettier --write .",
16
18
  "fmt:check": "prettier --check ."
17
19
  },
18
20
  "dependencies": {
19
21
  "@eslint/markdown": "^8.0.3",
20
22
  "@eslint/plugin-kit": "^0.7.2",
23
+ "@unified-latex/unified-latex-types": "^1.8.4",
21
24
  "@unified-latex/unified-latex-util-parse": "^1.8.4",
25
+ "@unified-latex/unified-latex-util-visit": "^1.8.4",
22
26
  "ajv": "^6.12.6",
23
27
  "eslint": "^10.9.1",
24
28
  "js-yaml": "^4.1.0",
25
29
  "markdown-it": "^14.3.0",
30
+ "remeda": "^2.50.0",
26
31
  "ts-essentials": "^10.2.1",
27
32
  "typescript": "^6.0.3",
28
33
  "unpdf": "^1.8.1",
@@ -40,7 +45,9 @@
40
45
  "conventional-changelog-conventionalcommits": "9.3.1",
41
46
  "eslint-import-resolver-typescript": "^4.4.5",
42
47
  "eslint-plugin-boundaries": "^7.2.0",
48
+ "eslint-plugin-functional": "^10.0.1",
43
49
  "eslint-plugin-n": "^18.3.0",
50
+ "eslint-plugin-sonarjs": "^4.2.1",
44
51
  "prettier": "^3.8.1",
45
52
  "semantic-release": "25.0.9",
46
53
  "typescript-eslint": "^8.70.1",
@@ -103,10 +110,12 @@
103
110
  "eslint-rules",
104
111
  "eslint.config.mjs",
105
112
  "fixtures",
113
+ "!fixtures/accepted-papers",
106
114
  "!fixtures/real-markdown-paper",
107
115
  "fixtures/real-markdown-paper/baseline.*",
108
116
  "hooks",
109
117
  "lib",
118
+ "presets",
110
119
  "plugin",
111
120
  "scripts",
112
121
  "skills",
@@ -126,5 +135,8 @@
126
135
  "types": "./dist/eslint-rules/*.d.ts",
127
136
  "default": "./dist/eslint-rules/*.js"
128
137
  }
138
+ },
139
+ "overrides": {
140
+ "deepmerge-ts": "^8.0.2"
129
141
  }
130
142
  }