paperlint 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/CLAUDE.md +9 -25
  2. package/CONTRIBUTING.md +5 -4
  3. package/README.md +4 -3
  4. package/dist/adapters/bibtex/index.d.ts +3 -0
  5. package/dist/adapters/bibtex/index.d.ts.map +1 -0
  6. package/dist/adapters/bibtex/index.js +103 -0
  7. package/dist/adapters/bibtex/index.js.map +1 -0
  8. package/dist/adapters/latex/filecontents.d.ts +33 -0
  9. package/dist/adapters/latex/filecontents.d.ts.map +1 -0
  10. package/dist/adapters/latex/filecontents.js +65 -0
  11. package/dist/adapters/latex/filecontents.js.map +1 -0
  12. package/dist/adapters/latex/includes.d.ts +3 -3
  13. package/dist/adapters/latex/includes.d.ts.map +1 -1
  14. package/dist/adapters/latex/includes.js +18 -10
  15. package/dist/adapters/latex/includes.js.map +1 -1
  16. package/dist/adapters/latex/index.d.ts.map +1 -1
  17. package/dist/adapters/latex/index.js +2 -0
  18. package/dist/adapters/latex/index.js.map +1 -1
  19. package/dist/adapters/latex/nodes.d.ts +12 -0
  20. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  21. package/dist/adapters/latex/nodes.js +39 -0
  22. package/dist/adapters/latex/nodes.js.map +1 -1
  23. package/dist/adapters/latex/parse.d.ts +7 -1
  24. package/dist/adapters/latex/parse.d.ts.map +1 -1
  25. package/dist/adapters/latex/parse.js +55 -6
  26. package/dist/adapters/latex/parse.js.map +1 -1
  27. package/dist/adapters/memory/index.d.ts +3 -0
  28. package/dist/adapters/memory/index.d.ts.map +1 -1
  29. package/dist/adapters/memory/index.js +9 -0
  30. package/dist/adapters/memory/index.js.map +1 -1
  31. package/dist/adapters/references/index.d.ts.map +1 -1
  32. package/dist/adapters/references/index.js +27 -24
  33. package/dist/adapters/references/index.js.map +1 -1
  34. package/dist/adapters/sources-record/index.d.ts +9 -0
  35. package/dist/adapters/sources-record/index.d.ts.map +1 -0
  36. package/dist/adapters/sources-record/index.js +68 -0
  37. package/dist/adapters/sources-record/index.js.map +1 -0
  38. package/dist/adapters/tex-output/blg.d.ts +9 -0
  39. package/dist/adapters/tex-output/blg.d.ts.map +1 -0
  40. package/dist/adapters/tex-output/blg.js +20 -0
  41. package/dist/adapters/tex-output/blg.js.map +1 -0
  42. package/dist/adapters/tex-output/fls.d.ts +8 -0
  43. package/dist/adapters/tex-output/fls.d.ts.map +1 -0
  44. package/dist/adapters/tex-output/fls.js +16 -0
  45. package/dist/adapters/tex-output/fls.js.map +1 -0
  46. package/dist/adapters/tex-output/index.d.ts +7 -0
  47. package/dist/adapters/tex-output/index.d.ts.map +1 -0
  48. package/dist/adapters/tex-output/index.js +7 -0
  49. package/dist/adapters/tex-output/index.js.map +1 -0
  50. package/dist/bib-rules.d.ts +47 -0
  51. package/dist/bib-rules.d.ts.map +1 -0
  52. package/dist/bib-rules.js +127 -0
  53. package/dist/bib-rules.js.map +1 -0
  54. package/dist/build.d.ts +29 -1
  55. package/dist/build.d.ts.map +1 -1
  56. package/dist/build.js +40 -24
  57. package/dist/build.js.map +1 -1
  58. package/dist/claim-provenance.d.ts +2 -2
  59. package/dist/cli.d.ts +6 -1
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +30 -26
  62. package/dist/cli.js.map +1 -1
  63. package/dist/domain/paper-source.d.ts +1 -10
  64. package/dist/domain/paper-source.d.ts.map +1 -1
  65. package/dist/domain/paper-source.js +34 -24
  66. package/dist/domain/paper-source.js.map +1 -1
  67. package/dist/domain/paper-sources.d.ts +72 -0
  68. package/dist/domain/paper-sources.d.ts.map +1 -0
  69. package/dist/domain/paper-sources.js +18 -0
  70. package/dist/domain/paper-sources.js.map +1 -0
  71. package/dist/domain/sources-record.d.ts +102 -0
  72. package/dist/domain/sources-record.d.ts.map +1 -0
  73. package/dist/domain/sources-record.js +38 -0
  74. package/dist/domain/sources-record.js.map +1 -0
  75. package/dist/domain/tex-run.d.ts +25 -0
  76. package/dist/domain/tex-run.d.ts.map +1 -0
  77. package/dist/domain/tex-run.js +79 -0
  78. package/dist/domain/tex-run.js.map +1 -0
  79. package/dist/eslint-rules/paper-context.d.ts.map +1 -1
  80. package/dist/eslint-rules/paper-context.js +4 -3
  81. package/dist/eslint-rules/paper-context.js.map +1 -1
  82. package/dist/eslint-rules/paper-typography.d.ts +0 -9
  83. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  84. package/dist/eslint-rules/paper-typography.js +12 -5
  85. package/dist/eslint-rules/paper-typography.js.map +1 -1
  86. package/dist/latex-log.d.ts +10 -0
  87. package/dist/latex-log.d.ts.map +1 -1
  88. package/dist/latex-log.js +10 -0
  89. package/dist/latex-log.js.map +1 -1
  90. package/dist/latex-loop.d.ts +2 -0
  91. package/dist/latex-loop.d.ts.map +1 -1
  92. package/dist/latex-loop.js.map +1 -1
  93. package/dist/package-dirs.d.ts.map +1 -1
  94. package/dist/package-dirs.js +3 -2
  95. package/dist/package-dirs.js.map +1 -1
  96. package/dist/paper-includes.d.ts +4 -7
  97. package/dist/paper-includes.d.ts.map +1 -1
  98. package/dist/paper-includes.js +29 -21
  99. package/dist/paper-includes.js.map +1 -1
  100. package/dist/paper-prose.d.ts +2 -2
  101. package/dist/paper-record.d.ts +25 -0
  102. package/dist/paper-record.d.ts.map +1 -0
  103. package/dist/paper-record.js +39 -0
  104. package/dist/paper-record.js.map +1 -0
  105. package/dist/paper-sources.d.ts +66 -0
  106. package/dist/paper-sources.d.ts.map +1 -0
  107. package/dist/paper-sources.js +92 -0
  108. package/dist/paper-sources.js.map +1 -0
  109. package/dist/ports/bib-reader.d.ts +18 -0
  110. package/dist/ports/bib-reader.d.ts.map +1 -0
  111. package/dist/ports/bib-reader.js +2 -0
  112. package/dist/ports/bib-reader.js.map +1 -0
  113. package/dist/ports/check-references.d.ts +9 -4
  114. package/dist/ports/check-references.d.ts.map +1 -1
  115. package/dist/ports/latex.d.ts +15 -0
  116. package/dist/ports/latex.d.ts.map +1 -1
  117. package/dist/ports/sources-codec.d.ts +11 -0
  118. package/dist/ports/sources-codec.d.ts.map +1 -0
  119. package/dist/ports/sources-codec.js +2 -0
  120. package/dist/ports/sources-codec.js.map +1 -0
  121. package/dist/ports/tex-output.d.ts +17 -0
  122. package/dist/ports/tex-output.d.ts.map +1 -0
  123. package/dist/ports/tex-output.js +2 -0
  124. package/dist/ports/tex-output.js.map +1 -0
  125. package/dist/recorded-bibliography.d.ts +89 -0
  126. package/dist/recorded-bibliography.d.ts.map +1 -0
  127. package/dist/recorded-bibliography.js +136 -0
  128. package/dist/recorded-bibliography.js.map +1 -0
  129. package/dist/reference-rules.d.ts +5 -5
  130. package/dist/reference-rules.d.ts.map +1 -1
  131. package/dist/reference-rules.js +150 -77
  132. package/dist/reference-rules.js.map +1 -1
  133. package/dist/references.d.ts +71 -16
  134. package/dist/references.d.ts.map +1 -1
  135. package/dist/references.js +110 -26
  136. package/dist/references.js.map +1 -1
  137. package/dist/register-bands.d.ts +3 -3
  138. package/dist/register-bands.d.ts.map +1 -1
  139. package/dist/register-bands.js.map +1 -1
  140. package/dist/register.d.ts +2 -2
  141. package/dist/sources-record.d.ts +38 -0
  142. package/dist/sources-record.d.ts.map +1 -0
  143. package/dist/sources-record.js +123 -0
  144. package/dist/sources-record.js.map +1 -0
  145. package/dist/sources-rules.d.ts +44 -0
  146. package/dist/sources-rules.d.ts.map +1 -0
  147. package/dist/sources-rules.js +76 -0
  148. package/dist/sources-rules.js.map +1 -0
  149. package/dist/tex-paper.d.ts +10 -15
  150. package/dist/tex-paper.d.ts.map +1 -1
  151. package/dist/tex-paper.js +24 -40
  152. package/dist/tex-paper.js.map +1 -1
  153. package/dist/tex-venue-rules.d.ts +3 -3
  154. package/dist/tex-venue-rules.d.ts.map +1 -1
  155. package/dist/tex-venue-rules.js.map +1 -1
  156. package/docs/configuration.md +12 -4
  157. package/docs/design/paper-sources.md +197 -0
  158. package/docs/package-shape-options.md +1 -1
  159. package/docs/references.md +34 -8
  160. package/docs/rules/bib/reachable-entry.md +64 -0
  161. package/docs/rules/paper/sources-fresh.md +81 -0
  162. package/docs/rules.md +62 -57
  163. package/eslint-rules/paper-context.ts +4 -3
  164. package/eslint-rules/paper-typography.test.mjs +12 -72
  165. package/eslint-rules/paper-typography.ts +13 -6
  166. package/eslint.config.mjs +22 -0
  167. package/fixtures/build-e2e/aidc/paper.tex +1 -1
  168. package/fixtures/build-e2e/anon-ack/paper.tex +1 -1
  169. package/fixtures/build-e2e/anon-author/paper.tex +1 -1
  170. package/fixtures/build-e2e/anon-bib/paper.tex +1 -1
  171. package/fixtures/build-e2e/anon-href/paper.tex +1 -1
  172. package/fixtures/build-e2e/anon-macro/paper.tex +1 -1
  173. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +1 -1
  174. package/fixtures/paper-sources/p1/paper.tex +30 -0
  175. package/fixtures/paper-sources/p1/refs.bib +6 -0
  176. package/fixtures/paper-sources/p1/sections/dead.tex +6 -0
  177. package/fixtures/paper-sources/p1/sections/intro.tex +7 -0
  178. package/fixtures/paper-sources/p1/sections/parked.tex +6 -0
  179. package/fixtures/paper-sources/p1/tex-truth.json +37 -0
  180. package/fixtures/paper-sources/p1/versions/2026-01-01-review.tex +4 -0
  181. package/fixtures/paper-sources/v1-stale/paper.tex +14 -0
  182. package/fixtures/paper-sources/v1-stale/refs.bib +6 -0
  183. package/fixtures/paper-sources/v1-stale/tex-truth.json +32 -0
  184. package/fixtures/paper-sources/v10-ifthenelse/anon.bib +1 -0
  185. package/fixtures/paper-sources/v10-ifthenelse/paper.tex +9 -0
  186. package/fixtures/paper-sources/v10-ifthenelse/real.bib +1 -0
  187. package/fixtures/paper-sources/v10-ifthenelse/tex-truth.json +28 -0
  188. package/fixtures/paper-sources/v11-two-blocks/paper.tex +12 -0
  189. package/fixtures/paper-sources/v11-two-blocks/tex-truth.json +30 -0
  190. package/fixtures/paper-sources/v13-percent-text/paper.tex +6 -0
  191. package/fixtures/paper-sources/v13-percent-text/refs.bib +3 -0
  192. package/fixtures/paper-sources/v13-percent-text/tex-truth.json +30 -0
  193. package/fixtures/paper-sources/v15-after-end/paper.tex +6 -0
  194. package/fixtures/paper-sources/v15-after-end/refs.bib +1 -0
  195. package/fixtures/paper-sources/v15-after-end/tex-truth.json +17 -0
  196. package/fixtures/paper-sources/v16-included-block/bibblock.tex +3 -0
  197. package/fixtures/paper-sources/v16-included-block/paper.tex +7 -0
  198. package/fixtures/paper-sources/v16-included-block/refs.bib +1 -0
  199. package/fixtures/paper-sources/v16-included-block/tex-truth.json +35 -0
  200. package/fixtures/paper-sources/v17-include-order/bibsetup.tex +1 -0
  201. package/fixtures/paper-sources/v17-include-order/first.bib +1 -0
  202. package/fixtures/paper-sources/v17-include-order/later.bib +1 -0
  203. package/fixtures/paper-sources/v17-include-order/paper.tex +7 -0
  204. package/fixtures/paper-sources/v17-include-order/tex-truth.json +40 -0
  205. package/fixtures/paper-sources/v2-overwrite/paper.tex +14 -0
  206. package/fixtures/paper-sources/v2-overwrite/refs.bib +6 -0
  207. package/fixtures/paper-sources/v2-overwrite/tex-truth.json +30 -0
  208. package/fixtures/paper-sources/v22-parked-thebibliography/paper.tex +7 -0
  209. package/fixtures/paper-sources/v22-parked-thebibliography/tex-truth.json +17 -0
  210. package/fixtures/paper-sources/v23-parked-include/old.bib +1 -0
  211. package/fixtures/paper-sources/v23-parked-include/paper.tex +7 -0
  212. package/fixtures/paper-sources/v23-parked-include/parked.tex +1 -0
  213. package/fixtures/paper-sources/v23-parked-include/refs.bib +1 -0
  214. package/fixtures/paper-sources/v23-parked-include/tex-truth.json +28 -0
  215. package/fixtures/paper-sources/v24-quoted-fields/paper.tex +6 -0
  216. package/fixtures/paper-sources/v24-quoted-fields/refs.bib +5 -0
  217. package/fixtures/paper-sources/v24-quoted-fields/tex-truth.json +32 -0
  218. package/fixtures/paper-sources/v25-string-differs/paper.tex +10 -0
  219. package/fixtures/paper-sources/v25-string-differs/refs.bib +2 -0
  220. package/fixtures/paper-sources/v25-string-differs/tex-truth.json +32 -0
  221. package/fixtures/paper-sources/v26-comment-in-names/one.bib +1 -0
  222. package/fixtures/paper-sources/v26-comment-in-names/paper.tex +7 -0
  223. package/fixtures/paper-sources/v26-comment-in-names/tex-truth.json +32 -0
  224. package/fixtures/paper-sources/v26-comment-in-names/two.bib +1 -0
  225. package/fixtures/paper-sources/v27-shared-string/abbreviations.bib +2 -0
  226. package/fixtures/paper-sources/v27-shared-string/paper.tex +6 -0
  227. package/fixtures/paper-sources/v27-shared-string/references.bib +6 -0
  228. package/fixtures/paper-sources/v27-shared-string/tex-truth.json +31 -0
  229. package/fixtures/paper-sources/v28-shared-string-block/abbreviations.bib +2 -0
  230. package/fixtures/paper-sources/v28-shared-string-block/paper.tex +14 -0
  231. package/fixtures/paper-sources/v28-shared-string-block/tex-truth.json +33 -0
  232. package/fixtures/paper-sources/v29-generated-input/paper.tex +9 -0
  233. package/fixtures/paper-sources/v29-generated-input/tex-truth.json +24 -0
  234. package/fixtures/paper-sources/v3-declared/paper.bib +6 -0
  235. package/fixtures/paper-sources/v3-declared/paper.tex +6 -0
  236. package/fixtures/paper-sources/v3-declared/tex-truth.json +28 -0
  237. package/fixtures/paper-sources/v30-name-next-line/paper.tex +14 -0
  238. package/fixtures/paper-sources/v30-name-next-line/tex-truth.json +30 -0
  239. package/fixtures/paper-sources/v4-commented/paper.tex +14 -0
  240. package/fixtures/paper-sources/v4-commented/refs.bib +6 -0
  241. package/fixtures/paper-sources/v4-commented/tex-truth.json +28 -0
  242. package/fixtures/paper-sources/v5-percent-entry/paper.tex +20 -0
  243. package/fixtures/paper-sources/v5-percent-entry/tex-truth.json +37 -0
  244. package/fixtures/paper-sources/v6-unclosed/paper.tex +6 -0
  245. package/fixtures/paper-sources/v6-unclosed/refs.bib +5 -0
  246. package/fixtures/paper-sources/v6-unclosed/tex-truth.json +37 -0
  247. package/fixtures/paper-sources/v8-jobname/paper.tex +9 -0
  248. package/fixtures/paper-sources/v8-jobname/tex-truth.json +30 -0
  249. package/fixtures/paper-sources/v9-two-bibliographies/one.bib +1 -0
  250. package/fixtures/paper-sources/v9-two-bibliographies/paper.tex +7 -0
  251. package/fixtures/paper-sources/v9-two-bibliographies/tex-truth.json +35 -0
  252. package/fixtures/paper-sources/v9-two-bibliographies/two.bib +1 -0
  253. package/fixtures/paper-typography/clean-paper/paper.tex +1 -1
  254. package/fixtures/paper-typography/messy-paper/paper.tex +1 -1
  255. package/package.json +2 -10
  256. package/scripts/check.test.ts +23 -1
  257. package/scripts/check.ts +7 -3
  258. package/scripts/coverage-config.test.ts +19 -10
  259. package/scripts/layer-legacy-frozen.test.ts +41 -2
  260. package/scripts/layer-legacy-frozen.ts +31 -2
  261. package/skills/grade-paper-writing/SKILL.md +4 -3
  262. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -2
  263. package/skills/grade-paper-writing/prose-lint.mjs +11 -2
  264. package/skills/grade-paper-writing/prose-lint.test.ts +52 -4
  265. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +83 -33
  266. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +67 -115
  267. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +128 -121
  268. package/skills/verify-citations/SKILL.md +7 -4
  269. package/skills/verify-citations/SKILL.md.spec.ts +6 -3
  270. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +17 -19
  271. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +129 -46
  272. package/skills/verify-citations/scripts/bib-authors.d.mts +22 -2
  273. package/skills/verify-citations/scripts/bib-authors.mjs +57 -111
  274. package/skills/verify-citations/scripts/verify-cites.cli.test.ts +16 -0
  275. package/skills/verify-citations/scripts/verify-cites.d.mts +19 -3
  276. package/skills/verify-citations/scripts/verify-cites.mjs +42 -108
  277. package/skills/verify-citations/scripts/verify-cites.test.mjs +23 -36
  278. package/src/adapters/bibtex/index.test.ts +163 -0
  279. package/src/adapters/bibtex/index.ts +155 -0
  280. package/src/adapters/latex/filecontents.ts +114 -0
  281. package/src/adapters/latex/includes.ts +18 -10
  282. package/src/adapters/latex/index.test.ts +73 -0
  283. package/src/adapters/latex/index.ts +2 -0
  284. package/src/adapters/latex/nodes.ts +64 -0
  285. package/src/adapters/latex/parse.test.ts +38 -0
  286. package/src/adapters/latex/parse.ts +67 -6
  287. package/src/adapters/memory/index.ts +17 -0
  288. package/src/adapters/references/index.test.ts +75 -21
  289. package/src/adapters/references/index.ts +37 -32
  290. package/src/adapters/sources-record/index.test.ts +87 -0
  291. package/src/adapters/sources-record/index.ts +82 -0
  292. package/src/adapters/tex-output/blg.ts +27 -0
  293. package/src/adapters/tex-output/fls.ts +24 -0
  294. package/src/adapters/tex-output/index.test.ts +171 -0
  295. package/src/adapters/tex-output/index.ts +12 -0
  296. package/src/bib-rules.test.ts +356 -0
  297. package/src/bib-rules.ts +228 -0
  298. package/src/bibliography-at.test.ts +286 -0
  299. package/src/build.test.ts +210 -0
  300. package/src/build.ts +100 -35
  301. package/src/claim-provenance.test.ts +33 -7
  302. package/src/claim-provenance.ts +2 -2
  303. package/src/cli.harness.mjs +20 -10
  304. package/src/cli.test.ts +14 -10
  305. package/src/cli.ts +37 -28
  306. package/src/domain/paper-source.test.ts +58 -20
  307. package/src/domain/paper-source.ts +38 -42
  308. package/src/domain/paper-sources.ts +94 -0
  309. package/src/domain/sources-record.test.ts +71 -0
  310. package/src/domain/sources-record.ts +136 -0
  311. package/src/domain/tex-run.test.ts +228 -0
  312. package/src/domain/tex-run.ts +139 -0
  313. package/src/heading-case.test.ts +24 -10
  314. package/src/latex-log.ts +20 -0
  315. package/src/latex-loop.harness.ts +1 -0
  316. package/src/latex-loop.ts +2 -0
  317. package/src/linted-set.test.ts +9 -1
  318. package/src/new-paper.harness.mjs +3 -2
  319. package/src/new-paper.test.ts +9 -5
  320. package/src/package-dirs.ts +3 -2
  321. package/src/paper-includes.test.ts +157 -38
  322. package/src/paper-includes.ts +39 -35
  323. package/src/paper-prose.test.ts +34 -5
  324. package/src/paper-prose.ts +2 -2
  325. package/src/paper-record.test.ts +173 -0
  326. package/src/paper-record.ts +66 -0
  327. package/src/paper-settings-commands.test.ts +5 -2
  328. package/src/paper-sources.ts +151 -0
  329. package/src/ports/bib-reader.ts +27 -0
  330. package/src/ports/check-references.ts +7 -3
  331. package/src/ports/latex.ts +19 -0
  332. package/src/ports/sources-codec.ts +11 -0
  333. package/src/ports/tex-output.ts +17 -0
  334. package/src/recorded-bibliography.ts +297 -0
  335. package/src/reference-rules.ts +217 -104
  336. package/src/references.test.ts +520 -177
  337. package/src/references.ts +185 -37
  338. package/src/register-bands.test.ts +5 -1
  339. package/src/register-bands.ts +3 -5
  340. package/src/register.test.ts +6 -1
  341. package/src/register.ts +2 -2
  342. package/src/rule-cases.test.ts +68 -12
  343. package/src/rule-docs.test.ts +0 -1
  344. package/src/sources-record.test.ts +308 -0
  345. package/src/sources-record.ts +231 -0
  346. package/src/sources-rules.test.ts +152 -0
  347. package/src/sources-rules.ts +117 -0
  348. package/src/tex-paper.test.ts +148 -78
  349. package/src/tex-paper.ts +31 -61
  350. package/src/tex-venue-rules.test.ts +36 -8
  351. package/src/tex-venue-rules.ts +3 -5
  352. package/dist/eslint-rules/bib-reachable-entry.d.ts +0 -22
  353. package/dist/eslint-rules/bib-reachable-entry.d.ts.map +0 -1
  354. package/dist/eslint-rules/bib-reachable-entry.js +0 -75
  355. package/dist/eslint-rules/bib-reachable-entry.js.map +0 -1
  356. package/eslint-rules/bib-reachable-entry.ts +0 -76
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The `bib` rules — about the bibliography TeX read, as the last build recorded it
3
+ * (`_build/sources.json`, src/recorded-bibliography.ts), on `paper.tex`:
4
+ *
5
+ * bib/reachable-entry an entry with no doi, url or arXiv id
6
+ *
7
+ * They judge the entries of the databases bibtex OPENED, and say nothing for a paper with no record or
8
+ * one that changed since: `paper/sources-fresh` speaks once for it.
9
+ *
10
+ * WHERE A FINDING ABOUT AN ENTRY GOES (`entryReports`). An entry of a `.bib` TeX wrote from a block of
11
+ * `paper.tex` is reported on its own line there, and a disable directive above it works as everywhere.
12
+ * Any other entry — a `.bib` the author keeps, which ESLint does not lint, or a block that is in an
13
+ * included file — is reported at the top of `paper.tex`, with the file, line and column at the front of
14
+ * the message (the way `reportInPaper` reports text from an included file); there, the same directive
15
+ * written in the `.bib` on the line above the entry keeps the exception where the entry is
16
+ * (`directiveAbove`).
17
+ *
18
+ * The main file's blocks are read from the editor's buffer, so a finding in `paper.tex` points at the
19
+ * text being edited; the `.bib` files and the includes are read from disk.
20
+ */
21
+ import { basename, dirname } from "node:path";
22
+ import { callerPath } from "./caller-path.ts";
23
+ import { MAIN_FILE, type BibEntry } from "./domain/paper-sources.ts";
24
+ import {
25
+ entriesOfDatabases,
26
+ entryReports,
27
+ recordedBibliography,
28
+ type EntryReport,
29
+ type RecordedDeps,
30
+ type RecordedEntry,
31
+ } from "./recorded-bibliography.ts";
32
+
33
+ export type { EntryReport } from "./recorded-bibliography.ts";
34
+
35
+ /** `% eslint-disable-next-line <rule>` (a reason after ` -- ` or not) on the line above `at` in `text`. */
36
+ function directiveAbove(text: string, at: number, rule: string): boolean {
37
+ const lineStart = text.lastIndexOf("\n", at - 1) + 1;
38
+ const above = text.slice(
39
+ text.lastIndexOf("\n", lineStart - 2) + 1,
40
+ Math.max(lineStart - 1, 0),
41
+ );
42
+ const m = /^\s*%\s*eslint-disable-next-line\s+(.*?)(?:\s+--\s.*)?$/.exec(
43
+ above,
44
+ );
45
+ return (m?.[1] ?? "")
46
+ .split(",")
47
+ .map((r) => r.trim())
48
+ .includes(rule);
49
+ }
50
+
51
+ /** Whether a rule should judge this file: the paper's main file only. */
52
+ export const isMain = (filename: string): boolean =>
53
+ basename(filename) === MAIN_FILE;
54
+
55
+ // ── the rules ───────────────────────────────────────────────────────────────────────────
56
+
57
+ interface Loc {
58
+ readonly line: number;
59
+ readonly column: number;
60
+ }
61
+
62
+ /** The slice of ESLint's rule API the `bib` rules use. */
63
+ export interface BibRuleContext {
64
+ readonly filename: string;
65
+ /** The `tex/latex` language's source code: `raw` is the file as written. */
66
+ readonly sourceCode: {
67
+ readonly raw: string;
68
+ getLocFromIndex(index: number): Loc;
69
+ };
70
+ report(d: {
71
+ readonly loc: { readonly start: Loc; readonly end: Loc };
72
+ readonly messageId: string;
73
+ readonly data?: Readonly<Record<string, string>>;
74
+ }): void;
75
+ }
76
+
77
+ export interface BibRuleModule {
78
+ readonly meta: {
79
+ readonly type: "problem" | "suggestion";
80
+ readonly docs: { readonly description: string; readonly url: string };
81
+ readonly schema: readonly object[];
82
+ readonly messages: Readonly<Record<string, string>>;
83
+ };
84
+ create(context: BibRuleContext): { "root:exit"?: () => void };
85
+ }
86
+
87
+ type BibRuleName = "reachable-entry";
88
+
89
+ const docsUrl = (name: BibRuleName): string =>
90
+ `https://github.com/zernie/paperlint/blob/main/docs/rules/bib/${name}.md`;
91
+
92
+ /** One finding a `bib` rule makes about an entry. */
93
+ interface EntryFinding {
94
+ readonly at: EntryReport;
95
+ readonly messageId: string;
96
+ readonly key: string;
97
+ }
98
+
99
+ /**
100
+ * One finding about an entry, where `entryReports` puts it: on the entry, or at the top of paper.tex
101
+ * with the `…In` message that names the entry's file, line and column first.
102
+ */
103
+ function reportEntry(context: BibRuleContext, f: EntryFinding): void {
104
+ const loc = (i: number) => context.sourceCode.getLocFromIndex(i);
105
+ context.report({
106
+ loc: { start: loc(f.at.span.start), end: loc(f.at.span.end) },
107
+ ...(f.at.kind === "here"
108
+ ? { messageId: f.messageId, data: { key: f.key } }
109
+ : {
110
+ messageId: `${f.messageId}In`,
111
+ data: { key: f.key, where: f.at.where },
112
+ }),
113
+ });
114
+ }
115
+
116
+ /** What a rule judges: the rule's name, the entries it flags, and the message each flagged entry gets. */
117
+ interface Judge {
118
+ readonly rule: string;
119
+ readonly flags: (e: BibEntry) => boolean;
120
+ readonly messageId: string;
121
+ }
122
+
123
+ /** A rule that judges the entries of the databases bibtex opened. */
124
+ const entryRule = (
125
+ meta: BibRuleModule["meta"],
126
+ judge: Judge,
127
+ deps: RecordedDeps,
128
+ ): BibRuleModule => ({
129
+ meta,
130
+ create: (context) => ({
131
+ "root:exit": () => {
132
+ findings(context, judge, deps).forEach((f) => {
133
+ reportEntry(context, f);
134
+ });
135
+ },
136
+ }),
137
+ });
138
+
139
+ /** An entry reported outside paper.tex whose line above disables `rule` for it (in paper.tex ESLint does that). */
140
+ const excepted = (f: RecordedEntry, at: EntryReport, rule: string): boolean =>
141
+ at.kind === "elsewhere" &&
142
+ directiveAbove(f.database.bib.text, f.entry.span.start, rule);
143
+
144
+ /** The findings for the paper being linted: none for any file but its main one, none without a current record. */
145
+ function findings(
146
+ context: BibRuleContext,
147
+ judge: Judge,
148
+ deps: RecordedDeps,
149
+ ): readonly EntryFinding[] {
150
+ if (!isMain(context.filename)) return [];
151
+ const main = callerPath(context.filename);
152
+ const dir = dirname(main);
153
+ const r = recordedBibliography(dir, deps);
154
+ if (r.kind === "unrecorded") return [];
155
+ const flagged = entriesOfDatabases(r.databases).filter((f) =>
156
+ judge.flags(f.entry),
157
+ );
158
+ const at = entryReports(
159
+ {
160
+ dir,
161
+ record: r.record,
162
+ main: { path: main, text: context.sourceCode.raw },
163
+ },
164
+ flagged,
165
+ deps,
166
+ );
167
+ return flagged.flatMap((f, i): readonly EntryFinding[] => {
168
+ const place = at[i];
169
+ return place === undefined || excepted(f, place, judge.rule)
170
+ ? []
171
+ : [{ at: place, messageId: judge.messageId, key: f.entry.key || "?" }];
172
+ });
173
+ }
174
+
175
+ /** An arXiv identifier in a field's text: `arXiv:2401.00001`, `arXiv preprint arXiv:2401.00001`. */
176
+ const ARXIV_ID = /arxiv[:\s]*\d{4}\.\d{4,5}/i;
177
+ /** A link the reader parsed out of `\url{…}` or `\href{…}`, in any field (`howpublished`, `note`). */
178
+ const PARSED_LINK = '<a href="';
179
+
180
+ const has = (e: BibEntry, field: string): boolean =>
181
+ (e.fields[field] ?? "").trim() !== "";
182
+
183
+ /** A doi, a url, an arXiv eprint, or a link or arXiv id in any field: something a reader can follow. */
184
+ const reachable = (e: BibEntry): boolean =>
185
+ has(e, "doi") ||
186
+ has(e, "url") ||
187
+ (has(e, "eprint") &&
188
+ /arxiv/i.test(e.fields["archiveprefix"] ?? e.fields["eprinttype"] ?? "")) ||
189
+ Object.values(e.fields).some(
190
+ (v) => ARXIV_ID.test(v) || v.includes(PARSED_LINK),
191
+ );
192
+
193
+ const UNREACHABLE =
194
+ "`{{key}}` has no doi, url or arXiv id — a reader has nothing to follow. If none exists, keep the exception with `% eslint-disable-next-line bib/reachable-entry -- <why>` above the entry";
195
+ /** `bib/reachable-entry`: every entry TeX reads carries something a reader can follow. */
196
+ const REACHABLE_META: BibRuleModule["meta"] = {
197
+ type: "suggestion",
198
+ docs: {
199
+ description:
200
+ "a bibliography entry carries a doi, a url or an arXiv id — something a reader can follow",
201
+ url: docsUrl("reachable-entry"),
202
+ },
203
+ schema: [],
204
+ messages: {
205
+ unreachable: UNREACHABLE,
206
+ unreachableIn: `{{where}}: ${UNREACHABLE}`,
207
+ },
208
+ };
209
+
210
+ /** The `bib` plugin's rules, reading the paper's record through `deps`. They act on `paper.tex` only. */
211
+ export const bibRules = (
212
+ deps: RecordedDeps,
213
+ ): Readonly<Record<BibRuleName, BibRuleModule>> => ({
214
+ "reachable-entry": entryRule(
215
+ REACHABLE_META,
216
+ {
217
+ rule: "bib/reachable-entry",
218
+ flags: (e) => !reachable(e),
219
+ messageId: "unreachable",
220
+ },
221
+ deps,
222
+ ),
223
+ });
224
+
225
+ /** The level each is on at for every `paper.tex` in paperlint's own config. */
226
+ export const BIB_RULE_LEVELS = {
227
+ "bib/reachable-entry": "warn",
228
+ } as const;
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `bibliographyAt` — a path a person gave, and the bibliography to read for it. A `.bib` is read alone,
3
+ * as named; a `.tex`, or a directory holding `paper.tex`, is the bibliography the last build's bibtex
4
+ * opened (`_build/sources.json`, docs/design/paper-sources.md §1) — read from the text the author
5
+ * edits: the `filecontents` block that wrote a database, or the file itself. A paper with no current
6
+ * record is refused, in one vocabulary, naming the command that records it.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { bibReader } from "./adapters/bibtex/index.ts";
10
+ import { latexReader } from "./adapters/latex/index.ts";
11
+ import { memoryFiles } from "./adapters/memory/index.ts";
12
+ import { sourcesCodec } from "./adapters/sources-record/index.ts";
13
+ import type { BibText } from "./domain/paper-sources.ts";
14
+ import { absolutePath } from "./domain/paths.ts";
15
+ import { bibliographyAt, bibliographyUnreadWhy } from "./paper-sources.ts";
16
+ import {
17
+ leftByTeX,
18
+ planted,
19
+ recordText,
20
+ type Shape,
21
+ } from "../test/recorded-fixture.ts";
22
+
23
+ const doc = (body: string): string =>
24
+ `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
25
+ const A = "@misc{a, title = {A}}\n";
26
+ const B = "@misc{b, title = {B}}\n";
27
+
28
+ /** A directory as TeX and the build leave it: the files, and the record over them (none: not built). */
29
+ function built(
30
+ dir: string,
31
+ files: Readonly<Record<string, string>>,
32
+ shape: Shape | string | null,
33
+ ): Record<string, string> {
34
+ const record =
35
+ shape === null
36
+ ? {}
37
+ : {
38
+ [`${dir}/_build/sources.json`]:
39
+ typeof shape === "string" ? shape : recordText(files, shape),
40
+ };
41
+ return {
42
+ ...Object.fromEntries(
43
+ Object.entries(files).map(([f, t]) => [`${dir}/${f}`, t]),
44
+ ),
45
+ ...record,
46
+ };
47
+ }
48
+
49
+ const PAPER = doc("\\bibliography{refs}");
50
+ const SHAPE: Shape = {
51
+ inputs: [
52
+ ["paper.tex", "body"],
53
+ ["refs.bib", "preamble"],
54
+ ],
55
+ databases: ["refs.bib"],
56
+ };
57
+ const OWN = { "paper.tex": PAPER, "refs.bib": A, "other.bib": B };
58
+
59
+ /** Each text read, with the keys of the entries it holds. */
60
+ const keysIn = (
61
+ r: ReturnType<typeof bibliographyAt>,
62
+ ): readonly (readonly [string, readonly string[]])[] =>
63
+ r.ok ? r.value.texts.map((t) => [t.path, entryKeys(t)] as const) : [];
64
+ const entryKeys = (t: BibText): readonly string[] =>
65
+ t.entries.map((e) => e.key);
66
+
67
+ const depsOf = (disk: Readonly<Record<string, string>>) => ({
68
+ files: memoryFiles({ "/p/notes.txt": "notes", ...disk }),
69
+ codec: sourcesCodec,
70
+ latex: latexReader,
71
+ bib: bibReader,
72
+ });
73
+
74
+ /** What a path comes to: the paper's directory and the files read, or the one sentence why not. */
75
+ const at = (disk: Readonly<Record<string, string>>, path: string) => {
76
+ const r = bibliographyAt(absolutePath(path), depsOf(disk));
77
+ return r.ok
78
+ ? [r.value.paperDir, r.value.texts.map((t) => t.path)]
79
+ : bibliographyUnreadWhy(r.error);
80
+ };
81
+
82
+ describe("bibliographyAt — the paths it reads", () => {
83
+ const disk = built("/p", OWN, SHAPE);
84
+
85
+ it("a .bib is read alone, as named — with no record of any build", () => {
86
+ expect([
87
+ at(built("/p", OWN, null), "/p/other.bib"),
88
+ at(disk, "/p/other.bib"),
89
+ ]).toEqual([
90
+ ["/p", ["/p/other.bib"]],
91
+ ["/p", ["/p/other.bib"]],
92
+ ]);
93
+ });
94
+
95
+ it("a .tex, or the directory of a paper.tex, is the bibliography the last build's bibtex opened — not every .bib in the folder", () => {
96
+ expect([at(disk, "/p/paper.tex"), at(disk, "/p")]).toEqual([
97
+ ["/p", ["/p/refs.bib"]],
98
+ ["/p", ["/p/refs.bib"]],
99
+ ]);
100
+ });
101
+
102
+ it("🔴 a .bib TeX wrote from a block is read from the block of the .tex the author edits, where its entries stand", () => {
103
+ const tex =
104
+ "\\documentclass{article}\n\\begin{filecontents*}[overwrite]{refs.bib}\n" +
105
+ A +
106
+ "\\end{filecontents*}\n\\begin{document}\nx\\bibliography{refs}\n\\end{document}\n";
107
+ const files = { "paper.tex": tex, "refs.bib": A };
108
+ const d = built("/p", files, {
109
+ inputs: [["paper.tex", "body"]],
110
+ written: ["refs.bib"],
111
+ databases: ["refs.bib"],
112
+ });
113
+ expect(keysIn(bibliographyAt(absolutePath("/p"), depsOf(d)))).toEqual([
114
+ ["/p/paper.tex", ["a"]],
115
+ ]);
116
+ });
117
+ });
118
+
119
+ describe("bibliographyAt — where a block is", () => {
120
+ it("a block that is in an included file is read from that file", () => {
121
+ const files = {
122
+ "paper.tex": doc("\\input{bibblock}\\bibliography{refs}"),
123
+ "bibblock.tex": `\\begin{filecontents*}{refs.bib}\n${A}\\end{filecontents*}\n`,
124
+ "refs.bib": A,
125
+ };
126
+ const d = built("/p", files, {
127
+ inputs: [
128
+ ["paper.tex", "body"],
129
+ ["bibblock.tex", "body"],
130
+ ],
131
+ written: ["refs.bib"],
132
+ databases: ["refs.bib"],
133
+ });
134
+ expect(at(d, "/p")).toEqual(["/p", ["/p/bibblock.tex"]]);
135
+ });
136
+
137
+ it("when no text holds what bibtex read, the file TeX left is read", () => {
138
+ const files = {
139
+ "paper.tex": doc("\\bibliography{refs}"),
140
+ "refs.bib": A,
141
+ };
142
+ const d = built("/p", files, {
143
+ inputs: [["paper.tex", "body"]],
144
+ written: ["refs.bib"],
145
+ databases: ["refs.bib"],
146
+ });
147
+ expect(at(d, "/p")).toEqual(["/p", ["/p/refs.bib"]]);
148
+ });
149
+ });
150
+
151
+ describe("bibliographyAt — every refusal in one vocabulary, naming the path and what to give instead", () => {
152
+ it("a path that is not a paper, a .bib or a .tex", () => {
153
+ const disk = built("/p", OWN, SHAPE);
154
+ expect([
155
+ at(disk, "/p/gone.bib"),
156
+ at(disk, "/nowhere"),
157
+ at(disk, "/p/notes.txt"),
158
+ at(disk, "/p/gone.tex"),
159
+ ]).toEqual([
160
+ "/p/gone.bib does not exist — nowhere to take a bibliography from",
161
+ "no paper.tex in /nowhere — name the paper's .tex, or a .bib to read it alone",
162
+ "/p/notes.txt is neither a .bib nor a .tex — name the paper's directory, its .tex, or a .bib",
163
+ "/p/gone.tex does not exist — nowhere to take a bibliography from",
164
+ ]);
165
+ });
166
+
167
+ it("🔴 a paper with no record of a build is not read from its source — run `npx paperlint build` first", () => {
168
+ const disk = built("/p", OWN, null);
169
+ expect([at(disk, "/p"), at(disk, "/p/paper.tex")]).toEqual([
170
+ "/p has not been built — run `npx paperlint build` first, which records the databases bibtex reads",
171
+ "/p has not been built — run `npx paperlint build` first, which records the databases bibtex reads",
172
+ ]);
173
+ });
174
+
175
+ it("a record the paper has changed since, and one that cannot be read, name the command too", () => {
176
+ const stale = built("/p", OWN, SHAPE);
177
+ stale["/p/refs.bib"] = `${A}${B}`;
178
+ expect(at(stale, "/p")).toBe(
179
+ "/p changed since the last build (refs.bib edited) — run `npx paperlint build` first",
180
+ );
181
+ const broken = built("/p", OWN, "{ not json");
182
+ expect(at(broken, "/p")).toMatch(
183
+ /^the last build's record of \/p cannot be used \(.+\) — run `npx paperlint build` first$/,
184
+ );
185
+ });
186
+
187
+ it("a build whose bibtex read no database, and one whose databases are gone, have nothing to read", () => {
188
+ const none = built(
189
+ "/q",
190
+ { "paper.tex": doc("no bibliography") },
191
+ {
192
+ inputs: [["paper.tex", "body"]],
193
+ },
194
+ );
195
+ expect(at(none, "/q")).toBe(
196
+ "bibtex read no database in the last build of /q — there is nothing to read",
197
+ );
198
+ const gone = built(
199
+ "/s",
200
+ { "paper.tex": doc("\\bibliography{gone}") },
201
+ {
202
+ inputs: [["paper.tex", "body"]],
203
+ databases: ["gone.bib"],
204
+ },
205
+ );
206
+ expect(at(gone, "/s")).toBe(
207
+ "bibtex opened gone.bib in the last build of /s, and none of them is on disk now",
208
+ );
209
+ });
210
+ });
211
+
212
+ describe("bibliographyAt — a bibliography it can read only in part", () => {
213
+ it("🔴 a build whose bibtex also opened a database outside the paper directory (BIBINPUTS) is refused, not read in part", () => {
214
+ // Measured: with BIBINPUTS=../shared, bibtex's .blg names `Database file #2: shared.bib` and the
215
+ // .bbl typesets its entry; the paper directory holds refs.bib only.
216
+ const partly = built(
217
+ "/r",
218
+ { "paper.tex": doc("\\bibliography{refs,shared}"), "refs.bib": A },
219
+ {
220
+ inputs: [["paper.tex", "body"]],
221
+ databases: ["refs.bib", "shared.bib"],
222
+ },
223
+ );
224
+ expect(at(partly, "/r")).toBe(
225
+ "bibtex opened shared.bib in the last build of /r from outside it (its search path, BIBINPUTS), and paperlint reads only the paper's own databases — copy it into /r, or name each .bib to read it alone",
226
+ );
227
+ });
228
+ });
229
+
230
+ describe("bibliographyAt on the planted papers, with the record TeX wrote for each", () => {
231
+ it.each([
232
+ ["v3-declared", ["/p/paper.bib"]],
233
+ ["v26-comment-in-names", ["/p/one.bib", "/p/two.bib"]],
234
+ ["v16-included-block", ["/p/refs.bib"]],
235
+ ["v25-string-differs", ["/p/refs.bib"]],
236
+ // A block that TeX wrote: read from the block where the author edits it.
237
+ ["v8-jobname", ["/p/paper.tex"]],
238
+ ["v5-percent-entry", ["/p/paper.tex"]],
239
+ // The block names its file on the line after \\begin: still read from the block.
240
+ ["v30-name-next-line", ["/p/paper.tex"]],
241
+ ])("%s", (name, files) => {
242
+ const p = planted(name);
243
+ const disk = built("/p", leftByTeX(p), p.record);
244
+ expect(at(disk, "/p")).toEqual(["/p", files]);
245
+ });
246
+ });
247
+
248
+ describe("bibliographyAt — an @string one database defines and a later one uses", () => {
249
+ /** Each text read, with each entry's author and booktitle as the reader read them. */
250
+ const fieldsAt = (name: string) => {
251
+ const p = planted(name);
252
+ const r = bibliographyAt(
253
+ absolutePath("/p"),
254
+ depsOf(built("/p", leftByTeX(p), p.record)),
255
+ );
256
+ return r.ok
257
+ ? r.value.texts.map((t) => [
258
+ t.path,
259
+ t.entries.map((e) => [
260
+ e.key,
261
+ e.names["author"],
262
+ e.fields["booktitle"],
263
+ ]),
264
+ ])
265
+ : r.error;
266
+ };
267
+ const LATE = [
268
+ "late",
269
+ [{ lastName: "Lovelace", firstName: "Ada" }],
270
+ "Proceedings of the Shared Venue",
271
+ ];
272
+
273
+ it("🔴 v27-shared-string: references.bib's entry is read with the @strings abbreviations.bib defined, as bibtex read it", () => {
274
+ expect(fieldsAt("v27-shared-string")).toEqual([
275
+ ["/p/abbreviations.bib", []],
276
+ ["/p/references.bib", [LATE]],
277
+ ]);
278
+ });
279
+
280
+ it("🔴 v28-shared-string-block: a block that uses them is still the database TeX wrote, read where the author edits it", () => {
281
+ expect(fieldsAt("v28-shared-string-block")).toEqual([
282
+ ["/p/abbreviations.bib", []],
283
+ ["/p/paper.tex", [LATE]],
284
+ ]);
285
+ });
286
+ });