paperlint 8.0.0 → 9.0.1

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 (393) hide show
  1. package/.github/CLAUDE.md +22 -0
  2. package/.github/CLAUDE.md.spec.ts +20 -0
  3. package/CLAUDE.md +59 -531
  4. package/CONTRIBUTING.md +7 -6
  5. package/README.md +4 -3
  6. package/dist/CLAUDE.md.spec.d.ts +3 -0
  7. package/dist/CLAUDE.md.spec.d.ts.map +1 -0
  8. package/dist/CLAUDE.md.spec.js +62 -0
  9. package/dist/CLAUDE.md.spec.js.map +1 -0
  10. package/dist/adapters/bibtex/index.d.ts +3 -0
  11. package/dist/adapters/bibtex/index.d.ts.map +1 -0
  12. package/dist/adapters/bibtex/index.js +103 -0
  13. package/dist/adapters/bibtex/index.js.map +1 -0
  14. package/dist/adapters/latex/filecontents.d.ts +33 -0
  15. package/dist/adapters/latex/filecontents.d.ts.map +1 -0
  16. package/dist/adapters/latex/filecontents.js +65 -0
  17. package/dist/adapters/latex/filecontents.js.map +1 -0
  18. package/dist/adapters/latex/includes.d.ts +3 -3
  19. package/dist/adapters/latex/includes.d.ts.map +1 -1
  20. package/dist/adapters/latex/includes.js +18 -10
  21. package/dist/adapters/latex/includes.js.map +1 -1
  22. package/dist/adapters/latex/index.d.ts.map +1 -1
  23. package/dist/adapters/latex/index.js +2 -0
  24. package/dist/adapters/latex/index.js.map +1 -1
  25. package/dist/adapters/latex/nodes.d.ts +12 -0
  26. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  27. package/dist/adapters/latex/nodes.js +39 -0
  28. package/dist/adapters/latex/nodes.js.map +1 -1
  29. package/dist/adapters/latex/parse.d.ts +7 -1
  30. package/dist/adapters/latex/parse.d.ts.map +1 -1
  31. package/dist/adapters/latex/parse.js +55 -6
  32. package/dist/adapters/latex/parse.js.map +1 -1
  33. package/dist/adapters/memory/index.d.ts +3 -0
  34. package/dist/adapters/memory/index.d.ts.map +1 -1
  35. package/dist/adapters/memory/index.js +9 -0
  36. package/dist/adapters/memory/index.js.map +1 -1
  37. package/dist/adapters/references/index.d.ts.map +1 -1
  38. package/dist/adapters/references/index.js +27 -24
  39. package/dist/adapters/references/index.js.map +1 -1
  40. package/dist/adapters/sources-record/index.d.ts +9 -0
  41. package/dist/adapters/sources-record/index.d.ts.map +1 -0
  42. package/dist/adapters/sources-record/index.js +68 -0
  43. package/dist/adapters/sources-record/index.js.map +1 -0
  44. package/dist/adapters/tex-output/blg.d.ts +9 -0
  45. package/dist/adapters/tex-output/blg.d.ts.map +1 -0
  46. package/dist/adapters/tex-output/blg.js +20 -0
  47. package/dist/adapters/tex-output/blg.js.map +1 -0
  48. package/dist/adapters/tex-output/fls.d.ts +8 -0
  49. package/dist/adapters/tex-output/fls.d.ts.map +1 -0
  50. package/dist/adapters/tex-output/fls.js +16 -0
  51. package/dist/adapters/tex-output/fls.js.map +1 -0
  52. package/dist/adapters/tex-output/index.d.ts +7 -0
  53. package/dist/adapters/tex-output/index.d.ts.map +1 -0
  54. package/dist/adapters/tex-output/index.js +7 -0
  55. package/dist/adapters/tex-output/index.js.map +1 -0
  56. package/dist/bib-rules.d.ts +47 -0
  57. package/dist/bib-rules.d.ts.map +1 -0
  58. package/dist/bib-rules.js +127 -0
  59. package/dist/bib-rules.js.map +1 -0
  60. package/dist/build.d.ts +29 -1
  61. package/dist/build.d.ts.map +1 -1
  62. package/dist/build.js +41 -25
  63. package/dist/build.js.map +1 -1
  64. package/dist/claim-provenance.d.ts +2 -2
  65. package/dist/cli.d.ts +6 -1
  66. package/dist/cli.d.ts.map +1 -1
  67. package/dist/cli.js +30 -26
  68. package/dist/cli.js.map +1 -1
  69. package/dist/domain/paper-source.d.ts +1 -10
  70. package/dist/domain/paper-source.d.ts.map +1 -1
  71. package/dist/domain/paper-source.js +34 -24
  72. package/dist/domain/paper-source.js.map +1 -1
  73. package/dist/domain/paper-sources.d.ts +72 -0
  74. package/dist/domain/paper-sources.d.ts.map +1 -0
  75. package/dist/domain/paper-sources.js +18 -0
  76. package/dist/domain/paper-sources.js.map +1 -0
  77. package/dist/domain/sources-record.d.ts +102 -0
  78. package/dist/domain/sources-record.d.ts.map +1 -0
  79. package/dist/domain/sources-record.js +38 -0
  80. package/dist/domain/sources-record.js.map +1 -0
  81. package/dist/domain/tex-run.d.ts +25 -0
  82. package/dist/domain/tex-run.d.ts.map +1 -0
  83. package/dist/domain/tex-run.js +79 -0
  84. package/dist/domain/tex-run.js.map +1 -0
  85. package/dist/eslint-rules/CLAUDE.md.spec.d.ts +3 -0
  86. package/dist/eslint-rules/CLAUDE.md.spec.d.ts.map +1 -0
  87. package/dist/eslint-rules/CLAUDE.md.spec.js +39 -0
  88. package/dist/eslint-rules/CLAUDE.md.spec.js.map +1 -0
  89. package/dist/eslint-rules/install-path-literals.d.ts +1 -1
  90. package/dist/eslint-rules/install-path-literals.js +4 -4
  91. package/dist/eslint-rules/install-path-literals.js.map +1 -1
  92. package/dist/eslint-rules/paper-context.d.ts.map +1 -1
  93. package/dist/eslint-rules/paper-context.js +4 -3
  94. package/dist/eslint-rules/paper-context.js.map +1 -1
  95. package/dist/eslint-rules/paper-typography.d.ts +0 -9
  96. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  97. package/dist/eslint-rules/paper-typography.js +12 -5
  98. package/dist/eslint-rules/paper-typography.js.map +1 -1
  99. package/dist/eslint-rules/pdf-last-page-balance.js +1 -1
  100. package/dist/latex-log.d.ts +10 -0
  101. package/dist/latex-log.d.ts.map +1 -1
  102. package/dist/latex-log.js +10 -0
  103. package/dist/latex-log.js.map +1 -1
  104. package/dist/latex-loop.d.ts +2 -0
  105. package/dist/latex-loop.d.ts.map +1 -1
  106. package/dist/latex-loop.js.map +1 -1
  107. package/dist/lib/agent-cli-version.js +1 -1
  108. package/dist/lib/agent-cli-version.js.map +1 -1
  109. package/dist/package-dirs.d.ts.map +1 -1
  110. package/dist/package-dirs.js +3 -2
  111. package/dist/package-dirs.js.map +1 -1
  112. package/dist/paper-includes.d.ts +4 -7
  113. package/dist/paper-includes.d.ts.map +1 -1
  114. package/dist/paper-includes.js +29 -21
  115. package/dist/paper-includes.js.map +1 -1
  116. package/dist/paper-prose.d.ts +2 -2
  117. package/dist/paper-record.d.ts +25 -0
  118. package/dist/paper-record.d.ts.map +1 -0
  119. package/dist/paper-record.js +39 -0
  120. package/dist/paper-record.js.map +1 -0
  121. package/dist/paper-sources.d.ts +66 -0
  122. package/dist/paper-sources.d.ts.map +1 -0
  123. package/dist/paper-sources.js +92 -0
  124. package/dist/paper-sources.js.map +1 -0
  125. package/dist/ports/bib-reader.d.ts +18 -0
  126. package/dist/ports/bib-reader.d.ts.map +1 -0
  127. package/dist/ports/bib-reader.js +2 -0
  128. package/dist/ports/bib-reader.js.map +1 -0
  129. package/dist/ports/check-references.d.ts +9 -4
  130. package/dist/ports/check-references.d.ts.map +1 -1
  131. package/dist/ports/latex.d.ts +15 -0
  132. package/dist/ports/latex.d.ts.map +1 -1
  133. package/dist/ports/sources-codec.d.ts +11 -0
  134. package/dist/ports/sources-codec.d.ts.map +1 -0
  135. package/dist/ports/sources-codec.js +2 -0
  136. package/dist/ports/sources-codec.js.map +1 -0
  137. package/dist/ports/tex-output.d.ts +17 -0
  138. package/dist/ports/tex-output.d.ts.map +1 -0
  139. package/dist/ports/tex-output.js +2 -0
  140. package/dist/ports/tex-output.js.map +1 -0
  141. package/dist/recorded-bibliography.d.ts +89 -0
  142. package/dist/recorded-bibliography.d.ts.map +1 -0
  143. package/dist/recorded-bibliography.js +136 -0
  144. package/dist/recorded-bibliography.js.map +1 -0
  145. package/dist/reference-rules.d.ts +5 -5
  146. package/dist/reference-rules.d.ts.map +1 -1
  147. package/dist/reference-rules.js +150 -77
  148. package/dist/reference-rules.js.map +1 -1
  149. package/dist/references.d.ts +71 -16
  150. package/dist/references.d.ts.map +1 -1
  151. package/dist/references.js +110 -26
  152. package/dist/references.js.map +1 -1
  153. package/dist/register-bands.d.ts +3 -3
  154. package/dist/register-bands.d.ts.map +1 -1
  155. package/dist/register-bands.js.map +1 -1
  156. package/dist/register.d.ts +2 -2
  157. package/dist/sources-record.d.ts +38 -0
  158. package/dist/sources-record.d.ts.map +1 -0
  159. package/dist/sources-record.js +123 -0
  160. package/dist/sources-record.js.map +1 -0
  161. package/dist/sources-rules.d.ts +44 -0
  162. package/dist/sources-rules.d.ts.map +1 -0
  163. package/dist/sources-rules.js +76 -0
  164. package/dist/sources-rules.js.map +1 -0
  165. package/dist/tex-paper.d.ts +10 -15
  166. package/dist/tex-paper.d.ts.map +1 -1
  167. package/dist/tex-paper.js +24 -40
  168. package/dist/tex-paper.js.map +1 -1
  169. package/dist/tex-requirements.js +1 -1
  170. package/dist/tex-venue-rules.d.ts +3 -3
  171. package/dist/tex-venue-rules.d.ts.map +1 -1
  172. package/dist/tex-venue-rules.js.map +1 -1
  173. package/dist/toolchain.js +1 -1
  174. package/docs/configuration.md +12 -4
  175. package/docs/design/paper-sources.md +197 -0
  176. package/docs/e2e.md +1 -1
  177. package/docs/incidents.md +5 -5
  178. package/docs/package-shape-options.md +1 -1
  179. package/docs/references.md +34 -8
  180. package/docs/rules/bib/reachable-entry.md +64 -0
  181. package/docs/rules/paper/sources-fresh.md +81 -0
  182. package/docs/rules.md +62 -57
  183. package/eslint-rules/CLAUDE.md +44 -0
  184. package/eslint-rules/CLAUDE.md.spec.ts +43 -0
  185. package/eslint-rules/install-path-literals.harness.mjs +1 -1
  186. package/eslint-rules/install-path-literals.ts +4 -4
  187. package/eslint-rules/paper-context.ts +4 -3
  188. package/eslint-rules/paper-typography.test.mjs +12 -72
  189. package/eslint-rules/paper-typography.ts +13 -6
  190. package/eslint-rules/pdf-last-page-balance.ts +1 -1
  191. package/eslint.config.mjs +28 -3
  192. package/fixtures/build-e2e/aidc/paper.tex +1 -1
  193. package/fixtures/build-e2e/anon-ack/paper.tex +1 -1
  194. package/fixtures/build-e2e/anon-author/paper.tex +1 -1
  195. package/fixtures/build-e2e/anon-bib/paper.tex +1 -1
  196. package/fixtures/build-e2e/anon-href/paper.tex +1 -1
  197. package/fixtures/build-e2e/anon-macro/paper.tex +1 -1
  198. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +1 -1
  199. package/fixtures/paper-sources/p1/paper.tex +30 -0
  200. package/fixtures/paper-sources/p1/refs.bib +6 -0
  201. package/fixtures/paper-sources/p1/sections/dead.tex +6 -0
  202. package/fixtures/paper-sources/p1/sections/intro.tex +7 -0
  203. package/fixtures/paper-sources/p1/sections/parked.tex +6 -0
  204. package/fixtures/paper-sources/p1/tex-truth.json +37 -0
  205. package/fixtures/paper-sources/p1/versions/2026-01-01-review.tex +4 -0
  206. package/fixtures/paper-sources/v1-stale/paper.tex +14 -0
  207. package/fixtures/paper-sources/v1-stale/refs.bib +6 -0
  208. package/fixtures/paper-sources/v1-stale/tex-truth.json +32 -0
  209. package/fixtures/paper-sources/v10-ifthenelse/anon.bib +1 -0
  210. package/fixtures/paper-sources/v10-ifthenelse/paper.tex +9 -0
  211. package/fixtures/paper-sources/v10-ifthenelse/real.bib +1 -0
  212. package/fixtures/paper-sources/v10-ifthenelse/tex-truth.json +28 -0
  213. package/fixtures/paper-sources/v11-two-blocks/paper.tex +12 -0
  214. package/fixtures/paper-sources/v11-two-blocks/tex-truth.json +30 -0
  215. package/fixtures/paper-sources/v13-percent-text/paper.tex +6 -0
  216. package/fixtures/paper-sources/v13-percent-text/refs.bib +3 -0
  217. package/fixtures/paper-sources/v13-percent-text/tex-truth.json +30 -0
  218. package/fixtures/paper-sources/v15-after-end/paper.tex +6 -0
  219. package/fixtures/paper-sources/v15-after-end/refs.bib +1 -0
  220. package/fixtures/paper-sources/v15-after-end/tex-truth.json +17 -0
  221. package/fixtures/paper-sources/v16-included-block/bibblock.tex +3 -0
  222. package/fixtures/paper-sources/v16-included-block/paper.tex +7 -0
  223. package/fixtures/paper-sources/v16-included-block/refs.bib +1 -0
  224. package/fixtures/paper-sources/v16-included-block/tex-truth.json +35 -0
  225. package/fixtures/paper-sources/v17-include-order/bibsetup.tex +1 -0
  226. package/fixtures/paper-sources/v17-include-order/first.bib +1 -0
  227. package/fixtures/paper-sources/v17-include-order/later.bib +1 -0
  228. package/fixtures/paper-sources/v17-include-order/paper.tex +7 -0
  229. package/fixtures/paper-sources/v17-include-order/tex-truth.json +40 -0
  230. package/fixtures/paper-sources/v2-overwrite/paper.tex +14 -0
  231. package/fixtures/paper-sources/v2-overwrite/refs.bib +6 -0
  232. package/fixtures/paper-sources/v2-overwrite/tex-truth.json +30 -0
  233. package/fixtures/paper-sources/v22-parked-thebibliography/paper.tex +7 -0
  234. package/fixtures/paper-sources/v22-parked-thebibliography/tex-truth.json +17 -0
  235. package/fixtures/paper-sources/v23-parked-include/old.bib +1 -0
  236. package/fixtures/paper-sources/v23-parked-include/paper.tex +7 -0
  237. package/fixtures/paper-sources/v23-parked-include/parked.tex +1 -0
  238. package/fixtures/paper-sources/v23-parked-include/refs.bib +1 -0
  239. package/fixtures/paper-sources/v23-parked-include/tex-truth.json +28 -0
  240. package/fixtures/paper-sources/v24-quoted-fields/paper.tex +6 -0
  241. package/fixtures/paper-sources/v24-quoted-fields/refs.bib +5 -0
  242. package/fixtures/paper-sources/v24-quoted-fields/tex-truth.json +32 -0
  243. package/fixtures/paper-sources/v25-string-differs/paper.tex +10 -0
  244. package/fixtures/paper-sources/v25-string-differs/refs.bib +2 -0
  245. package/fixtures/paper-sources/v25-string-differs/tex-truth.json +32 -0
  246. package/fixtures/paper-sources/v26-comment-in-names/one.bib +1 -0
  247. package/fixtures/paper-sources/v26-comment-in-names/paper.tex +7 -0
  248. package/fixtures/paper-sources/v26-comment-in-names/tex-truth.json +32 -0
  249. package/fixtures/paper-sources/v26-comment-in-names/two.bib +1 -0
  250. package/fixtures/paper-sources/v27-shared-string/abbreviations.bib +2 -0
  251. package/fixtures/paper-sources/v27-shared-string/paper.tex +6 -0
  252. package/fixtures/paper-sources/v27-shared-string/references.bib +6 -0
  253. package/fixtures/paper-sources/v27-shared-string/tex-truth.json +31 -0
  254. package/fixtures/paper-sources/v28-shared-string-block/abbreviations.bib +2 -0
  255. package/fixtures/paper-sources/v28-shared-string-block/paper.tex +14 -0
  256. package/fixtures/paper-sources/v28-shared-string-block/tex-truth.json +33 -0
  257. package/fixtures/paper-sources/v29-generated-input/paper.tex +9 -0
  258. package/fixtures/paper-sources/v29-generated-input/tex-truth.json +24 -0
  259. package/fixtures/paper-sources/v3-declared/paper.bib +6 -0
  260. package/fixtures/paper-sources/v3-declared/paper.tex +6 -0
  261. package/fixtures/paper-sources/v3-declared/tex-truth.json +28 -0
  262. package/fixtures/paper-sources/v30-name-next-line/paper.tex +14 -0
  263. package/fixtures/paper-sources/v30-name-next-line/tex-truth.json +30 -0
  264. package/fixtures/paper-sources/v4-commented/paper.tex +14 -0
  265. package/fixtures/paper-sources/v4-commented/refs.bib +6 -0
  266. package/fixtures/paper-sources/v4-commented/tex-truth.json +28 -0
  267. package/fixtures/paper-sources/v5-percent-entry/paper.tex +20 -0
  268. package/fixtures/paper-sources/v5-percent-entry/tex-truth.json +37 -0
  269. package/fixtures/paper-sources/v6-unclosed/paper.tex +6 -0
  270. package/fixtures/paper-sources/v6-unclosed/refs.bib +5 -0
  271. package/fixtures/paper-sources/v6-unclosed/tex-truth.json +37 -0
  272. package/fixtures/paper-sources/v8-jobname/paper.tex +9 -0
  273. package/fixtures/paper-sources/v8-jobname/tex-truth.json +30 -0
  274. package/fixtures/paper-sources/v9-two-bibliographies/one.bib +1 -0
  275. package/fixtures/paper-sources/v9-two-bibliographies/paper.tex +7 -0
  276. package/fixtures/paper-sources/v9-two-bibliographies/tex-truth.json +35 -0
  277. package/fixtures/paper-sources/v9-two-bibliographies/two.bib +1 -0
  278. package/fixtures/paper-typography/clean-paper/paper.tex +1 -1
  279. package/fixtures/paper-typography/messy-paper/paper.tex +1 -1
  280. package/hooks/CLAUDE.md +34 -0
  281. package/hooks/CLAUDE.md.spec.ts +30 -0
  282. package/lib/agent-cli-version.ts +1 -1
  283. package/package.json +3 -11
  284. package/plugin/CLAUDE.md +22 -0
  285. package/plugin/CLAUDE.md.spec.ts +22 -0
  286. package/scripts/check.test.ts +23 -1
  287. package/scripts/check.ts +7 -3
  288. package/scripts/coverage-config.test.ts +21 -11
  289. package/scripts/layer-legacy-frozen.test.ts +41 -2
  290. package/scripts/layer-legacy-frozen.ts +31 -2
  291. package/skills/CLAUDE.md +19 -0
  292. package/skills/CLAUDE.md.spec.ts +18 -0
  293. package/skills/grade-paper-writing/SKILL.md +4 -3
  294. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -2
  295. package/skills/grade-paper-writing/prose-lint.mjs +11 -2
  296. package/skills/grade-paper-writing/prose-lint.test.ts +52 -4
  297. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +83 -33
  298. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +67 -115
  299. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +128 -121
  300. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +21 -102
  301. package/skills/verify-citations/SKILL.md +7 -4
  302. package/skills/verify-citations/SKILL.md.spec.ts +6 -3
  303. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +17 -19
  304. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +129 -46
  305. package/skills/verify-citations/scripts/bib-authors.d.mts +22 -2
  306. package/skills/verify-citations/scripts/bib-authors.mjs +57 -111
  307. package/skills/verify-citations/scripts/verify-cites.cli.test.ts +16 -0
  308. package/skills/verify-citations/scripts/verify-cites.d.mts +19 -3
  309. package/skills/verify-citations/scripts/verify-cites.mjs +42 -108
  310. package/skills/verify-citations/scripts/verify-cites.test.mjs +23 -36
  311. package/src/CLAUDE.md +23 -1
  312. package/src/CLAUDE.md.spec.ts +72 -0
  313. package/src/adapters/bibtex/index.test.ts +163 -0
  314. package/src/adapters/bibtex/index.ts +155 -0
  315. package/src/adapters/latex/filecontents.ts +114 -0
  316. package/src/adapters/latex/includes.ts +18 -10
  317. package/src/adapters/latex/index.test.ts +73 -0
  318. package/src/adapters/latex/index.ts +2 -0
  319. package/src/adapters/latex/nodes.ts +64 -0
  320. package/src/adapters/latex/parse.test.ts +38 -0
  321. package/src/adapters/latex/parse.ts +67 -6
  322. package/src/adapters/memory/index.ts +17 -0
  323. package/src/adapters/references/index.test.ts +75 -21
  324. package/src/adapters/references/index.ts +37 -32
  325. package/src/adapters/sources-record/index.test.ts +87 -0
  326. package/src/adapters/sources-record/index.ts +82 -0
  327. package/src/adapters/tex-output/blg.ts +27 -0
  328. package/src/adapters/tex-output/fls.ts +24 -0
  329. package/src/adapters/tex-output/index.test.ts +171 -0
  330. package/src/adapters/tex-output/index.ts +12 -0
  331. package/src/bib-rules.test.ts +356 -0
  332. package/src/bib-rules.ts +228 -0
  333. package/src/bibliography-at.test.ts +286 -0
  334. package/src/build.test.ts +210 -0
  335. package/src/build.ts +101 -36
  336. package/src/claim-provenance.test.ts +33 -7
  337. package/src/claim-provenance.ts +2 -2
  338. package/src/cli.harness.mjs +20 -10
  339. package/src/cli.test.ts +14 -10
  340. package/src/cli.ts +37 -28
  341. package/src/domain/paper-source.test.ts +58 -20
  342. package/src/domain/paper-source.ts +38 -42
  343. package/src/domain/paper-sources.ts +94 -0
  344. package/src/domain/sources-record.test.ts +71 -0
  345. package/src/domain/sources-record.ts +136 -0
  346. package/src/domain/tex-run.test.ts +228 -0
  347. package/src/domain/tex-run.ts +139 -0
  348. package/src/heading-case.test.ts +24 -10
  349. package/src/latex-log.ts +20 -0
  350. package/src/latex-loop.harness.ts +1 -0
  351. package/src/latex-loop.ts +2 -0
  352. package/src/linted-set.test.ts +9 -1
  353. package/src/new-paper.harness.mjs +3 -2
  354. package/src/new-paper.test.ts +9 -5
  355. package/src/package-dirs.ts +3 -2
  356. package/src/paper-includes.test.ts +157 -38
  357. package/src/paper-includes.ts +39 -35
  358. package/src/paper-prose.test.ts +34 -5
  359. package/src/paper-prose.ts +2 -2
  360. package/src/paper-record.test.ts +173 -0
  361. package/src/paper-record.ts +66 -0
  362. package/src/paper-settings-commands.test.ts +5 -2
  363. package/src/paper-sources.ts +151 -0
  364. package/src/ports/bib-reader.ts +27 -0
  365. package/src/ports/check-references.ts +7 -3
  366. package/src/ports/latex.ts +19 -0
  367. package/src/ports/sources-codec.ts +11 -0
  368. package/src/ports/tex-output.ts +17 -0
  369. package/src/recorded-bibliography.ts +297 -0
  370. package/src/reference-rules.ts +217 -104
  371. package/src/references.test.ts +520 -177
  372. package/src/references.ts +185 -37
  373. package/src/register-bands.test.ts +5 -1
  374. package/src/register-bands.ts +3 -5
  375. package/src/register.test.ts +6 -1
  376. package/src/register.ts +2 -2
  377. package/src/rule-cases.test.ts +68 -12
  378. package/src/rule-docs.test.ts +0 -1
  379. package/src/sources-record.test.ts +308 -0
  380. package/src/sources-record.ts +231 -0
  381. package/src/sources-rules.test.ts +152 -0
  382. package/src/sources-rules.ts +117 -0
  383. package/src/tex-paper.test.ts +148 -78
  384. package/src/tex-paper.ts +31 -61
  385. package/src/tex-requirements.ts +1 -1
  386. package/src/tex-venue-rules.test.ts +36 -8
  387. package/src/tex-venue-rules.ts +3 -5
  388. package/src/toolchain.ts +1 -1
  389. package/dist/eslint-rules/bib-reachable-entry.d.ts +0 -22
  390. package/dist/eslint-rules/bib-reachable-entry.d.ts.map +0 -1
  391. package/dist/eslint-rules/bib-reachable-entry.js +0 -75
  392. package/dist/eslint-rules/bib-reachable-entry.js.map +0 -1
  393. package/eslint-rules/bib-reachable-entry.ts +0 -76
@@ -60,16 +60,15 @@
60
60
  * registry's response, not by a comparison. If the cache kept parsed entries, those readers would
61
61
  * stay uncovered while looking covered.
62
62
  */
63
- import {
64
- readFileSync,
65
- writeFileSync,
66
- existsSync,
67
- mkdirSync,
68
- statSync,
69
- } from "node:fs";
63
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
70
64
  import { createHash } from "node:crypto";
71
65
  import { join, resolve, dirname, basename } from "node:path";
72
- import { bibRange } from "#eslint-rules/paper-typography";
66
+ import { latexReader } from "#src/adapters/latex/index";
67
+ import { nodeFiles } from "#src/adapters/node/index";
68
+ import { sourcesCodec } from "#src/adapters/sources-record/index";
69
+ import { absolutePath } from "#src/domain/paths";
70
+ import { bibReader } from "#src/adapters/bibtex/index";
71
+ import { bibliographyAt, bibliographyUnreadWhy } from "#src/paper-sources";
73
72
  import { isMain } from "./consumer.mjs";
74
73
 
75
74
  const ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -111,47 +110,15 @@ const joinName = (a) =>
111
110
  .trim();
112
111
 
113
112
  /**
114
- * 🔴 THE .bib PARSER IS AN OPTIONAL DEPENDENCY, AND THE FAILURE MUST BE LOUD AND CARRY THE CURE.
115
- * It weighs 15 MB out of the consumer's 56 MB install (the package itself 9 MB plus the English
116
- * model `wink-eng-lite-web-model` 4 MB and `unicode2latex` 2 MB) — 27% of the weight for a single
117
- * call that only whoever extracts bibliography facts needs. The import here is dynamic anyway, so
118
- * the laziness was already there; the manifest merely stopped lying about it being required.
119
- *
120
- * ⚠️ A silent skip here would be the worst of the options: a missing checker and a passing one look
121
- * the same, and "the bibliography was not checked" reads as "the bibliography is fine". That is why
122
- * the message names the command, not the fact.
113
+ * The facts' entries of one database, as the bibtex reader read them (src/ports/bib-reader.ts): each
114
+ * with its line in the file that holds it, so a finding points where the author edits (the block's
115
+ * lines in paper.tex, or the `.bib`'s).
123
116
  */
124
- export async function parseBib(
125
- text,
126
- { load = () => import("@retorquere/bibtex-parser") } = {},
127
- ) {
128
- let parse;
129
- try {
130
- ({ parse } = await load());
131
- } catch (e) {
132
- if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e;
133
- throw new Error(
134
- "parsing .bib requires @retorquere/bibtex-parser — it is declared OPTIONAL because it " +
135
- "weighs 15 MB and is needed only for bibliography facts.\n" +
136
- " Install: npm i -D @retorquere/bibtex-parser\n" +
137
- " Why not our own regex: measured 26.08 — the regex gave 0 entries on both real " +
138
- "files, four libraries gave the correct 27 and 51.",
139
- );
140
- }
141
- // `sentenceCase: false` — the title is needed as written. Our normalisation folds the case
142
- // anyway, but a fact has to be a fact: a paraphrased title cannot be shown to a human.
143
- const res = parse(text, { sentenceCase: false, verbatimFields: [] });
144
- // The entry's line in the file — found by its key. The parser gives no positions, and a finding
145
- // needs an address; a key in `.bib` is unique by definition of the format, so the search is
146
- // unambiguous.
147
- const lines = text.split("\n");
148
- const lineOfKey = (key) => {
149
- const i = lines.findIndex((l) => l.includes(`{${key},`));
150
- return i === -1 ? 0 : i + 1;
151
- };
152
- return res.entries.map((e, i) => {
153
- const f = e.fields ?? {};
154
- const names = Array.isArray(f.author) ? f.author : [];
117
+ export function factEntries(bib) {
118
+ const lineOf = (at) => bib.text.slice(0, at).split("\n").length;
119
+ return bib.entries.map((e, i) => {
120
+ const f = e.fields;
121
+ const names = e.names.author ?? [];
155
122
  // `and others` is BibTeX's `et al.`. The parser returns it as an author with no first name.
156
123
  const isOthers = (a) => !a.firstName && /^others$/i.test(a.lastName ?? "");
157
124
  const truncated = names.some(isOthers);
@@ -159,29 +126,20 @@ export async function parseBib(
159
126
  .filter((a) => !isOthers(a))
160
127
  .map(joinName)
161
128
  .filter(Boolean);
162
- // A field is a string in today's parser output (list fields such as `keywords` are arrays,
163
- // and none of those is read); an array or a number from another parser version is joined or
164
- // printed rather than dropped.
165
- const str = (v) =>
166
- Array.isArray(v)
167
- ? v.join(" ")
168
- : typeof v === "string"
169
- ? v
170
- : v == null
171
- ? ""
172
- : String(v);
129
+ // Every field the reader gives is text; a field the entry lacks is "".
130
+ const str = (v) => v ?? "";
173
131
  const venueText = [f.booktitle, f.journal, f.note, f.howpublished]
174
132
  .map(str)
175
133
  .filter(Boolean)
176
134
  .join(" ");
177
135
  return {
178
136
  n: i + 1,
179
- line: lineOfKey(e.key),
137
+ line: lineOf(e.span.start),
180
138
  key: e.key,
181
139
  // `raw` is where identifiers are looked for: they hide in `note`, in `journal`
182
140
  // (`arXiv preprint arXiv:2107.03374`), and in a separate `doi` field.
183
141
  raw: [
184
- str(f.author && authors.join(" and ")),
142
+ authors.join(" and "),
185
143
  str(f.title),
186
144
  venueText,
187
145
  str(f.doi),
@@ -398,40 +356,39 @@ export function recordFrom(key, cached) {
398
356
  // ── assembling the facts ─────────────────────────────────────────────────────
399
357
 
400
358
  /**
401
- * `paper.tex` when it embeds its `.bib` in `filecontents` → `refs.bib` → `build/custom.bib`.
402
- * `refs.bib` ADDED 26.08 (defect #1). The embedded one comes first, as in `paperlint build`
403
- * (`bibliographyOf`, src/references.ts): the author edits it there, and the `refs.bib` LaTeX
404
- * writes out of it is missing before a build and stale after an edit.
359
+ * WHICH BIBLIOGRAPHY: the one the last build's bibtex opened, as `paperlint build` recorded it
360
+ * (`_build/sources.json`, docs/design/paper-sources.md §1) and `bibliographyAt` reads it
361
+ * (src/paper-sources.ts) — never an order of file names of our own, and never read out of TeX source.
362
+ *
363
+ * a directory the databases bibtex opened for its paper.tex
364
+ * a .tex the same, for that file's folder
365
+ * a .bib that file, as named
366
+ *
367
+ * A paper with no current record is refused: run `npx paperlint build` first. A database TeX wrote from
368
+ * a `filecontents` block is read from the block, where the author edits it.
405
369
  */
406
- export const SOURCE_ORDER = ["paper.tex", "refs.bib", "build/custom.bib"];
407
-
408
- /** Whether `p` is a source: any `.bib`, and a `.tex` only when it carries a `.bib`. */
409
- const isSource = (p) =>
410
- !p.endsWith(".tex") || bibRange(readFileSync(p, "utf8")) !== null;
411
-
412
- export function resolveSource(target) {
413
- const t = resolve(target);
414
- if (existsSync(t) && statSync(t).isFile()) return t;
415
- for (const c of SOURCE_ORDER) {
416
- const p = join(t, c);
417
- if (existsSync(p) && isSource(p)) return p;
418
- }
419
- return null;
420
- }
370
+ const DEPS = {
371
+ files: nodeFiles,
372
+ codec: sourcesCodec,
373
+ latex: latexReader,
374
+ bib: bibReader,
375
+ };
421
376
 
422
377
  /**
423
- * The entries of a `.bib`, or of the `.bib` a `.tex` embeds (none when it embeds none) — each
424
- * entry's `line` its line in the file read, so a finding points where the author edits.
378
+ * The bibliography of `target`: the texts to read, or why there are none — `bibliographyAt`
379
+ * (src/paper-sources.ts) answers, in its words. `paperDir` is where the cache and the facts go.
425
380
  */
426
- export async function loadEntries(path) {
427
- const text = readFileSync(path, "utf8");
428
- if (!path.endsWith(".tex")) return parseBib(text);
429
- const bib = bibRange(text);
430
- if (bib === null) return [];
431
- const above = text.slice(0, bib.bodyStart).split("\n").length - 1;
432
- return (await parseBib(bib.body)).map((e) =>
433
- e.line === 0 ? e : { ...e, line: e.line + above },
434
- );
381
+ export function bibliographyFrom(target) {
382
+ const r = bibliographyAt(absolutePath(resolve(target)), DEPS);
383
+ if (!r.ok) return { ok: false, why: bibliographyUnreadWhy(r.error) };
384
+ const { paperDir, texts } = r.value;
385
+ return { ok: true, paperDir, texts };
386
+ }
387
+
388
+ /** The entries of one text TeX reads, each with its file. */
389
+ export function loadEntries(bib) {
390
+ const file = rel(bib.path);
391
+ return factEntries(bib).map((e) => ({ ...e, file }));
435
392
  }
436
393
 
437
394
  /** Every registry key a list of entries needs. Computed once so the requests go in one batch. */
@@ -443,7 +400,11 @@ export function keysFor(entries) {
443
400
 
444
401
  const rel = (p) => (p.startsWith(ROOT) ? p.slice(ROOT.length + 1) : p);
445
402
 
446
- export function buildFacts({ source, text, entries, cache, cachePath }) {
403
+ /**
404
+ * The facts of a run. `source` is the file the first database was read from (the only one, for a
405
+ * paper with one), `sources` every one; `source_sha256` hashes those files' bytes, in order.
406
+ */
407
+ export function buildFacts({ texts, entries, cache, cachePath }) {
447
408
  const withIds = entries.map((e) => {
448
409
  const ids = extractIds(e);
449
410
  return { ...e, ids, primary: primaryOf(ids), keys: allKeys(ids) };
@@ -454,9 +415,12 @@ export function buildFacts({ source, text, entries, cache, cachePath }) {
454
415
  if (!(k in records)) records[k] = recordFrom(k, cache[k]);
455
416
  return {
456
417
  schema: SCHEMA,
457
- source: rel(source),
418
+ source: rel(texts[0].path),
419
+ sources: [...new Set(texts.map((b) => rel(b.path)))],
458
420
  source_kind: "bibtex",
459
- source_sha256: createHash("sha256").update(text).digest("hex"),
421
+ source_sha256: [...new Set(texts.map((b) => b.text))]
422
+ .reduce((h, t) => h.update(t), createHash("sha256"))
423
+ .digest("hex"),
460
424
  cache: cachePath ? rel(cachePath) : null,
461
425
  generated: new Date().toISOString().slice(0, 10),
462
426
  entries: withIds,
@@ -478,24 +442,12 @@ async function main(argv) {
478
442
  .slice(1)
479
443
  .join("=");
480
444
 
481
- const src = resolveSource(target);
482
- if (!src) {
483
- console.error(
484
- `🛑 no ${SOURCE_ORDER.join(", ")} under ${resolve(target)} — nowhere to take a bibliography from.`,
485
- );
486
- return 1;
487
- }
488
- // A paper's references are read from a `.bib`, or the one a `.tex` embeds: any other file
489
- // parsed as BibTeX would report zero entries for the wrong reason.
490
- if (!src.endsWith(".bib") && !src.endsWith(".tex")) {
491
- console.error(
492
- `🛑 ${rel(src)} is neither a .bib nor a .tex — the bibliography is read from ${SOURCE_ORDER.join(", ")}.`,
493
- );
445
+ const found = bibliographyFrom(target);
446
+ if (!found.ok) {
447
+ console.error(`🛑 ${found.why}.`);
494
448
  return 1;
495
449
  }
496
- const paperDir = statSync(resolve(target)).isFile()
497
- ? dirname(src)
498
- : resolve(target);
450
+ const { paperDir, texts } = found;
499
451
  const cachePath = opt("cache")
500
452
  ? resolve(opt("cache"))
501
453
  : join(paperDir, "repro", "refs-cache.json");
@@ -503,13 +455,13 @@ async function main(argv) {
503
455
  ? resolve(opt("out"))
504
456
  : join(paperDir, "_build", "refs.facts.json");
505
457
 
506
- const text = readFileSync(src, "utf8");
507
- const entries = await loadEntries(src);
458
+ const entries = texts.flatMap(loadEntries);
459
+ const names = [...new Set(texts.map((b) => basename(b.path)))].join(", ");
508
460
  if (!entries.length) {
509
461
  // 🔴 Zero entries is a suspect, not a success. Facts with an empty list would look like a
510
462
  // clean bibliography, so we do not write them at all and exit with a non-zero code.
511
463
  console.error(
512
- `🛑 parsed 0 entries out of ${rel(src)}. Silence here would look like a clean bibliography.`,
464
+ `🛑 parsed 0 entries out of ${[...new Set(texts.map((b) => rel(b.path)))].join(", ")}. Silence here would look like a clean bibliography.`,
513
465
  );
514
466
  return 1;
515
467
  }
@@ -536,12 +488,12 @@ async function main(argv) {
536
488
  if (fetched) saveCache(cachePath, cache);
537
489
  }
538
490
 
539
- const facts = buildFacts({ source: src, text, entries, cache, cachePath });
491
+ const facts = buildFacts({ texts, entries, cache, cachePath });
540
492
  mkdirSync(dirname(out), { recursive: true });
541
493
  writeFileSync(out, `${JSON.stringify(facts, null, 2)}\n`);
542
494
  const resolved = Object.values(facts.records).filter((r) => r.found).length;
543
495
  console.log(
544
- `📚 ${basename(src)} → ${rel(out)} (${entries.length} entries, ${Object.keys(facts.records).length} identifiers, ` +
496
+ `📚 ${names} → ${rel(out)} (${entries.length} entries, ${Object.keys(facts.records).length} identifiers, ` +
545
497
  `${resolved} resolved${offline ? ", offline" : fetched ? `, +${fetched} fetched` : ", all from cache"})`,
546
498
  );
547
499
  return 0;
@@ -9,17 +9,18 @@ import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { test } from "vitest";
11
11
  import { runNode, useTempDir, writeTree } from "../../../test/support.ts";
12
+ import { plantedOnDisk, recordOnDisk } from "../../../test/recorded-fixture.ts";
13
+ import { bibliographyUnreadWhy } from "#src/paper-sources";
12
14
  import {
15
+ bibliographyFrom,
13
16
  buildFacts,
14
17
  extractIds,
15
18
  loadCache,
16
19
  loadEntries,
17
- parseBib,
18
20
  primaryOf,
19
21
  readArxiv,
20
22
  readCrossref,
21
23
  recordFrom,
22
- resolveSource,
23
24
  } from "./extract-ref-facts.mjs";
24
25
 
25
26
  const SCRIPT = join(
@@ -28,10 +29,13 @@ const SCRIPT = join(
28
29
  );
29
30
  const root = useTempDir("extract-ref-facts-");
30
31
 
31
- test(".bib: a key the text spells with spaces has no line; a missing title or year is null, not empty", async () => {
32
- const [entry] = await parseBib(
33
- "@misc{ spaced ,\n note={see arXiv:2101.00001}\n}\n",
32
+ test(".bib: an entry's line is where its `@` stands; a missing title or year is null, not empty", () => {
33
+ const bib = join(root, "lines.bib");
34
+ writeFileSync(
35
+ bib,
36
+ "% a comment\n\n@misc{ spaced ,\n note={see arXiv:2101.00001}\n}\n",
34
37
  );
38
+ const [entry] = loadEntries(bibliographyFrom(bib).texts[0]);
35
39
  assert.deepEqual(
36
40
  {
37
41
  line: entry.line,
@@ -41,7 +45,7 @@ test(".bib: a key the text spells with spaces has no line; a missing title or ye
41
45
  raw: entry.raw,
42
46
  },
43
47
  {
44
- line: 0,
48
+ line: 3,
45
49
  title: null,
46
50
  year: null,
47
51
  authors: [],
@@ -50,52 +54,6 @@ test(".bib: a key the text spells with spaces has no line; a missing title or ye
50
54
  );
51
55
  });
52
56
 
53
- test(".bib from a parser that returns no fields, or list and number fields: joined and printed, not dropped", async () => {
54
- const load = async () => ({
55
- parse: () => ({
56
- entries: [
57
- { key: "bare" },
58
- {
59
- key: "odd",
60
- fields: {
61
- title: ["Split", "Title"],
62
- year: 2021,
63
- note: null,
64
- author: [{ firstName: "Ada", lastName: "Lovelace" }],
65
- },
66
- },
67
- ],
68
- }),
69
- });
70
- const [bare, odd] = await parseBib("@misc{bare,}\n@misc{odd,}\n", { load });
71
- assert.deepEqual(
72
- [
73
- bare,
74
- { title: odd.title, year: odd.year, authors: odd.authors, raw: odd.raw },
75
- ],
76
- [
77
- {
78
- n: 1,
79
- line: 1,
80
- key: "bare",
81
- raw: "",
82
- authors: [],
83
- truncated: false,
84
- title: null,
85
- year: null,
86
- venue_text: "",
87
- doi_field: null,
88
- },
89
- {
90
- title: "Split Title",
91
- year: "2021",
92
- authors: ["Ada Lovelace"],
93
- raw: "Ada Lovelace Split Title 2021",
94
- },
95
- ],
96
- );
97
- });
98
-
99
57
  test("identifiers: a declared DOI field comes first; an entry without text fields has none", () => {
100
58
  const ids = extractIds({
101
59
  doi_field: "10.1111/field",
@@ -181,19 +139,22 @@ test("cache: no path and a corrupt file both read as empty", () => {
181
139
  assert.deepEqual([loadCache(undefined), loadCache(corrupt)], [{}, {}]);
182
140
  });
183
141
 
184
- test("sources: a file names itself; a .bib is read as BibTeX; facts without a cache say so", async () => {
142
+ test("sources: a .bib named is read as itself, as BibTeX; facts without a cache say so", async () => {
185
143
  const bib = join(root, "one.bib");
186
144
  writeFileSync(bib, "@misc{k, title={T}, year={2020}}\n");
187
- assert.equal(resolveSource(bib), bib);
188
- const entries = await loadEntries(bib);
145
+ const found = bibliographyFrom(bib);
146
+ assert.deepEqual([found.ok, found.texts.map((t) => t.path)], [true, [bib]]);
147
+ const entries = loadEntries(found.texts[0]);
189
148
  const facts = buildFacts({
190
- source: bib,
191
- text: "",
149
+ texts: found.texts,
192
150
  entries,
193
151
  cache: {},
194
152
  cachePath: null,
195
153
  });
196
- assert.deepEqual([facts.source_kind, facts.cache], ["bibtex", null]);
154
+ assert.deepEqual(
155
+ [facts.source_kind, facts.cache, facts.sources],
156
+ ["bibtex", null, [bib]],
157
+ );
197
158
  });
198
159
 
199
160
  // ── the command ─────────────────────────────────────────────────────────────
@@ -205,10 +166,15 @@ const CROSSREF_OK = JSON.stringify({
205
166
  issued: { "date-parts": [[2020]] },
206
167
  },
207
168
  });
169
+ /** A paper.tex declaring `\bibliography{refs}`, with `preamble` before its document. */
170
+ const declaring = (preamble = "") =>
171
+ `\\documentclass{article}\n${preamble}\\begin{document}x\\bibliography{refs}\\end{document}\n`;
208
172
  writeTree(root, {
173
+ "paper/paper.tex": declaring(),
209
174
  "paper/refs.bib":
210
175
  "@misc{a, author={A. A}, title={Found}, note={doi:10.1234/ok}}\n" +
211
176
  "@misc{b, author={B. B}, title={Lost}, note={doi:10.1234/down}}\n",
177
+ "empty/paper.tex": declaring(),
212
178
  "empty/refs.bib": "% nothing cited\n",
213
179
  "notes/notes.txt": "A. A. Found. doi:10.1234/ok\n",
214
180
  // `fetch` in the child: 10.1234/down fails at the transport, anything else is a CrossRef hit.
@@ -217,26 +183,42 @@ writeTree(root, {
217
183
  ` if (url.includes("down")) throw new Error("network down");\n` +
218
184
  ` return { status: 200, text: async () => ${JSON.stringify(CROSSREF_OK)} };\n` +
219
185
  `};\n`,
220
- // The optional .bib parser, uninstalled — and broken in some other way.
221
- "no-parser.mjs":
222
- "import { register } from 'node:module';\n" +
223
- "register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '@retorquere/bibtex-parser') throw Object.assign(new Error('gone'), { code: 'ERR_MODULE_NOT_FOUND' }); return next(s, c); }\"));\n",
224
- "bad-parser.mjs":
225
- "import { register } from 'node:module';\n" +
226
- "register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '@retorquere/bibtex-parser') throw Object.assign(new Error('broken install'), { code: 'EACCES' }); return next(s, c); }\"));\n",
186
+ "bib/paper.tex": declaring(),
187
+ "switched/paper.tex":
188
+ "\\documentclass{article}\\newif\\ifanon\\begin{document}x\\ifanon\\bibliography{anon}\\else\\bibliography{refs}\\fi\\end{document}\n",
189
+ "switched/anon.bib": "@misc{anon1, title={A}, note={doi:10.1234/a}}\n",
190
+ "switched/refs.bib": "@misc{refs1, title={R}, note={doi:10.1234/r}}\n",
227
191
  "bib/refs.bib": "@misc{k, title={T}, note={doi:10.1234/ok}}\n",
228
- // The bibliography is embedded in paper.tex; the refs.bib beside it is a stale build output.
229
- "inline/paper.tex":
230
- "\\documentclass{article}\n" +
192
+ // The bibliography is embedded in paper.tex; the refs.bib beside it is what TeX wrote from the block.
193
+ "inline/paper.tex": declaring(
231
194
  "\\begin{filecontents*}[overwrite]{refs.bib}\n" +
195
+ "@misc{a, author={A. A}, title={Found}, note={doi:10.1234/ok}}\n" +
196
+ "@misc{b, author={B. B}, title={Lost}, note={doi:10.1234/down}}\n" +
197
+ "\\end{filecontents*}\n",
198
+ ),
199
+ "inline/refs.bib":
232
200
  "@misc{a, author={A. A}, title={Found}, note={doi:10.1234/ok}}\n" +
233
- "@misc{b, author={B. B}, title={Lost}, note={doi:10.1234/down}}\n" +
234
- "\\end{filecontents*}\n\\begin{document}x\\end{document}\n",
235
- "inline/refs.bib": "@misc{stale, title={Stale}, note={doi:10.1234/old}}\n",
236
- // A paper.tex with no embedded .bib is not a source: the refs.bib beside it is.
237
- "plain/paper.tex":
238
- "\\documentclass{article}\\begin{document}x\\end{document}\n",
239
- "plain/refs.bib": "@misc{k, title={T}, note={doi:10.1234/ok}}\n",
201
+ "@misc{b, author={B. B}, title={Lost}, note={doi:10.1234/down}}\n",
202
+ // A paper that was never built has no record: nothing says which bibliography TeX reads.
203
+ "unbuilt/paper.tex": declaring(),
204
+ "unbuilt/refs.bib": "@misc{k, title={T}, note={doi:10.1234/ok}}\n",
205
+ });
206
+ /** What a build records for a paper whose bibliography is the file `db` the author keeps. */
207
+ const keeps = (db = "refs.bib") => ({
208
+ inputs: [
209
+ ["paper.tex", "body"],
210
+ [db, "preamble"],
211
+ ],
212
+ databases: [db],
213
+ });
214
+ for (const dir of ["paper", "empty", "bib"])
215
+ recordOnDisk(join(root, dir), keeps());
216
+ // bibtex opened refs.bib only: the switch took its `\else`.
217
+ recordOnDisk(join(root, "switched"), keeps());
218
+ recordOnDisk(join(root, "inline"), {
219
+ inputs: [["paper.tex", "body"]],
220
+ written: ["refs.bib"],
221
+ databases: ["refs.bib"],
240
222
  });
241
223
  const cli = (args, { cwd = root, nodeArgs = [] } = {}) =>
242
224
  runNode(SCRIPT, args, {
@@ -299,11 +281,14 @@ test("no target: the current directory", () => {
299
281
  assert.equal(r.status, 0, r.stderr);
300
282
  });
301
283
 
302
- test("refusals: nowhere to read from, a file that is not a .bib, and zero entries (not written as a clean bibliography)", () => {
303
- assert.deepEqual(cli([join(root, "nope"), "--offline"]), {
284
+ test("refusals: no bibliography to read (in bibliographyAt's words), and zero entries (not written as a clean bibliography)", () => {
285
+ // The path → bibliography vocabulary is tested once, beside bibliographyAt (src/paper-sources.ts).
286
+ // Here: that the command speaks it, and stops.
287
+ const notes = join(root, "notes/notes.txt");
288
+ assert.deepEqual(cli([notes, "--offline"]), {
304
289
  status: 1,
305
290
  stdout: "",
306
- stderr: `🛑 no paper.tex, refs.bib, build/custom.bib under ${join(root, "nope")} — nowhere to take a bibliography from.\n`,
291
+ stderr: `🛑 ${bibliographyUnreadWhy({ kind: "not-bib-or-tex", path: notes })}.\n`,
307
292
  });
308
293
  assert.deepEqual(cli(["empty", "--offline"]), {
309
294
  status: 1,
@@ -311,33 +296,9 @@ test("refusals: nowhere to read from, a file that is not a .bib, and zero entrie
311
296
  stderr:
312
297
  "🛑 parsed 0 entries out of empty/refs.bib. Silence here would look like a clean bibliography.\n",
313
298
  });
314
- // Guards: a file that is neither a .bib nor a .tex is named, not parsed as BibTeX, and nothing is written.
315
- assert.deepEqual(cli([join(root, "notes/notes.txt"), "--offline"]), {
316
- status: 1,
317
- stdout: "",
318
- stderr:
319
- "🛑 notes/notes.txt is neither a .bib nor a .tex — the bibliography is read from paper.tex, refs.bib, build/custom.bib.\n",
320
- });
321
- });
322
-
323
- test(".bib without its optional parser: the error names the install; any other failure is not masked", () => {
324
- const missing = cli(["bib", "--offline"], {
325
- nodeArgs: ["--import", join(root, "no-parser.mjs")],
326
- });
327
- assert.equal(missing.status, 1);
328
- assert.match(
329
- missing.stderr,
330
- /parsing \.bib requires @retorquere\/bibtex-parser[\s\S]*Install: {2}npm i -D @retorquere\/bibtex-parser/,
331
- );
332
- const broken = cli(["bib", "--offline"], {
333
- nodeArgs: ["--import", join(root, "bad-parser.mjs")],
334
- });
335
- assert.equal(broken.status, 1);
336
- assert.match(broken.stderr, /broken install/);
337
- assert.doesNotMatch(broken.stderr, /npm i -D/);
338
299
  });
339
300
 
340
- test("🔴 a paper directory whose paper.tex embeds its .bib: the embedded one is read, not a stale refs.bib", () => {
301
+ test("🔴 a paper directory whose paper.tex embeds its .bib: the entries are read from the block that wrote it, where the author edits them", () => {
341
302
  const r = cli(["inline", "--offline"]);
342
303
  // Guards: only materialized .bib files were looked for, so a paper with its bibliography in
343
304
  // `filecontents` was refused before a build and read from a stale refs.bib after an edit.
@@ -352,35 +313,81 @@ test("🔴 a paper directory whose paper.tex embeds its .bib: the embedded one i
352
313
  );
353
314
  // Each entry's line is its line in paper.tex, where the author edits it.
354
315
  assert.deepEqual(
355
- facts.entries.map((e) => [e.key, e.line]),
316
+ facts.entries.map((e) => [e.file, e.key, e.line]),
356
317
  [
357
- ["a", 3],
358
- ["b", 4],
318
+ ["inline/paper.tex", "a", 3],
319
+ ["inline/paper.tex", "b", 4],
359
320
  ],
360
321
  );
361
322
  });
362
323
 
363
- test("a paper.tex that embeds no .bib: in a directory the refs.bib beside it is read; named alone, it has no entries", () => {
364
- assert.deepEqual(
365
- cli(["plain", "--offline"]).stdout,
366
- "📚 refs.bib → plain/_build/refs.facts.json (1 entries, 1 identifiers, 0 resolved, offline)\n",
367
- );
368
- assert.deepEqual(cli([join(root, "plain/paper.tex"), "--offline"]), {
324
+ test("a bibliography behind a switch: the database bibtex opened is read, not every candidate", () => {
325
+ assert.deepEqual(cli(["switched", "--offline"]), {
326
+ status: 0,
327
+ stdout:
328
+ "📚 refs.bib → switched/_build/refs.facts.json (1 entries, 1 identifiers, 0 resolved, offline)\n",
329
+ stderr: "",
330
+ });
331
+ });
332
+
333
+ test("🔴 a paper that was not built is refused — run `npx paperlint build` first — never read from its source", () => {
334
+ assert.deepEqual(cli(["unbuilt", "--offline"]), {
369
335
  status: 1,
370
336
  stdout: "",
371
- stderr:
372
- "🛑 parsed 0 entries out of plain/paper.tex. Silence here would look like a clean bibliography.\n",
337
+ stderr: `🛑 ${join(root, "unbuilt")} has not been built — run \`npx paperlint build\` first, which records the databases bibtex reads.\n`,
373
338
  });
374
339
  });
375
340
 
376
- test("an embedded entry whose key has no line of its own keeps line 0, not a shifted 0", async () => {
377
- const tex = join(root, "spaced.tex");
378
- writeFileSync(
379
- tex,
380
- "x\n\\begin{filecontents*}{refs.bib}\n@misc{ spaced ,\n note={arXiv:2101.00001}\n}\n\\end{filecontents*}\n",
341
+ test("an embedded entry's line is its line in paper.tex", () => {
342
+ const block = "@misc{ spaced ,\n note={arXiv:2101.00001}\n}\n";
343
+ writeTree(root, {
344
+ "spaced/paper.tex": declaring(
345
+ `\\begin{filecontents*}{refs.bib}\n${block}\\end{filecontents*}\n`,
346
+ ),
347
+ "spaced/refs.bib": block,
348
+ });
349
+ recordOnDisk(join(root, "spaced"), {
350
+ inputs: [["paper.tex", "body"]],
351
+ written: ["refs.bib"],
352
+ databases: ["refs.bib"],
353
+ });
354
+ const found = bibliographyFrom(join(root, "spaced", "paper.tex"));
355
+ assert.deepEqual(
356
+ loadEntries(found.texts[0]).map((e) => e.line),
357
+ [3],
381
358
  );
359
+ });
360
+
361
+ // ── the planted papers of fixtures/paper-sources, laid out as a build leaves them ─────
362
+
363
+ test.each([
364
+ // [paper, the file read, the keys read] — tex-truth.json beside each paper is TeX's own answer: the
365
+ // databases bibtex opened. A `.bib` TeX wrote from a block is read from the block (paper.tex).
366
+ ["v1-stale", "refs.bib", ["stale2020"]],
367
+ ["v2-overwrite", "paper.tex", ["inline2024"]],
368
+ ["v3-declared", "paper.bib", ["declared2023"]],
369
+ ["v4-commented", "refs.bib", ["stale2020"]],
370
+ // What bibtex reads beyond these — a malformed entry, or one behind `%` or inside `@comment{…}` —
371
+ // the build's post-build check names (src/references.ts).
372
+ ["v5-percent-entry", "paper.tex", ["inline2024"]],
373
+ ["v6-unclosed", "refs.bib", ["a1", "a3"]],
374
+ ["v13-percent-text", "refs.bib", ["ok1"]],
375
+ ["v8-jobname", "paper.tex", ["jkey"]],
376
+ ])("%s: the bibliography bibtex opened", (paper, file, keys) => {
377
+ const dir = join(root, "planted", paper);
378
+ plantedOnDisk(dir, paper);
379
+ const out = join(root, "planted", `${paper}.json`);
380
+ const r = cli([
381
+ dir,
382
+ "--offline",
383
+ `--out=${out}`,
384
+ `--cache=${join(root, "planted", "cache.json")}`,
385
+ ]);
386
+ assert.deepEqual([r.status, r.stderr], [0, ""]);
387
+ assert.match(r.stdout, new RegExp(`^📚 ${file} → `));
388
+ const facts = JSON.parse(readFileSync(out, "utf8"));
382
389
  assert.deepEqual(
383
- (await loadEntries(tex)).map((e) => e.line),
384
- [0],
390
+ facts.entries.map((e) => e.key),
391
+ keys,
385
392
  );
386
393
  });