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
@@ -1,11 +1,16 @@
1
1
  /**
2
- * The references a build checked, end to end without a network: the `references` build step
2
+ * The references a build checked, end to end without a network: the `references` build step checks
3
+ * the databases the build's bibtex opened (`_build/sources.json`, docs/design/paper-sources.md §1),
3
4
  * records what a (fake) checker answers, and the offline rules judge that record.
4
5
  *
5
- * a pass is recorded, with the hash of the bibliography checked
6
- * one mismatched entry → paper/author-list on THAT entry's line
7
- * the bibliography edited after the build → paper/refs-fresh, and the per-entry rules go quiet
6
+ * a pass is recorded, with the hash of the databases checked
7
+ * one mismatched entry → paper/author-list on THAT entry, at the file, line and column it is in
8
+ * a database edited after the verdicts → paper/refs-fresh, and the per-entry rules go quiet
8
9
  * offline → the step still succeeds (the build is not failed) and paper/refs-checked warns
10
+ * no record of the build, or a stale one → the step checks nothing and the rules are silent
11
+ *
12
+ * The planted papers of `fixtures/paper-sources/` supply the record: each one's `tex-truth.json` is
13
+ * what the build wrote for it, taken from TeX's own files.
9
14
  */
10
15
  import { describe, expect, it } from "vitest";
11
16
  import { ESLint } from "eslint";
@@ -14,42 +19,49 @@ import { join } from "node:path";
14
19
  import { memoryFiles } from "./adapters/memory/index.ts";
15
20
  import { ok } from "./domain/result.ts";
16
21
  import { absolutePath } from "./domain/paths.ts";
22
+ import { sha256Hex } from "./domain/sha256.ts";
23
+ import {
24
+ serializeSourcesRecord,
25
+ type SourcesRecord,
26
+ } from "./domain/sources-record.ts";
17
27
  import type {
18
28
  CheckReferences,
19
29
  EntryVerdict,
20
30
  } from "./ports/check-references.ts";
21
31
  import { referencesStep } from "./build.ts";
32
+ import { notWiredRecord } from "./sources-record.ts";
22
33
  import {
23
34
  bibHash,
24
- bibliographyOf,
35
+ bibliographyReader,
25
36
  lookupCachePath,
37
+ notWiredBibliography,
38
+ recordReferences,
26
39
  readReferences,
27
40
  referencesPath,
41
+ unseenKeys,
28
42
  } from "./references.ts";
43
+ import { bibReader } from "./adapters/bibtex/index.ts";
44
+ import { latexReader } from "./adapters/latex/index.ts";
45
+ import { sourcesCodec } from "./adapters/sources-record/index.ts";
29
46
  import {
30
47
  parseLookupCache,
31
48
  serializeLookupCache,
32
49
  type CachedResponse,
33
50
  type LookupCache,
34
51
  } from "./domain/lookup-cache.ts";
52
+ import { bibtexRead } from "./latex-log.ts";
35
53
  import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
36
54
  import { texLanguage } from "../eslint-rules/latex-language.ts";
37
55
  import { present } from "../test/support.ts";
56
+ import { builtFixture } from "../test/recorded-fixture.ts";
38
57
 
39
58
  const PAPER = "/work/papers/p";
40
- const TEX = (entries: string) =>
41
- [
42
- "\\documentclass{acmart}",
43
- "\\begin{filecontents*}{refs.bib}",
44
- entries,
45
- "\\end{filecontents*}",
46
- "\\begin{document}",
47
- "x",
48
- "\\end{document}",
49
- "",
50
- ].join("\n");
51
59
  const ENTRIES =
52
- "@inproceedings{schick2023,\n title = {Toolformer},\n author = {Schick, Timo},\n booktitle = {NeurIPS}\n}\n@misc{other,\n title = {Other},\n url = {https://x.org}\n}";
60
+ "@inproceedings{schick2023,\n title = {Toolformer},\n author = {Schick, Timo},\n booktitle = {NeurIPS}\n}\n@misc{other,\n title = {Other},\n url = {https://x.org}\n}\n";
61
+ const TEX = "\\documentclass{article}\n\\begin{document}x\\end{document}\n";
62
+
63
+ type Files = ReturnType<typeof memoryFiles>;
64
+ const bytes = (s: string) => new TextEncoder().encode(s);
53
65
 
54
66
  const verdict = (
55
67
  key: string,
@@ -74,9 +86,32 @@ const offline: CheckReferences = (_bib, cache) =>
74
86
  },
75
87
  cache,
76
88
  });
89
+ /** A checker that hands back a passing verdict for each entry it is given, and records the keys. */
90
+ function passing() {
91
+ const handed: string[] = [];
92
+ const check: CheckReferences = (bib, cache) => {
93
+ handed.push(...bib.map((e) => e.key));
94
+ return Promise.resolve({
95
+ check: { kind: "checked", entries: bib.map((e) => verdict(e.key)) },
96
+ cache,
97
+ });
98
+ };
99
+ return { check, handed };
100
+ }
101
+ const never: CheckReferences = () => Promise.reject(new Error("never asked"));
102
+
103
+ /** What reads a paper's record and the databases it names: the disk, the schema, the bibtex reader. */
104
+ const depsOf = (files: Files) => ({
105
+ files,
106
+ codec: sourcesCodec,
107
+ latex: latexReader,
108
+ bib: bibReader,
109
+ });
77
110
 
78
111
  /** The step's context over `files`, with every port it does not use stubbed. */
79
- const ctx = (files: ReturnType<typeof memoryFiles>) => ({
112
+ const ctx = (files: Files) => ({
113
+ readBibliography: bibliographyReader(depsOf(files)),
114
+ recordSources: notWiredRecord,
80
115
  paperDir: PAPER,
81
116
  env: {},
82
117
  run: () => ({ status: 0 }),
@@ -89,9 +124,15 @@ const ctx = (files: ReturnType<typeof memoryFiles>) => ({
89
124
  files,
90
125
  });
91
126
 
127
+ /** The bibliography the record in `files` names, read as the step reads it. */
128
+ const readIn = (files: Files) => {
129
+ const r = bibliographyReader(depsOf(files))(PAPER);
130
+ if (r.kind !== "read") throw new Error(`not read: ${r.kind}`);
131
+ return r.bibliography;
132
+ };
133
+
92
134
  /** Run the build step on a paper held in memory; returns the files and the step's outcome. */
93
- async function build(tex: string, check: CheckReferences) {
94
- const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
135
+ async function build(files: Files, check: CheckReferences) {
95
136
  const out = await referencesStep.run({
96
137
  ...ctx(files),
97
138
  checkReferences: check,
@@ -99,11 +140,10 @@ async function build(tex: string, check: CheckReferences) {
99
140
  return { files, out };
100
141
  }
101
142
 
102
- /** Lint `tex` as the paper's paper.tex against what `files` recorded. */
103
- async function lint(files: ReturnType<typeof memoryFiles>, tex: string) {
104
- files.writeAtomic(
105
- absolutePath(`${PAPER}/paper.tex`),
106
- new TextEncoder().encode(tex),
143
+ /** Lint `paper.tex` of the paper in `files`. */
144
+ async function lint(files: Files) {
145
+ const tex = new TextDecoder().decode(
146
+ present(files.readBytes(absolutePath(`${PAPER}/paper.tex`)), "a paper.tex"),
107
147
  );
108
148
  const eslint = new ESLint({
109
149
  cwd: "/work",
@@ -113,7 +153,7 @@ async function lint(files: ReturnType<typeof memoryFiles>, tex: string) {
113
153
  files: ["**/paper.tex"],
114
154
  plugins: {
115
155
  tex: { languages: { latex: texLanguage } },
116
- paper: { rules: referenceRules({ files }) },
156
+ paper: { rules: referenceRules(depsOf(files)) },
117
157
  },
118
158
  language: "tex/latex",
119
159
  rules: REFERENCE_RULE_LEVELS,
@@ -126,28 +166,102 @@ async function lint(files: ReturnType<typeof memoryFiles>, tex: string) {
126
166
  return present(res, "one lint result").messages;
127
167
  }
128
168
 
169
+ // ── a paper in memory, with the record its build would have written ────────────────────────
170
+
171
+ /**
172
+ * A paper whose build opened `databases` (name → text), typeset `keys`, and wrote `written` itself. The
173
+ * record is what `paperlint build` writes for it: the bytes of paper.tex and of every database TeX did
174
+ * not write, hashed.
175
+ */
176
+ function built(o: {
177
+ readonly databases: Readonly<Record<string, string>>;
178
+ readonly keys: readonly string[];
179
+ readonly written?: readonly string[];
180
+ }): Files {
181
+ const written = o.written ?? [];
182
+ const record: SourcesRecord = {
183
+ schema: 1,
184
+ inputs: [{ path: "paper.tex", role: "body" }],
185
+ written,
186
+ bibdata: Object.keys(o.databases).map((d) => d.replace(/\.bib$/, "")),
187
+ bibtex: {
188
+ ran: true,
189
+ databases: Object.keys(o.databases),
190
+ keys: o.keys,
191
+ exit: 0,
192
+ errors: [],
193
+ },
194
+ sha256: Object.fromEntries([
195
+ ["paper.tex", sha256Hex(bytes(TEX))] as const,
196
+ ...Object.entries(o.databases)
197
+ .filter(([name]) => !written.includes(name))
198
+ .map(([name, text]) => [name, sha256Hex(bytes(text))] as const),
199
+ ]),
200
+ };
201
+ return memoryFiles({
202
+ [`${PAPER}/paper.tex`]: TEX,
203
+ [`${PAPER}/_build/sources.json`]: serializeSourcesRecord(record),
204
+ ...Object.fromEntries(
205
+ Object.entries(o.databases).map(([name, text]) => [
206
+ `${PAPER}/${name}`,
207
+ text,
208
+ ]),
209
+ ),
210
+ });
211
+ }
212
+
213
+ /** The ordinary paper: refs.bib with two entries, both typeset. */
214
+ const ordinary = () =>
215
+ built({
216
+ databases: { "refs.bib": ENTRIES },
217
+ keys: ["schick2023", "other"],
218
+ });
219
+
220
+ /** Rewrite the paper's record with `patch` laid over it. */
221
+ function patchRecord(files: Files, patch: (r: SourcesRecord) => SourcesRecord) {
222
+ const path = `${PAPER}/_build/sources.json`;
223
+ const r = sourcesCodec.parse(
224
+ new TextDecoder().decode(present(files.map.get(path), "a record")),
225
+ );
226
+ if (!r.ok) throw new Error(r.why);
227
+ files.map.set(path, bytes(serializeSourcesRecord(patch(r.record))));
228
+ }
229
+
230
+ // ── the planted papers of fixtures/paper-sources, with the record TeX wrote for each ───────
231
+
232
+ /** A planted paper under PAPER, and its `tex-truth.json` as the build's record. */
233
+ const planted = (paper: string, extra: Readonly<Record<string, string>> = {}) =>
234
+ memoryFiles({
235
+ ...builtFixture(paper, PAPER),
236
+ ...Object.fromEntries(
237
+ Object.entries(extra).map(([name, text]) => [`${PAPER}/${name}`, text]),
238
+ ),
239
+ });
240
+
129
241
  describe("the references build step", () => {
130
- it("records a pass, with the hash of the bibliography it checked", async () => {
131
- const tex = TEX(ENTRIES);
132
- const { files, out } = await build(
133
- tex,
242
+ it("records a pass, with the hash of the databases it checked and no record of what bibtex read", async () => {
243
+ const files = ordinary();
244
+ const { out } = await build(
245
+ files,
134
246
  checker([verdict("schick2023"), verdict("other")]),
135
247
  );
136
248
  expect(out.ok).toBe(true);
137
249
  const doc = readReferences(files, PAPER);
138
250
  expect(doc?.status).toBe("checked");
139
- expect(doc?.bib.sha256).toBe(
140
- bibHash(present(bibliographyOf(files, PAPER), "the bibliography")),
141
- );
142
- expect(await lint(files, tex)).toEqual([]);
251
+ expect(doc?.bib).toEqual({
252
+ sources: ["refs.bib"],
253
+ sha256: bibHash(readIn(files)),
254
+ });
255
+ // What bibtex read lives in sources.json, once.
256
+ expect(doc).not.toHaveProperty("bibtex");
257
+ expect(await lint(files)).toEqual([]);
143
258
  });
144
259
 
145
260
  it("🔴 offline: the step succeeds — the build is not failed — and lint warns once", async () => {
146
- const tex = TEX(ENTRIES);
147
- const { files, out } = await build(tex, offline);
261
+ const { files, out } = await build(ordinary(), offline);
148
262
  expect(out.ok).toBe(true);
149
263
  expect(readReferences(files, PAPER)?.status).toBe("not-checked");
150
- const msgs = await lint(files, tex);
264
+ const msgs = await lint(files);
151
265
  expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
152
266
  ["paper/refs-checked", 1],
153
267
  ]);
@@ -155,7 +269,7 @@ describe("the references build step", () => {
155
269
  });
156
270
 
157
271
  it("a checker that throws is recorded as not checked, not as a failed build", async () => {
158
- const { files, out } = await build(TEX(ENTRIES), () =>
272
+ const { files, out } = await build(ordinary(), () =>
159
273
  Promise.reject(new Error("DBLP exploded")),
160
274
  );
161
275
  expect(out.ok).toBe(true);
@@ -184,7 +298,7 @@ function fetching(key: string) {
184
298
  };
185
299
  return { check, seen };
186
300
  }
187
- const cacheText = (files: ReturnType<typeof memoryFiles>): string | null => {
301
+ const cacheText = (files: Files): string | null => {
188
302
  const b = files.readBytes(absolutePath(lookupCachePath(PAPER)));
189
303
  return b === null ? null : new TextDecoder().decode(b);
190
304
  };
@@ -192,7 +306,7 @@ const cacheText = (files: ReturnType<typeof memoryFiles>): string | null => {
192
306
  describe("the lookup cache — <paper>/repro/references-cache.json", () => {
193
307
  it("a build that fetched writes the cache; the next build is handed it and writes nothing", async () => {
194
308
  const f = fetching("crossref:doi:10.1/x");
195
- const { files, out } = await build(TEX(ENTRIES), f.check);
309
+ const { files, out } = await build(ordinary(), f.check);
196
310
  expect(out).toEqual({
197
311
  ok: true,
198
312
  note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/references-cache.json",
@@ -220,7 +334,7 @@ describe("the lookup cache — <paper>/repro/references-cache.json", () => {
220
334
  });
221
335
  });
222
336
 
223
- describe("the lookup cache — counting and refusing", () => {
337
+ describe("the lookup cache — counting", () => {
224
338
  it("two new answers are counted as two", async () => {
225
339
  const two: CheckReferences = (_bib, cache) =>
226
340
  Promise.resolve({
@@ -233,7 +347,7 @@ describe("the lookup cache — counting and refusing", () => {
233
347
  dblp: cache.dblp,
234
348
  },
235
349
  });
236
- const { out } = await build(TEX(ENTRIES), two);
350
+ const { out } = await build(ordinary(), two);
237
351
  expect(out).toEqual({
238
352
  ok: true,
239
353
  note: "references: 1 checked, 0 failing → _build/references.json; 2 new answers → repro/references-cache.json",
@@ -241,18 +355,46 @@ describe("the lookup cache — counting and refusing", () => {
241
355
  });
242
356
 
243
357
  it("no answer fetched and no cache on disk: no cache file is created", async () => {
244
- const { files } = await build(
245
- TEX(ENTRIES),
246
- checker([verdict("schick2023")]),
247
- );
358
+ const { files } = await build(ordinary(), checker([verdict("schick2023")]));
248
359
  expect(cacheText(files)).toBeNull();
249
360
  });
250
361
 
251
- it("🔴 a cache that does not parse is refused BY NAME and left alone — the check is not run over half a cache", async () => {
252
- const files = memoryFiles({
253
- [`${PAPER}/paper.tex`]: TEX(ENTRIES),
254
- [lookupCachePath(PAPER)]: '{"schema": 7}',
362
+ // Guards: an answer asked again after MAX_AGE_DAYS keeps its key, so the key count does not change.
363
+ // Counted by size, the refreshed answer was not written, and every later build asked it again.
364
+ it("an answer refreshed under the same key is counted and written", async () => {
365
+ const OLD: CachedResponse = { ...ANSWER, fetched: "2026-08-01" };
366
+ const put =
367
+ (answer: CachedResponse): CheckReferences =>
368
+ (_bib, cache) =>
369
+ Promise.resolve({
370
+ check: { kind: "checked", entries: [verdict("schick2023")] },
371
+ cache: {
372
+ citations: new Map([...cache.citations, ["a", answer]]),
373
+ dblp: cache.dblp,
374
+ },
375
+ });
376
+ const { files } = await build(ordinary(), put(OLD));
377
+ const again = await referencesStep.run({
378
+ ...ctx(files),
379
+ checkReferences: put(ANSWER),
380
+ });
381
+ expect({ again, file: cacheText(files) }).toEqual({
382
+ again: {
383
+ ok: true,
384
+ note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/references-cache.json",
385
+ },
386
+ file: serializeLookupCache({
387
+ citations: new Map([["a", ANSWER]]),
388
+ dblp: new Map(),
389
+ }),
255
390
  });
391
+ });
392
+ });
393
+
394
+ describe("the lookup cache — refusing", () => {
395
+ it("🔴 a cache that does not parse is refused BY NAME and left alone — the check is not run over half a cache", async () => {
396
+ const files = ordinary();
397
+ files.map.set(lookupCachePath(PAPER), bytes('{"schema": 7}'));
256
398
  const f = fetching("k");
257
399
  const out = await referencesStep.run({
258
400
  ...ctx(files),
@@ -269,83 +411,161 @@ describe("the lookup cache — counting and refusing", () => {
269
411
  });
270
412
 
271
413
  describe("the reference rules", () => {
272
- it("one mismatched entry → paper/author-list on that entry's own line", async () => {
273
- const tex = TEX(ENTRIES);
414
+ it("one mismatched entry → paper/author-list at that entry, naming its file, line and column", async () => {
274
415
  const { files } = await build(
275
- tex,
416
+ ordinary(),
276
417
  checker([verdict("schick2023", "mismatch"), verdict("other")]),
277
418
  );
278
- const msgs = await lint(files, tex);
279
- expect(msgs.map((m) => m.ruleId)).toEqual(["paper/author-list"]);
280
- expect(msgs[0]?.line).toBe(3);
281
- expect(msgs[0]?.message).toMatch(/`schick2023`.*missing hambro/);
419
+ const msgs = await lint(files);
420
+ expect(msgs.map((m) => [m.ruleId, m.line, m.column])).toEqual([
421
+ ["paper/author-list", 1, 1],
422
+ ]);
423
+ expect(msgs[0]?.message).toMatch(
424
+ /^refs\.bib:1:1: `schick2023`.*missing hambro/,
425
+ );
282
426
  });
283
427
 
284
428
  it("an identifier that provably fails → paper/cite-exists on that entry", async () => {
285
- const tex = TEX(ENTRIES);
286
429
  const { files } = await build(
287
- tex,
430
+ ordinary(),
288
431
  checker([verdict("schick2023"), verdict("other", "skipped", "false")]),
289
432
  );
290
- const msgs = await lint(files, tex);
291
- expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
292
- ["paper/cite-exists", 8],
433
+ const msgs = await lint(files);
434
+ expect(msgs.map((m) => [m.ruleId, m.message.split(": ")[0]])).toEqual([
435
+ ["paper/cite-exists", "refs.bib:6:1"],
293
436
  ]);
294
437
  });
295
438
 
296
- it("🔴 the bibliography edited after the build → refs-fresh, and the stale verdicts are not judged", async () => {
297
- const { files } = await build(
298
- TEX(ENTRIES),
299
- checker([verdict("schick2023", "mismatch"), verdict("other")]),
300
- );
301
- const edited = TEX(
302
- ENTRIES.replace("Schick, Timo", "Schick, Timo and Hambro, Eric"),
439
+ it("a database in a subfolder is named as bibtex names it", async () => {
440
+ const files = built({
441
+ databases: { "bibs/x.bib": ENTRIES },
442
+ keys: ["schick2023", "other"],
443
+ });
444
+ await build(
445
+ files,
446
+ checker([verdict("schick2023"), verdict("other", "skipped", "false")]),
303
447
  );
304
- const msgs = await lint(files, edited);
305
- expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
306
- ["paper/refs-fresh", 2],
448
+ expect((await lint(files)).map((m) => m.message.split(": ")[0])).toEqual([
449
+ "bibs/x.bib:6:1",
307
450
  ]);
308
451
  });
309
452
 
310
- it("the same bibliography, unchanged → silent", async () => {
311
- const tex = TEX(ENTRIES);
312
- const { files } = await build(tex, checker([verdict("schick2023")]));
313
- expect(await lint(files, tex)).toEqual([]);
453
+ it("only paper.tex is judged", () => {
454
+ const rules = referenceRules(depsOf(memoryFiles({})));
455
+ for (const rule of Object.values(rules))
456
+ expect(
457
+ rule.create({
458
+ filename: `${PAPER}/notes.tex`,
459
+ cwd: "/work",
460
+ sourceCode: {
461
+ raw: "",
462
+ getLocFromIndex: () => ({ line: 1, column: 0 }),
463
+ },
464
+ report: () => {
465
+ throw new Error("a file that is not paper.tex is never reported");
466
+ },
467
+ }),
468
+ ).toEqual({});
314
469
  });
470
+ });
315
471
 
316
- it("never built → one warning naming the command", async () => {
317
- const files = memoryFiles({});
318
- const msgs = await lint(files, TEX(ENTRIES));
319
- expect(msgs.map((m) => m.ruleId)).toEqual(["paper/refs-checked"]);
320
- expect(msgs[0]?.message).toMatch(/npx paperlint build/);
472
+ describe("the references step and the rules, when there is no usable record of the build", () => {
473
+ it("🔴 never built: the step checks nothing and records nothing; the rules are silent — paper/sources-fresh speaks", async () => {
474
+ const files = memoryFiles({
475
+ [`${PAPER}/paper.tex`]: TEX,
476
+ [`${PAPER}/refs.bib`]: ENTRIES,
477
+ });
478
+ const { out } = await build(files, never);
479
+ expect(out).toEqual({
480
+ ok: true,
481
+ note: "references NOT checked — the build recorded no sources (_build/sources.json was not written); lint will say so",
482
+ });
483
+ expect(readReferences(files, PAPER)).toBeNull();
484
+ expect(await lint(files)).toEqual([]);
321
485
  });
322
486
 
323
- it("a paper with no bibliography gets nothing", async () => {
324
- const files = memoryFiles({});
325
- expect(
326
- await lint(
327
- files,
328
- "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
329
- ),
330
- ).toEqual([]);
331
- expect(referencesPath(PAPER)).toBe(`${PAPER}/_build/references.json`);
487
+ it("a record this paperlint cannot read is named", async () => {
488
+ const files = ordinary();
489
+ files.map.set(`${PAPER}/_build/sources.json`, bytes('{"schema":2}'));
490
+ const { out } = await build(files, never);
491
+ expect(out).toEqual({
492
+ ok: true,
493
+ note: "references NOT checked — _build/sources.json cannot be used (sources.json is schema 2, this paperlint reads schema 1); lint will say so",
494
+ });
332
495
  });
333
- });
334
496
 
335
- describe("reading what is on disk", () => {
336
- it("no paper.tex: the bibliography is refs.bib, from its first byte", () => {
337
- const files = memoryFiles({ [`${PAPER}/refs.bib`]: ENTRIES });
338
- expect(bibliographyOf(files, PAPER)).toEqual({
339
- source: "refs.bib",
340
- text: ENTRIES,
341
- offset: 0,
497
+ it("🔴 a record older than the paper: the step checks nothing, and the verdicts already on disk are not judged", async () => {
498
+ const files = ordinary();
499
+ await build(
500
+ files,
501
+ checker([verdict("schick2023", "mismatch"), verdict("other")]),
502
+ );
503
+ files.map.set(
504
+ `${PAPER}/refs.bib`,
505
+ bytes(ENTRIES.replace("Schick, Timo", "Schick, Timo and Hambro, Eric")),
506
+ );
507
+ expect(await lint(files)).toEqual([]);
508
+ const { out } = await build(files, never);
509
+ expect(out).toEqual({
510
+ ok: true,
511
+ note: "references NOT checked — the paper changed since the build recorded it (refs.bib edited); lint will say so",
342
512
  });
343
513
  });
344
514
 
515
+ it("no reader wired into the build: it says so, and checks nothing", async () => {
516
+ const note = await recordReferences(
517
+ ordinary(),
518
+ PAPER,
519
+ notWiredBibliography(PAPER),
520
+ never,
521
+ );
522
+ expect(note).toBe(
523
+ "references NOT checked — no bibliography reader was wired into this build; lint will say so",
524
+ );
525
+ });
526
+ });
527
+
528
+ describe("the reference rules, when the verdicts are not about the databases the record names", () => {
529
+ it("🔴 a later build recorded the databases, but its references step did not run → refs-fresh, and the stale verdicts are not judged", async () => {
530
+ const files = ordinary();
531
+ await build(
532
+ files,
533
+ checker([verdict("schick2023", "mismatch"), verdict("other")]),
534
+ );
535
+ // The next build edited refs.bib and recorded it (sources.json is rewritten after every compile)
536
+ // and stopped before the references step: references.json is about the earlier refs.bib.
537
+ const edited = ENTRIES.replace("Schick, Timo", "Schick, Timo and Hambro");
538
+ files.map.set(`${PAPER}/refs.bib`, bytes(edited));
539
+ patchRecord(files, (r) => ({
540
+ ...r,
541
+ sha256: { ...r.sha256, "refs.bib": sha256Hex(bytes(edited)) },
542
+ }));
543
+ const msgs = await lint(files);
544
+ expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
545
+ ["paper/refs-fresh", 2],
546
+ ]);
547
+ });
548
+
549
+ it("the same databases, unchanged → silent", async () => {
550
+ const files = ordinary();
551
+ await build(files, checker([verdict("schick2023"), verdict("other")]));
552
+ expect(await lint(files)).toEqual([]);
553
+ });
554
+
555
+ it("a paper whose build ran no bibtex gets nothing", async () => {
556
+ const files = planted("v15-after-end");
557
+ expect(await lint(files)).toEqual([]);
558
+ expect(referencesPath(PAPER)).toBe(`${PAPER}/_build/references.json`);
559
+ });
560
+
345
561
  it("a references.json of another schema, without entries, or not JSON reads as none", () => {
346
562
  for (const body of [
347
563
  JSON.stringify({ schema: 999, entries: [] }),
348
- JSON.stringify({ schema: 1 }),
564
+ // Schema 1 named one file in `bib.source`; the next build rewrites it.
565
+ JSON.stringify({ schema: 1, entries: [] }),
566
+ // Schema 2 recorded what bibtex read here; sources.json holds it now.
567
+ JSON.stringify({ schema: 2, entries: [] }),
568
+ JSON.stringify({ schema: 3 }),
349
569
  "null",
350
570
  "{",
351
571
  ]) {
@@ -355,116 +575,239 @@ describe("reading what is on disk", () => {
355
575
  });
356
576
  });
357
577
 
358
- /** A record for `bib`, as the build would write it, with `patch` applied to the document. */
359
- const record = (
360
- files: ReturnType<typeof memoryFiles>,
361
- body: Record<string, unknown>,
362
- ) => {
363
- const bib = present(bibliographyOf(files, PAPER), "the bibliography");
578
+ /** A references.json for the paper's databases as the build would write it, with `body` laid over it. */
579
+ const writeVerdicts = (files: Files, body: Record<string, unknown>): void => {
364
580
  files.writeAtomic(
365
581
  absolutePath(referencesPath(PAPER)),
366
- new TextEncoder().encode(
582
+ bytes(
367
583
  JSON.stringify({
368
- schema: 1,
369
- bib: { source: bib.source, sha256: bibHash(bib) },
584
+ schema: 3,
585
+ bib: { sources: ["refs.bib"], sha256: bibHash(readIn(files)) },
370
586
  ...body,
371
587
  }),
372
588
  ),
373
589
  );
374
590
  };
375
591
 
376
- describe("the reference rules over a record written by hand", () => {
592
+ describe("the reference rules over a references.json written by hand", () => {
377
593
  it("not-checked with no reason recorded says so", async () => {
378
- const tex = TEX(ENTRIES);
379
- const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
380
- record(files, { status: "not-checked", entries: [] });
381
- const msgs = await lint(files, tex);
594
+ const files = ordinary();
595
+ writeVerdicts(files, { status: "not-checked", entries: [] });
596
+ const msgs = await lint(files);
382
597
  expect(msgs.map((m) => m.ruleId)).toEqual(["paper/refs-checked"]);
383
598
  expect(msgs[0]?.message).toMatch(/no reason recorded/);
384
599
  });
385
600
 
386
- it("a verdict without a reason, and one for a key the bibliography lacks, still report", async () => {
387
- const tex = TEX(ENTRIES);
388
- const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
389
- record(files, {
601
+ it("a record of the build and no verdicts yet: the references have not been checked", async () => {
602
+ const msgs = await lint(ordinary());
603
+ expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
604
+ ["paper/refs-checked", 1],
605
+ ]);
606
+ expect(msgs[0]?.message).toMatch(/npx paperlint build/);
607
+ });
608
+
609
+ it("a verdict without a reason still reports, on the entry it is about", async () => {
610
+ const files = ordinary();
611
+ writeVerdicts(files, {
390
612
  status: "checked",
391
613
  entries: [
392
614
  { key: "schick2023", exists: "true", authors: "mismatch" },
393
- { key: "ghost", exists: "false", authors: "match" },
615
+ { key: "other", exists: "false", authors: "match" },
394
616
  ],
395
617
  });
396
- const msgs = await lint(files, tex);
397
- expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
398
- ["paper/cite-exists", 1],
399
- ["paper/author-list", 3],
618
+ const msgs = await lint(files);
619
+ expect(msgs.map((m) => [m.ruleId, m.message.split(": ")[0]])).toEqual([
620
+ ["paper/author-list", "refs.bib:1:1"],
621
+ ["paper/cite-exists", "refs.bib:6:1"],
400
622
  ]);
401
623
  });
624
+
625
+ it("verdicts that are not one per entry, in order, are not about these databases: refs-fresh", async () => {
626
+ const files = ordinary();
627
+ const judged = async (entries: readonly EntryVerdict[]) => {
628
+ writeVerdicts(files, { status: "checked", entries });
629
+ return (await lint(files)).map((m) => m.ruleId);
630
+ };
631
+ expect([
632
+ await judged([verdict("schick2023", "mismatch")]),
633
+ await judged([verdict("other"), verdict("schick2023", "mismatch")]),
634
+ ]).toEqual([["paper/refs-fresh"], ["paper/refs-fresh"]]);
635
+ });
402
636
  });
403
637
 
404
- describe("the reference rules over refs.bib, and on other files", () => {
405
- it("an external refs.bib: findings sit at the start of paper.tex, naming the key", async () => {
406
- const tex = "\\documentclass{acmart}\n\\begin{document}x\\end{document}\n";
407
- const files = memoryFiles({
408
- [`${PAPER}/paper.tex`]: tex,
409
- [`${PAPER}/refs.bib`]: ENTRIES,
638
+ describe("the references step checks the databases bibtex opened (tex-truth.json)", () => {
639
+ it.each([
640
+ ["v1-stale", ["refs.bib"], ["stale2020"]],
641
+ ["v3-declared", ["paper.bib"], ["declared2023"]],
642
+ // Two databases in one `\bibliography`, in the order bibtex opened them.
643
+ ["v26-comment-in-names", ["one.bib", "two.bib"], ["onekey", "twokey"]],
644
+ // Both databases sit in the folder; the switch decided which one TeX read, and bibtex opened one.
645
+ ["v10-ifthenelse", ["anon.bib"], ["anonkey"]],
646
+ // The comment lines are bibtex's entries, not the reader's: the post-build check names them.
647
+ ["v13-percent-text", ["refs.bib"], ["ok1"]],
648
+ ])("%s: checks %j, keys %j", async (paper, sources, keys) => {
649
+ const files = planted(paper);
650
+ const f = passing();
651
+ await build(files, f.check);
652
+ expect({
653
+ sources: readReferences(files, PAPER)?.bib.sources,
654
+ keys: f.handed,
655
+ }).toEqual({ sources, keys });
656
+ });
657
+
658
+ it("a database TeX wrote that is on disk (the build's leftover) is checked as bibtex read it", async () => {
659
+ const files = planted("v8-jobname");
660
+ const f = passing();
661
+ await build(files, f.check);
662
+ expect(f.handed).toEqual(["jkey"]);
663
+ expect(readReferences(files, PAPER)?.bib.sources).toEqual(["paper.bib"]);
664
+ });
665
+
666
+ it("two entries sharing a key across databases each get a verdict, in order", async () => {
667
+ const copy = (title: string) =>
668
+ `@misc{k,\n title = {${title}},\n url = {https://x.org}\n}\n`;
669
+ const files = built({
670
+ databases: { "a.bib": copy("Good"), "b.bib": `\n\n${copy("Fake")}` },
671
+ keys: ["k"],
410
672
  });
411
- record(files, {
412
- status: "checked",
413
- entries: [verdict("schick2023", "mismatch")],
673
+ await referencesStep.run({
674
+ ...ctx(files),
675
+ checkReferences: (bib, cache) =>
676
+ Promise.resolve({
677
+ check: {
678
+ kind: "checked",
679
+ entries: bib.map((e) => ({
680
+ key: e.key,
681
+ exists: e.fields["title"] === "Fake" ? "false" : "true",
682
+ authors: "match",
683
+ })),
684
+ },
685
+ cache,
686
+ }),
414
687
  });
415
- const msgs = await lint(files, tex);
416
- expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
417
- ["paper/author-list", 1],
688
+ const msgs = await lint(files);
689
+ expect(msgs.map((m) => [m.ruleId, m.message.split(": ")[0]])).toEqual([
690
+ ["paper/cite-exists", "b.bib:3:1"],
418
691
  ]);
419
- expect(msgs[0]?.message).toMatch(/schick2023/);
420
692
  });
693
+ });
421
694
 
422
- it("only paper.tex is judged", () => {
423
- const files = memoryFiles({});
424
- const rules = referenceRules({ files });
425
- for (const rule of Object.values(rules))
426
- expect(
427
- rule.create({
428
- filename: `${PAPER}/notes.tex`,
429
- cwd: "/work",
430
- sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
431
- report: () => {
432
- throw new Error("a file that is not paper.tex is never reported");
433
- },
434
- }),
435
- ).toEqual({});
695
+ describe("when there is nothing bibtex opened, the step says why — and records nothing", () => {
696
+ it.each(["v15-after-end", "v22-parked-thebibliography"])(
697
+ "%s: the build ran no bibtex",
698
+ async (paper) => {
699
+ const files = planted(paper);
700
+ const { out } = await build(files, never);
701
+ expect(out).toEqual({
702
+ ok: true,
703
+ note: "no bibliography database — bibtex opened none, nothing to check",
704
+ });
705
+ expect(readReferences(files, PAPER)).toBeNull();
706
+ },
707
+ );
708
+
709
+ it("a bibtex that ran and opened no database leaves nothing to check either", async () => {
710
+ const files = ordinary();
711
+ patchRecord(files, (r) => ({
712
+ ...r,
713
+ bibtex: r.bibtex.ran ? { ...r.bibtex, databases: [] } : r.bibtex,
714
+ }));
715
+ const { out } = await build(files, never);
716
+ expect(out).toEqual({
717
+ ok: true,
718
+ note: "no bibliography database — bibtex opened none, nothing to check",
719
+ });
720
+ expect(await lint(files)).toEqual([]);
721
+ });
722
+
723
+ it("🔴 a database TeX wrote that a clean removed after the build makes the record stale: the step says so", async () => {
724
+ const files = planted("v11-two-blocks");
725
+ files.map.delete(`${PAPER}/refs.bib`);
726
+ const { out } = await build(files, never);
727
+ expect(out).toEqual({
728
+ ok: true,
729
+ note: "references NOT checked — the paper changed since the build recorded it (refs.bib deleted); lint will say so",
730
+ });
731
+ expect(readReferences(files, PAPER)).toBeNull();
732
+ });
733
+
734
+ it("🔴 a database bibtex found outside the paper directory cannot be read: the step says which, and lint warns", async () => {
735
+ const files = planted("v11-two-blocks");
736
+ files.map.delete(`${PAPER}/refs.bib`);
737
+ patchRecord(files, (r) => ({
738
+ ...r,
739
+ sha256: { ...r.sha256, "refs.bib": null },
740
+ }));
741
+ const { out } = await build(files, never);
742
+ expect(out).toEqual({
743
+ ok: true,
744
+ note: "references NOT checked — refs.bib, which bibtex opened, is not in the paper directory; lint will say so",
745
+ });
746
+ expect(readReferences(files, PAPER)).toBeNull();
747
+ const msgs = await lint(files);
748
+ expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
749
+ ["paper/refs-checked", 1],
750
+ ]);
751
+ expect(msgs[0]?.message).toMatch(/refs\.bib.*not in the paper directory/);
436
752
  });
437
753
  });
438
754
 
439
- describe("the lookup cache — refreshed answers", () => {
440
- // Guards: an answer asked again after MAX_AGE_DAYS keeps its key, so the key count does not change.
441
- // Counted by size, the refreshed answer was not written, and every later build asked it again.
442
- it("an answer refreshed under the same key is counted and written", async () => {
443
- const OLD: CachedResponse = { ...ANSWER, fetched: "2026-08-01" };
444
- const put =
445
- (answer: CachedResponse): CheckReferences =>
446
- (_bib, cache) =>
447
- Promise.resolve({
448
- check: { kind: "checked", entries: [verdict("schick2023")] },
449
- cache: {
450
- citations: new Map([...cache.citations, ["a", answer]]),
451
- dblp: cache.dblp,
452
- },
453
- });
454
- const { files } = await build(TEX(ENTRIES), put(OLD));
455
- const again = await referencesStep.run({
456
- ...ctx(files),
457
- checkReferences: put(ANSWER),
755
+ describe("what bibtex typeset, against what paperlint read (the post-build check)", () => {
756
+ it("🔴 entries bibtex read that the reader does not see (behind `%`, inside `@comment`) → one refs-checked finding names them", async () => {
757
+ const files = planted("v13-percent-text");
758
+ await build(files, passing().check);
759
+ const msgs = await lint(files);
760
+ expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
761
+ ["paper/refs-checked", 1],
762
+ ]);
763
+ expect(msgs[0]?.message).toMatch(/`pt1`, `k2inComment`/);
764
+ });
765
+
766
+ it("an entry the reader read and bibtex typeset is not named", async () => {
767
+ const files = planted("v1-stale");
768
+ await build(files, passing().check);
769
+ expect(await lint(files)).toEqual([]);
770
+ });
771
+
772
+ it("an entry the reader read and bibtex did not typeset (not cited) is not named", async () => {
773
+ const files = ordinary();
774
+ patchRecord(files, (r) => ({
775
+ ...r,
776
+ bibtex: r.bibtex.ran ? { ...r.bibtex, keys: ["schick2023"] } : r.bibtex,
777
+ }));
778
+ await build(files, passing().check);
779
+ expect(await lint(files)).toEqual([]);
780
+ });
781
+
782
+ it("🔴 bibtex's keys are case-insensitive: `\\cite{SMITH}` typesets `\\bibitem{SMITH}` from an entry `smith`", async () => {
783
+ // Measured: pdflatex and bibtex on `\cite{SMITH}` with `@misc{smith,…}` write `\bibitem{SMITH}`.
784
+ const files = built({
785
+ databases: { "refs.bib": "@misc{smith, url = {https://x.org}}\n" },
786
+ keys: ["SMITH"],
458
787
  });
459
- expect({ again, file: cacheText(files) }).toEqual({
460
- again: {
461
- ok: true,
462
- note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/references-cache.json",
463
- },
464
- file: serializeLookupCache({
465
- citations: new Map([["a", ANSWER]]),
466
- dblp: new Map(),
467
- }),
788
+ await build(files, passing().check);
789
+ expect(await lint(files)).toEqual([]);
790
+ });
791
+
792
+ it("unseenKeys: the typeset keys no entry read has, in the order bibtex typeset them, each once", () => {
793
+ const files = built({
794
+ databases: { "refs.bib": "@misc{a, url = {https://x.org}}\n" },
795
+ keys: ["a", "x", "A", "y", "x"],
796
+ });
797
+ expect(unseenKeys(readIn(files))).toEqual(["x", "y"]);
798
+ });
799
+ });
800
+
801
+ describe("reading what bibtex left", () => {
802
+ it("bibtexRead: the databases the .blg names, the keys the .bbl typesets — natbib's labels too", () => {
803
+ expect(
804
+ bibtexRead(
805
+ "This is BibTeX\nDatabase file #1: refs.bib\nDatabase file #2: /lib/shared.bib\n",
806
+ "\\bibitem{plain}\n\\bibitem[{Smith et~al.(2020)}]{smith}\n\\bibitem[Doe(2019)]{doe}\n",
807
+ ),
808
+ ).toEqual({
809
+ databases: ["refs.bib", "/lib/shared.bib"],
810
+ bibitems: ["plain", "smith", "doe"],
468
811
  });
469
812
  });
470
813
  });