paperlint 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/CLAUDE.md +9 -25
  2. package/CONTRIBUTING.md +5 -4
  3. package/README.md +4 -3
  4. package/dist/adapters/bibtex/index.d.ts +3 -0
  5. package/dist/adapters/bibtex/index.d.ts.map +1 -0
  6. package/dist/adapters/bibtex/index.js +103 -0
  7. package/dist/adapters/bibtex/index.js.map +1 -0
  8. package/dist/adapters/latex/filecontents.d.ts +33 -0
  9. package/dist/adapters/latex/filecontents.d.ts.map +1 -0
  10. package/dist/adapters/latex/filecontents.js +65 -0
  11. package/dist/adapters/latex/filecontents.js.map +1 -0
  12. package/dist/adapters/latex/includes.d.ts +3 -3
  13. package/dist/adapters/latex/includes.d.ts.map +1 -1
  14. package/dist/adapters/latex/includes.js +18 -10
  15. package/dist/adapters/latex/includes.js.map +1 -1
  16. package/dist/adapters/latex/index.d.ts.map +1 -1
  17. package/dist/adapters/latex/index.js +2 -0
  18. package/dist/adapters/latex/index.js.map +1 -1
  19. package/dist/adapters/latex/nodes.d.ts +12 -0
  20. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  21. package/dist/adapters/latex/nodes.js +39 -0
  22. package/dist/adapters/latex/nodes.js.map +1 -1
  23. package/dist/adapters/latex/parse.d.ts +7 -1
  24. package/dist/adapters/latex/parse.d.ts.map +1 -1
  25. package/dist/adapters/latex/parse.js +55 -6
  26. package/dist/adapters/latex/parse.js.map +1 -1
  27. package/dist/adapters/memory/index.d.ts +3 -0
  28. package/dist/adapters/memory/index.d.ts.map +1 -1
  29. package/dist/adapters/memory/index.js +9 -0
  30. package/dist/adapters/memory/index.js.map +1 -1
  31. package/dist/adapters/references/index.d.ts.map +1 -1
  32. package/dist/adapters/references/index.js +27 -24
  33. package/dist/adapters/references/index.js.map +1 -1
  34. package/dist/adapters/sources-record/index.d.ts +9 -0
  35. package/dist/adapters/sources-record/index.d.ts.map +1 -0
  36. package/dist/adapters/sources-record/index.js +68 -0
  37. package/dist/adapters/sources-record/index.js.map +1 -0
  38. package/dist/adapters/tex-output/blg.d.ts +9 -0
  39. package/dist/adapters/tex-output/blg.d.ts.map +1 -0
  40. package/dist/adapters/tex-output/blg.js +20 -0
  41. package/dist/adapters/tex-output/blg.js.map +1 -0
  42. package/dist/adapters/tex-output/fls.d.ts +8 -0
  43. package/dist/adapters/tex-output/fls.d.ts.map +1 -0
  44. package/dist/adapters/tex-output/fls.js +16 -0
  45. package/dist/adapters/tex-output/fls.js.map +1 -0
  46. package/dist/adapters/tex-output/index.d.ts +7 -0
  47. package/dist/adapters/tex-output/index.d.ts.map +1 -0
  48. package/dist/adapters/tex-output/index.js +7 -0
  49. package/dist/adapters/tex-output/index.js.map +1 -0
  50. package/dist/bib-rules.d.ts +47 -0
  51. package/dist/bib-rules.d.ts.map +1 -0
  52. package/dist/bib-rules.js +127 -0
  53. package/dist/bib-rules.js.map +1 -0
  54. package/dist/build.d.ts +29 -1
  55. package/dist/build.d.ts.map +1 -1
  56. package/dist/build.js +40 -24
  57. package/dist/build.js.map +1 -1
  58. package/dist/claim-provenance.d.ts +2 -2
  59. package/dist/cli.d.ts +6 -1
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +30 -26
  62. package/dist/cli.js.map +1 -1
  63. package/dist/domain/paper-source.d.ts +1 -10
  64. package/dist/domain/paper-source.d.ts.map +1 -1
  65. package/dist/domain/paper-source.js +34 -24
  66. package/dist/domain/paper-source.js.map +1 -1
  67. package/dist/domain/paper-sources.d.ts +72 -0
  68. package/dist/domain/paper-sources.d.ts.map +1 -0
  69. package/dist/domain/paper-sources.js +18 -0
  70. package/dist/domain/paper-sources.js.map +1 -0
  71. package/dist/domain/sources-record.d.ts +102 -0
  72. package/dist/domain/sources-record.d.ts.map +1 -0
  73. package/dist/domain/sources-record.js +38 -0
  74. package/dist/domain/sources-record.js.map +1 -0
  75. package/dist/domain/tex-run.d.ts +25 -0
  76. package/dist/domain/tex-run.d.ts.map +1 -0
  77. package/dist/domain/tex-run.js +79 -0
  78. package/dist/domain/tex-run.js.map +1 -0
  79. package/dist/eslint-rules/paper-context.d.ts.map +1 -1
  80. package/dist/eslint-rules/paper-context.js +4 -3
  81. package/dist/eslint-rules/paper-context.js.map +1 -1
  82. package/dist/eslint-rules/paper-typography.d.ts +0 -9
  83. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  84. package/dist/eslint-rules/paper-typography.js +12 -5
  85. package/dist/eslint-rules/paper-typography.js.map +1 -1
  86. package/dist/latex-log.d.ts +10 -0
  87. package/dist/latex-log.d.ts.map +1 -1
  88. package/dist/latex-log.js +10 -0
  89. package/dist/latex-log.js.map +1 -1
  90. package/dist/latex-loop.d.ts +2 -0
  91. package/dist/latex-loop.d.ts.map +1 -1
  92. package/dist/latex-loop.js.map +1 -1
  93. package/dist/package-dirs.d.ts.map +1 -1
  94. package/dist/package-dirs.js +3 -2
  95. package/dist/package-dirs.js.map +1 -1
  96. package/dist/paper-includes.d.ts +4 -7
  97. package/dist/paper-includes.d.ts.map +1 -1
  98. package/dist/paper-includes.js +29 -21
  99. package/dist/paper-includes.js.map +1 -1
  100. package/dist/paper-prose.d.ts +2 -2
  101. package/dist/paper-record.d.ts +25 -0
  102. package/dist/paper-record.d.ts.map +1 -0
  103. package/dist/paper-record.js +39 -0
  104. package/dist/paper-record.js.map +1 -0
  105. package/dist/paper-sources.d.ts +66 -0
  106. package/dist/paper-sources.d.ts.map +1 -0
  107. package/dist/paper-sources.js +92 -0
  108. package/dist/paper-sources.js.map +1 -0
  109. package/dist/ports/bib-reader.d.ts +18 -0
  110. package/dist/ports/bib-reader.d.ts.map +1 -0
  111. package/dist/ports/bib-reader.js +2 -0
  112. package/dist/ports/bib-reader.js.map +1 -0
  113. package/dist/ports/check-references.d.ts +9 -4
  114. package/dist/ports/check-references.d.ts.map +1 -1
  115. package/dist/ports/latex.d.ts +15 -0
  116. package/dist/ports/latex.d.ts.map +1 -1
  117. package/dist/ports/sources-codec.d.ts +11 -0
  118. package/dist/ports/sources-codec.d.ts.map +1 -0
  119. package/dist/ports/sources-codec.js +2 -0
  120. package/dist/ports/sources-codec.js.map +1 -0
  121. package/dist/ports/tex-output.d.ts +17 -0
  122. package/dist/ports/tex-output.d.ts.map +1 -0
  123. package/dist/ports/tex-output.js +2 -0
  124. package/dist/ports/tex-output.js.map +1 -0
  125. package/dist/recorded-bibliography.d.ts +89 -0
  126. package/dist/recorded-bibliography.d.ts.map +1 -0
  127. package/dist/recorded-bibliography.js +136 -0
  128. package/dist/recorded-bibliography.js.map +1 -0
  129. package/dist/reference-rules.d.ts +5 -5
  130. package/dist/reference-rules.d.ts.map +1 -1
  131. package/dist/reference-rules.js +150 -77
  132. package/dist/reference-rules.js.map +1 -1
  133. package/dist/references.d.ts +71 -16
  134. package/dist/references.d.ts.map +1 -1
  135. package/dist/references.js +110 -26
  136. package/dist/references.js.map +1 -1
  137. package/dist/register-bands.d.ts +3 -3
  138. package/dist/register-bands.d.ts.map +1 -1
  139. package/dist/register-bands.js.map +1 -1
  140. package/dist/register.d.ts +2 -2
  141. package/dist/sources-record.d.ts +38 -0
  142. package/dist/sources-record.d.ts.map +1 -0
  143. package/dist/sources-record.js +123 -0
  144. package/dist/sources-record.js.map +1 -0
  145. package/dist/sources-rules.d.ts +44 -0
  146. package/dist/sources-rules.d.ts.map +1 -0
  147. package/dist/sources-rules.js +76 -0
  148. package/dist/sources-rules.js.map +1 -0
  149. package/dist/tex-paper.d.ts +10 -15
  150. package/dist/tex-paper.d.ts.map +1 -1
  151. package/dist/tex-paper.js +24 -40
  152. package/dist/tex-paper.js.map +1 -1
  153. package/dist/tex-venue-rules.d.ts +3 -3
  154. package/dist/tex-venue-rules.d.ts.map +1 -1
  155. package/dist/tex-venue-rules.js.map +1 -1
  156. package/docs/configuration.md +12 -4
  157. package/docs/design/paper-sources.md +197 -0
  158. package/docs/package-shape-options.md +1 -1
  159. package/docs/references.md +34 -8
  160. package/docs/rules/bib/reachable-entry.md +64 -0
  161. package/docs/rules/paper/sources-fresh.md +81 -0
  162. package/docs/rules.md +62 -57
  163. package/eslint-rules/paper-context.ts +4 -3
  164. package/eslint-rules/paper-typography.test.mjs +12 -72
  165. package/eslint-rules/paper-typography.ts +13 -6
  166. package/eslint.config.mjs +22 -0
  167. package/fixtures/build-e2e/aidc/paper.tex +1 -1
  168. package/fixtures/build-e2e/anon-ack/paper.tex +1 -1
  169. package/fixtures/build-e2e/anon-author/paper.tex +1 -1
  170. package/fixtures/build-e2e/anon-bib/paper.tex +1 -1
  171. package/fixtures/build-e2e/anon-href/paper.tex +1 -1
  172. package/fixtures/build-e2e/anon-macro/paper.tex +1 -1
  173. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +1 -1
  174. package/fixtures/paper-sources/p1/paper.tex +30 -0
  175. package/fixtures/paper-sources/p1/refs.bib +6 -0
  176. package/fixtures/paper-sources/p1/sections/dead.tex +6 -0
  177. package/fixtures/paper-sources/p1/sections/intro.tex +7 -0
  178. package/fixtures/paper-sources/p1/sections/parked.tex +6 -0
  179. package/fixtures/paper-sources/p1/tex-truth.json +37 -0
  180. package/fixtures/paper-sources/p1/versions/2026-01-01-review.tex +4 -0
  181. package/fixtures/paper-sources/v1-stale/paper.tex +14 -0
  182. package/fixtures/paper-sources/v1-stale/refs.bib +6 -0
  183. package/fixtures/paper-sources/v1-stale/tex-truth.json +32 -0
  184. package/fixtures/paper-sources/v10-ifthenelse/anon.bib +1 -0
  185. package/fixtures/paper-sources/v10-ifthenelse/paper.tex +9 -0
  186. package/fixtures/paper-sources/v10-ifthenelse/real.bib +1 -0
  187. package/fixtures/paper-sources/v10-ifthenelse/tex-truth.json +28 -0
  188. package/fixtures/paper-sources/v11-two-blocks/paper.tex +12 -0
  189. package/fixtures/paper-sources/v11-two-blocks/tex-truth.json +30 -0
  190. package/fixtures/paper-sources/v13-percent-text/paper.tex +6 -0
  191. package/fixtures/paper-sources/v13-percent-text/refs.bib +3 -0
  192. package/fixtures/paper-sources/v13-percent-text/tex-truth.json +30 -0
  193. package/fixtures/paper-sources/v15-after-end/paper.tex +6 -0
  194. package/fixtures/paper-sources/v15-after-end/refs.bib +1 -0
  195. package/fixtures/paper-sources/v15-after-end/tex-truth.json +17 -0
  196. package/fixtures/paper-sources/v16-included-block/bibblock.tex +3 -0
  197. package/fixtures/paper-sources/v16-included-block/paper.tex +7 -0
  198. package/fixtures/paper-sources/v16-included-block/refs.bib +1 -0
  199. package/fixtures/paper-sources/v16-included-block/tex-truth.json +35 -0
  200. package/fixtures/paper-sources/v17-include-order/bibsetup.tex +1 -0
  201. package/fixtures/paper-sources/v17-include-order/first.bib +1 -0
  202. package/fixtures/paper-sources/v17-include-order/later.bib +1 -0
  203. package/fixtures/paper-sources/v17-include-order/paper.tex +7 -0
  204. package/fixtures/paper-sources/v17-include-order/tex-truth.json +40 -0
  205. package/fixtures/paper-sources/v2-overwrite/paper.tex +14 -0
  206. package/fixtures/paper-sources/v2-overwrite/refs.bib +6 -0
  207. package/fixtures/paper-sources/v2-overwrite/tex-truth.json +30 -0
  208. package/fixtures/paper-sources/v22-parked-thebibliography/paper.tex +7 -0
  209. package/fixtures/paper-sources/v22-parked-thebibliography/tex-truth.json +17 -0
  210. package/fixtures/paper-sources/v23-parked-include/old.bib +1 -0
  211. package/fixtures/paper-sources/v23-parked-include/paper.tex +7 -0
  212. package/fixtures/paper-sources/v23-parked-include/parked.tex +1 -0
  213. package/fixtures/paper-sources/v23-parked-include/refs.bib +1 -0
  214. package/fixtures/paper-sources/v23-parked-include/tex-truth.json +28 -0
  215. package/fixtures/paper-sources/v24-quoted-fields/paper.tex +6 -0
  216. package/fixtures/paper-sources/v24-quoted-fields/refs.bib +5 -0
  217. package/fixtures/paper-sources/v24-quoted-fields/tex-truth.json +32 -0
  218. package/fixtures/paper-sources/v25-string-differs/paper.tex +10 -0
  219. package/fixtures/paper-sources/v25-string-differs/refs.bib +2 -0
  220. package/fixtures/paper-sources/v25-string-differs/tex-truth.json +32 -0
  221. package/fixtures/paper-sources/v26-comment-in-names/one.bib +1 -0
  222. package/fixtures/paper-sources/v26-comment-in-names/paper.tex +7 -0
  223. package/fixtures/paper-sources/v26-comment-in-names/tex-truth.json +32 -0
  224. package/fixtures/paper-sources/v26-comment-in-names/two.bib +1 -0
  225. package/fixtures/paper-sources/v27-shared-string/abbreviations.bib +2 -0
  226. package/fixtures/paper-sources/v27-shared-string/paper.tex +6 -0
  227. package/fixtures/paper-sources/v27-shared-string/references.bib +6 -0
  228. package/fixtures/paper-sources/v27-shared-string/tex-truth.json +31 -0
  229. package/fixtures/paper-sources/v28-shared-string-block/abbreviations.bib +2 -0
  230. package/fixtures/paper-sources/v28-shared-string-block/paper.tex +14 -0
  231. package/fixtures/paper-sources/v28-shared-string-block/tex-truth.json +33 -0
  232. package/fixtures/paper-sources/v29-generated-input/paper.tex +9 -0
  233. package/fixtures/paper-sources/v29-generated-input/tex-truth.json +24 -0
  234. package/fixtures/paper-sources/v3-declared/paper.bib +6 -0
  235. package/fixtures/paper-sources/v3-declared/paper.tex +6 -0
  236. package/fixtures/paper-sources/v3-declared/tex-truth.json +28 -0
  237. package/fixtures/paper-sources/v30-name-next-line/paper.tex +14 -0
  238. package/fixtures/paper-sources/v30-name-next-line/tex-truth.json +30 -0
  239. package/fixtures/paper-sources/v4-commented/paper.tex +14 -0
  240. package/fixtures/paper-sources/v4-commented/refs.bib +6 -0
  241. package/fixtures/paper-sources/v4-commented/tex-truth.json +28 -0
  242. package/fixtures/paper-sources/v5-percent-entry/paper.tex +20 -0
  243. package/fixtures/paper-sources/v5-percent-entry/tex-truth.json +37 -0
  244. package/fixtures/paper-sources/v6-unclosed/paper.tex +6 -0
  245. package/fixtures/paper-sources/v6-unclosed/refs.bib +5 -0
  246. package/fixtures/paper-sources/v6-unclosed/tex-truth.json +37 -0
  247. package/fixtures/paper-sources/v8-jobname/paper.tex +9 -0
  248. package/fixtures/paper-sources/v8-jobname/tex-truth.json +30 -0
  249. package/fixtures/paper-sources/v9-two-bibliographies/one.bib +1 -0
  250. package/fixtures/paper-sources/v9-two-bibliographies/paper.tex +7 -0
  251. package/fixtures/paper-sources/v9-two-bibliographies/tex-truth.json +35 -0
  252. package/fixtures/paper-sources/v9-two-bibliographies/two.bib +1 -0
  253. package/fixtures/paper-typography/clean-paper/paper.tex +1 -1
  254. package/fixtures/paper-typography/messy-paper/paper.tex +1 -1
  255. package/package.json +2 -10
  256. package/scripts/check.test.ts +23 -1
  257. package/scripts/check.ts +7 -3
  258. package/scripts/coverage-config.test.ts +19 -10
  259. package/scripts/layer-legacy-frozen.test.ts +41 -2
  260. package/scripts/layer-legacy-frozen.ts +31 -2
  261. package/skills/grade-paper-writing/SKILL.md +4 -3
  262. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -2
  263. package/skills/grade-paper-writing/prose-lint.mjs +11 -2
  264. package/skills/grade-paper-writing/prose-lint.test.ts +52 -4
  265. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +83 -33
  266. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +67 -115
  267. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +128 -121
  268. package/skills/verify-citations/SKILL.md +7 -4
  269. package/skills/verify-citations/SKILL.md.spec.ts +6 -3
  270. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +17 -19
  271. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +129 -46
  272. package/skills/verify-citations/scripts/bib-authors.d.mts +22 -2
  273. package/skills/verify-citations/scripts/bib-authors.mjs +57 -111
  274. package/skills/verify-citations/scripts/verify-cites.cli.test.ts +16 -0
  275. package/skills/verify-citations/scripts/verify-cites.d.mts +19 -3
  276. package/skills/verify-citations/scripts/verify-cites.mjs +42 -108
  277. package/skills/verify-citations/scripts/verify-cites.test.mjs +23 -36
  278. package/src/adapters/bibtex/index.test.ts +163 -0
  279. package/src/adapters/bibtex/index.ts +155 -0
  280. package/src/adapters/latex/filecontents.ts +114 -0
  281. package/src/adapters/latex/includes.ts +18 -10
  282. package/src/adapters/latex/index.test.ts +73 -0
  283. package/src/adapters/latex/index.ts +2 -0
  284. package/src/adapters/latex/nodes.ts +64 -0
  285. package/src/adapters/latex/parse.test.ts +38 -0
  286. package/src/adapters/latex/parse.ts +67 -6
  287. package/src/adapters/memory/index.ts +17 -0
  288. package/src/adapters/references/index.test.ts +75 -21
  289. package/src/adapters/references/index.ts +37 -32
  290. package/src/adapters/sources-record/index.test.ts +87 -0
  291. package/src/adapters/sources-record/index.ts +82 -0
  292. package/src/adapters/tex-output/blg.ts +27 -0
  293. package/src/adapters/tex-output/fls.ts +24 -0
  294. package/src/adapters/tex-output/index.test.ts +171 -0
  295. package/src/adapters/tex-output/index.ts +12 -0
  296. package/src/bib-rules.test.ts +356 -0
  297. package/src/bib-rules.ts +228 -0
  298. package/src/bibliography-at.test.ts +286 -0
  299. package/src/build.test.ts +210 -0
  300. package/src/build.ts +100 -35
  301. package/src/claim-provenance.test.ts +33 -7
  302. package/src/claim-provenance.ts +2 -2
  303. package/src/cli.harness.mjs +20 -10
  304. package/src/cli.test.ts +14 -10
  305. package/src/cli.ts +37 -28
  306. package/src/domain/paper-source.test.ts +58 -20
  307. package/src/domain/paper-source.ts +38 -42
  308. package/src/domain/paper-sources.ts +94 -0
  309. package/src/domain/sources-record.test.ts +71 -0
  310. package/src/domain/sources-record.ts +136 -0
  311. package/src/domain/tex-run.test.ts +228 -0
  312. package/src/domain/tex-run.ts +139 -0
  313. package/src/heading-case.test.ts +24 -10
  314. package/src/latex-log.ts +20 -0
  315. package/src/latex-loop.harness.ts +1 -0
  316. package/src/latex-loop.ts +2 -0
  317. package/src/linted-set.test.ts +9 -1
  318. package/src/new-paper.harness.mjs +3 -2
  319. package/src/new-paper.test.ts +9 -5
  320. package/src/package-dirs.ts +3 -2
  321. package/src/paper-includes.test.ts +157 -38
  322. package/src/paper-includes.ts +39 -35
  323. package/src/paper-prose.test.ts +34 -5
  324. package/src/paper-prose.ts +2 -2
  325. package/src/paper-record.test.ts +173 -0
  326. package/src/paper-record.ts +66 -0
  327. package/src/paper-settings-commands.test.ts +5 -2
  328. package/src/paper-sources.ts +151 -0
  329. package/src/ports/bib-reader.ts +27 -0
  330. package/src/ports/check-references.ts +7 -3
  331. package/src/ports/latex.ts +19 -0
  332. package/src/ports/sources-codec.ts +11 -0
  333. package/src/ports/tex-output.ts +17 -0
  334. package/src/recorded-bibliography.ts +297 -0
  335. package/src/reference-rules.ts +217 -104
  336. package/src/references.test.ts +520 -177
  337. package/src/references.ts +185 -37
  338. package/src/register-bands.test.ts +5 -1
  339. package/src/register-bands.ts +3 -5
  340. package/src/register.test.ts +6 -1
  341. package/src/register.ts +2 -2
  342. package/src/rule-cases.test.ts +68 -12
  343. package/src/rule-docs.test.ts +0 -1
  344. package/src/sources-record.test.ts +308 -0
  345. package/src/sources-record.ts +231 -0
  346. package/src/sources-rules.test.ts +152 -0
  347. package/src/sources-rules.ts +117 -0
  348. package/src/tex-paper.test.ts +148 -78
  349. package/src/tex-paper.ts +31 -61
  350. package/src/tex-venue-rules.test.ts +36 -8
  351. package/src/tex-venue-rules.ts +3 -5
  352. package/dist/eslint-rules/bib-reachable-entry.d.ts +0 -22
  353. package/dist/eslint-rules/bib-reachable-entry.d.ts.map +0 -1
  354. package/dist/eslint-rules/bib-reachable-entry.js +0 -75
  355. package/dist/eslint-rules/bib-reachable-entry.js.map +0 -1
  356. package/eslint-rules/bib-reachable-entry.ts +0 -76
@@ -0,0 +1,136 @@
1
+ /**
2
+ * WHAT THE LAST BUILD READ — `<paper>/_build/sources.json`, written by `paperlint build` from TeX's
3
+ * own files (docs/design/paper-sources.md §1) and read, offline, by the rules that need to know which
4
+ * files make up a paper and which bibliography it uses. Nothing else answers either question: no
5
+ * code of paperlint reads TeX source to decide them.
6
+ *
7
+ * { "schema": 1,
8
+ * "inputs": [ { "path": "paper.tex", "role": "body" }, … ], the files of the paper directory TeX
9
+ * read, in first-read order
10
+ * "written": [ "refs.bib" ], the `.bib` and `.tex` files TeX wrote (filecontents)
11
+ * "bibdata": [ "refs" ], what `\bibdata` told bibtex to read
12
+ * "bibtex": { "ran": true, "databases": [ "refs.bib" ], the files bibtex opened, the keys it
13
+ * "keys": [ … ], "exit": 0, "errors": [] }, typeset, how it ended
14
+ * "sha256": { "paper.tex": "…", … } } every input and every database bibtex
15
+ * opened, by path and bytes
16
+ *
17
+ * 🔴 A role is the file's, and the main file is the body. A file is `preamble` when TeX read it only
18
+ * before `\begin{document}` — before it opened `paper.aux` for writing (measured) — and `body` when
19
+ * any read of it came after. The main file, the first one read, is `body` whatever its place in that
20
+ * order: it holds `\begin{document}` itself.
21
+ *
22
+ * The text of the file is checked once, where it enters: `SourcesCodec` (`src/ports/sources-codec.ts`),
23
+ * whose adapter holds the zod schema of this shape. The domain holds the value.
24
+ *
25
+ * 🔴 STALENESS is the record's own question: `changesSince` compares the bytes now with `sha256`, one
26
+ * key per file, so a rule can tell that the paper changed after the build without running TeX. A `.bib`
27
+ * TeX wrote is hashed too: the rules read it from disk, so an edit to it after the build is a change.
28
+ */
29
+ import type { Sha256 } from "./sha256.ts";
30
+
31
+ export const SOURCES_SCHEMA = 1;
32
+ export const SOURCES_DIR = "_build";
33
+ export const SOURCES_FILE = "sources.json";
34
+
35
+ /** The job name every file a build writes carries (`paper.aux`, `paper.fls`): the build names its job so. */
36
+ export const JOBNAME = "paper";
37
+
38
+ /** Before or after `\begin{document}`. */
39
+ export type InputRole = "preamble" | "body";
40
+
41
+ /** One file of the paper directory that TeX read. `path` is relative to it, with forward slashes. */
42
+ export interface RecordedInput {
43
+ readonly path: string;
44
+ readonly role: InputRole;
45
+ }
46
+
47
+ /** One error bibtex reported, as its `.blg` says it. */
48
+ export interface BibtexError {
49
+ readonly message: string;
50
+ /** The file it was reading (`refs.bib`, `paper.aux`). */
51
+ readonly file: string;
52
+ /** Null when bibtex named no line. */
53
+ readonly line: number | null;
54
+ }
55
+
56
+ /** What the build's bibtex did — or that the build ran none (the paper names no database). */
57
+ export type RecordedBibtex =
58
+ | { readonly ran: false }
59
+ | {
60
+ readonly ran: true;
61
+ /** The database files it opened, as it names them (`refs.bib`, `bibs/x.bib`). */
62
+ readonly databases: readonly string[];
63
+ /** The keys it typeset: every `\bibitem` of the `.bbl`. */
64
+ readonly keys: readonly string[];
65
+ readonly exit: number;
66
+ readonly errors: readonly BibtexError[];
67
+ };
68
+
69
+ export interface SourcesRecord {
70
+ readonly schema: typeof SOURCES_SCHEMA;
71
+ readonly inputs: readonly RecordedInput[];
72
+ readonly written: readonly string[];
73
+ readonly bibdata: readonly string[];
74
+ readonly bibtex: RecordedBibtex;
75
+ /** Path → digest of its bytes; null for a file that was not there when the record was made. */
76
+ readonly sha256: Readonly<Record<string, string | null>>;
77
+ }
78
+
79
+ export type ParsedRecord =
80
+ | { readonly ok: true; readonly record: SourcesRecord }
81
+ | { readonly ok: false; readonly why: string };
82
+
83
+ /** The record as the file holds it: two-space JSON and a final newline, so a diff of two builds reads. */
84
+ export const serializeSourcesRecord = (r: SourcesRecord): string =>
85
+ `${JSON.stringify(r, null, 2)}\n`;
86
+
87
+ // ── staleness ───────────────────────────────────────────────────────────────────────────
88
+
89
+ export type ChangeKind = "edited" | "deleted" | "added";
90
+
91
+ /** One file whose bytes are not those the build hashed. */
92
+ export interface Change {
93
+ readonly path: string;
94
+ readonly change: ChangeKind;
95
+ }
96
+
97
+ /**
98
+ * The files whose bytes now differ from the record's: edited, deleted, or present where the record saw
99
+ * none. `now` answers for one path with the digest of its bytes, or null when there is no such file.
100
+ * Empty when the record is current.
101
+ */
102
+ export function changesSince(
103
+ r: SourcesRecord,
104
+ now: (path: string) => Sha256 | null,
105
+ ): readonly Change[] {
106
+ return Object.entries(r.sha256).flatMap(
107
+ ([path, recorded]): readonly Change[] => {
108
+ const current = now(path);
109
+ if (current === recorded) return [];
110
+ if (current === null) return [{ path, change: "deleted" }];
111
+ return [{ path, change: recorded === null ? "added" : "edited" }];
112
+ },
113
+ );
114
+ }
115
+
116
+ /** `paper.tex edited, sections/a.tex deleted` — what a finding names. */
117
+ export const describeChanges = (cs: readonly Change[]): string =>
118
+ cs.map((c) => `${c.path} ${c.change}`).join(", ");
119
+
120
+ /**
121
+ * The name among `names` that `name` stands for: `name` itself, else the one name equal to it ignoring
122
+ * case — null when there is none, or several. TeX opens a file by the name the source gives it; on a
123
+ * file system that ignores case that is the source's spelling, on one that does not TeX retries
124
+ * without case, and the record holds the disk's spelling either way. A name and the record's spelling
125
+ * of it are matched by this, once, wherever one is looked up in the other.
126
+ */
127
+ export function spelledIn(
128
+ names: readonly string[],
129
+ name: string,
130
+ ): string | null {
131
+ if (names.includes(name)) return name;
132
+ const [only, ...more] = names.filter(
133
+ (n) => n.toLowerCase() === name.toLowerCase(),
134
+ );
135
+ return only !== undefined && more.length === 0 ? only : null;
136
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Which files of the paper directory a build's TeX passes read, and which `.bib` files they wrote —
3
+ * decided from the `.fls` lines of each pass. The inputs are `.fls` files captured whole from real
4
+ * runs of the planted papers (test/fixtures/tex-output/README.md); the few shapes no planted paper
5
+ * has are written out, and say so.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { describe, expect, it } from "vitest";
11
+ import { texOutput } from "../adapters/tex-output/index.ts";
12
+ import { filesOfRun } from "./tex-run.ts";
13
+
14
+ const CAPTURES = join(
15
+ dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
16
+ "test",
17
+ "fixtures",
18
+ "tex-output",
19
+ );
20
+ /** A planted paper's passes, as `.fls` text → lines, in the order they ran. */
21
+ const passes = (paper: string) =>
22
+ ["first-pass.fls", "paper.fls"].map((f) =>
23
+ texOutput.fls(readFileSync(join(CAPTURES, paper, f), "utf8")),
24
+ );
25
+ const BIBTEX_RAN = { jobname: "paper", generated: ["paper.bbl"] };
26
+
27
+ describe("the files a build's TeX read", () => {
28
+ it("a paper of one file: the main file, a body", () => {
29
+ expect(filesOfRun(passes("v3-declared"), BIBTEX_RAN)).toEqual({
30
+ inputs: [{ path: "paper.tex", role: "body" }],
31
+ written: [],
32
+ });
33
+ });
34
+
35
+ it("an include read after \\begin{document} is body, a database TeX read before it is preamble — in first-read order", () => {
36
+ expect(filesOfRun(passes("p1"), BIBTEX_RAN)).toEqual({
37
+ inputs: [
38
+ { path: "paper.tex", role: "body" },
39
+ { path: "refs.bib", role: "preamble" },
40
+ { path: "sections/intro.tex", role: "body" },
41
+ ],
42
+ written: [],
43
+ });
44
+ });
45
+
46
+ it("a file read before \\begin{document} and never after is preamble", () => {
47
+ expect(filesOfRun(passes("v16-included-block"), BIBTEX_RAN)).toEqual({
48
+ inputs: [
49
+ { path: "paper.tex", role: "body" },
50
+ { path: "bibblock.tex", role: "preamble" },
51
+ { path: "refs.bib", role: "preamble" },
52
+ ],
53
+ written: [],
54
+ });
55
+ });
56
+ });
57
+
58
+ describe("the .bib files TeX wrote", () => {
59
+ it("a .bib TeX wrote (filecontents) is written, and is no input: its bytes are the block's", () => {
60
+ expect(filesOfRun(passes("v8-jobname"), BIBTEX_RAN)).toEqual({
61
+ inputs: [{ path: "paper.tex", role: "body" }],
62
+ written: ["paper.bib"],
63
+ });
64
+ });
65
+
66
+ it("🔴 a block writes its file on the first pass only (the later passes find it there and read it): the last pass alone would list it as an input and not as written", () => {
67
+ const [first, last] = passes("plain-block");
68
+ expect(last?.lines.filter((l) => l.path.endsWith(".bib"))).toEqual([
69
+ { op: "INPUT", path: "./refs.bib" },
70
+ { op: "INPUT", path: "refs.bib" },
71
+ ]);
72
+ expect(filesOfRun(passes("plain-block"), BIBTEX_RAN)).toEqual({
73
+ inputs: [{ path: "paper.tex", role: "body" }],
74
+ written: ["refs.bib"],
75
+ });
76
+ expect(first?.lines.filter((l) => l.path.endsWith(".bib"))).toEqual([
77
+ { op: "OUTPUT", path: "refs.bib" },
78
+ ]);
79
+ });
80
+ });
81
+
82
+ describe("a .tex TeX wrote and then read — the paper's text, generated", () => {
83
+ it("🔴 a block that writes a .tex the body \\input's: the file is written AND an input, a body file — its text is in the PDF", () => {
84
+ // v29-generated-input: pass 1 writes body.tex and reads it after \begin{document}; the later
85
+ // passes find it there, read it in the preamble (the block's check) and again in the body.
86
+ const [first, last] = passes("v29-generated-input");
87
+ expect(first?.lines.filter((l) => l.path.endsWith("body.tex"))[0]).toEqual({
88
+ op: "OUTPUT",
89
+ path: "body.tex",
90
+ });
91
+ expect(
92
+ last?.lines.some((l) => l.op === "OUTPUT" && l.path === "body.tex"),
93
+ ).toBe(false);
94
+ expect(filesOfRun(passes("v29-generated-input"), BIBTEX_RAN)).toEqual({
95
+ inputs: [
96
+ { path: "paper.tex", role: "body" },
97
+ { path: "body.tex", role: "body" },
98
+ ],
99
+ written: ["body.tex"],
100
+ });
101
+ });
102
+
103
+ it("with [overwrite] the last pass writes it again before reading it: still an input", () => {
104
+ const fls = texOutput.fls(
105
+ [
106
+ "PWD /work/paper",
107
+ "INPUT paper.tex",
108
+ "INPUT ./body.tex",
109
+ "OUTPUT body.tex",
110
+ "OUTPUT paper.aux",
111
+ "INPUT ./body.tex",
112
+ ].join("\n"),
113
+ );
114
+ expect(filesOfRun([fls], BIBTEX_RAN)).toEqual({
115
+ inputs: [
116
+ { path: "paper.tex", role: "body" },
117
+ { path: "body.tex", role: "body" },
118
+ ],
119
+ written: ["body.tex"],
120
+ });
121
+ });
122
+ });
123
+
124
+ describe("what is not the paper's: its TeX tree, a sibling directory, what TeX writes", () => {
125
+ it("the .bbl bibtex wrote is no input of the paper; with no bibtex run it is a file TeX read, and so is one", () => {
126
+ const last = passes("v3-declared");
127
+ expect(
128
+ filesOfRun(last, { jobname: "paper", generated: [] }).inputs,
129
+ ).toEqual([
130
+ { path: "paper.tex", role: "body" },
131
+ { path: "paper.bbl", role: "body" },
132
+ ]);
133
+ });
134
+
135
+ it("files the paper's TeX tree supplies are not the paper's: only the paper directory's files are listed", () => {
136
+ const fls = texOutput.fls(
137
+ [
138
+ "PWD /work/paper",
139
+ "INPUT /usr/share/texlive/texmf-dist/tex/latex/base/article.cls",
140
+ "INPUT paper.tex",
141
+ "INPUT /work/paper/inside.tex",
142
+ "INPUT ../shared/outside.tex",
143
+ "INPUT /elsewhere/other.tex",
144
+ "OUTPUT paper.aux",
145
+ ].join("\n"),
146
+ );
147
+ expect(
148
+ filesOfRun([fls], { jobname: "paper", generated: [] }).inputs,
149
+ ).toEqual([
150
+ { path: "paper.tex", role: "body" },
151
+ { path: "inside.tex", role: "preamble" },
152
+ ]);
153
+ });
154
+ });
155
+
156
+ describe("an .fls with no directory, and a name that merely starts with dots", () => {
157
+ it("an .fls that names no directory lists the files named relative to it and no absolute path", () => {
158
+ const fls = texOutput.fls(
159
+ "INPUT /usr/share/texlive/article.cls\nINPUT paper.tex\nINPUT ..x.tex\n",
160
+ );
161
+ expect(
162
+ filesOfRun([fls], { jobname: "paper", generated: [] }).inputs,
163
+ ).toEqual([
164
+ { path: "paper.tex", role: "body" },
165
+ { path: "..x.tex", role: "preamble" },
166
+ ]);
167
+ });
168
+ });
169
+
170
+ describe("roles on the edges: both places, no body, no passes", () => {
171
+ it("a file read in the preamble and again in the body is body: its first read alone would drop it from the prose", () => {
172
+ const fls = texOutput.fls(
173
+ [
174
+ "PWD /work/paper",
175
+ "INPUT paper.tex",
176
+ "INPUT ./macros.tex",
177
+ "OUTPUT paper.aux",
178
+ "INPUT ./macros.tex",
179
+ "INPUT ./intro.tex",
180
+ ].join("\n"),
181
+ );
182
+ expect(
183
+ filesOfRun([fls], { jobname: "paper", generated: [] }).inputs,
184
+ ).toEqual([
185
+ { path: "paper.tex", role: "body" },
186
+ { path: "macros.tex", role: "body" },
187
+ { path: "intro.tex", role: "body" },
188
+ ]);
189
+ });
190
+
191
+ it("a run that stopped before opening the .aux has no body: everything but the main file is preamble", () => {
192
+ const fls = texOutput.fls(
193
+ "PWD /work/paper\nINPUT paper.tex\nINPUT ./a.tex\nINPUT ./b.tex\n",
194
+ );
195
+ expect(
196
+ filesOfRun([fls], { jobname: "paper", generated: [] }).inputs,
197
+ ).toEqual([
198
+ { path: "paper.tex", role: "body" },
199
+ { path: "a.tex", role: "preamble" },
200
+ { path: "b.tex", role: "preamble" },
201
+ ]);
202
+ });
203
+
204
+ it("a file TeX wrote on any pass is not an input, even one it also reads on a later pass (.toc, .out)", () => {
205
+ const first = texOutput.fls(
206
+ "PWD /w\nINPUT paper.tex\nOUTPUT paper.aux\nOUTPUT paper.toc\n",
207
+ );
208
+ const second = texOutput.fls(
209
+ "PWD /w\nINPUT paper.tex\nINPUT ./paper.toc\nOUTPUT paper.aux\n",
210
+ );
211
+ expect(
212
+ filesOfRun([first, second], { jobname: "paper", generated: [] }).inputs,
213
+ ).toEqual([{ path: "paper.tex", role: "body" }]);
214
+ });
215
+
216
+ it("no passes, or passes that opened nothing of the paper, list nothing", () => {
217
+ expect(filesOfRun([], { jobname: "paper", generated: [] })).toEqual({
218
+ inputs: [],
219
+ written: [],
220
+ });
221
+ expect(
222
+ filesOfRun([texOutput.fls("PWD /w\nINPUT /usr/x.cls\n")], {
223
+ jobname: "paper",
224
+ generated: [],
225
+ }),
226
+ ).toEqual({ inputs: [], written: [] });
227
+ });
228
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * WHICH FILES A BUILD'S TeX PASSES READ — decided from the lines of each pass's `.fls` (pdflatex
3
+ * `-recorder`), not from any TeX source (docs/design/paper-sources.md §1).
4
+ *
5
+ * inputs the files of the paper directory the LAST pass read, in the order it first read them,
6
+ * each a `preamble` or `body` file: before or after TeX opened `<jobname>.aux` for writing,
7
+ * which `\begin{document}` does (measured). The main file, read first, is `body`.
8
+ * written the `.bib` and `.tex` files ANY pass wrote — a `filecontents` block's.
9
+ *
10
+ * 🔴 WHY THE LAST PASS FOR INPUTS AND EVERY PASS FOR WHAT WAS WRITTEN. A block without `[overwrite]`
11
+ * writes its file on the first pass only; the later passes find it there and read it (measured on TeX
12
+ * Live 2026: pass 1 `OUTPUT refs.bib`, passes 2 and 3 `INPUT refs.bib`, no OUTPUT). The last pass alone
13
+ * would list TeX's own copy of a `.bib` as a file of the paper. And the first pass reads no `.aux`,
14
+ * `.toc` or `.out` yet, so only a later pass lists what the paper reads and the passes after it write.
15
+ *
16
+ * 🔴 A FILE TeX WROTE IS AN INPUT ONLY WHEN IT IS TeX SOURCE OF THE PAPER: a `.tex`. A block that
17
+ * writes `body.tex` the body `\input`s puts that text in the PDF (measured: pass 1 `OUTPUT body.tex`
18
+ * then `INPUT ./body.tex` after `\begin{document}`; the later passes read it again), so it is an
19
+ * input like any other: hashed, spliced where its include stands. Every other file a pass wrote is
20
+ * TeX's bookkeeping or a database — its `.aux`, `.out` and `.toc`, a block's `.bib` — and is no input.
21
+ * A file another program wrote from TeX's output (`generated`: the `.bbl`, when bibtex ran) is none
22
+ * either.
23
+ */
24
+ import { isAbsolute, posix } from "node:path";
25
+ import type { RecordedInput } from "./sources-record.ts";
26
+
27
+ /** One line of an `.fls`: TeX opened a file to read it, or to write it. */
28
+ export interface FlsLine {
29
+ readonly op: "INPUT" | "OUTPUT";
30
+ /** As TeX named the file: `./a.tex` and `a.tex` for one file, an absolute path for the TeX tree's. */
31
+ readonly path: string;
32
+ }
33
+
34
+ /** A pdflatex `-recorder` file: where it ran, and every file it opened, in order. */
35
+ export interface Fls {
36
+ /** The directory pdflatex ran in, or null when the file names none. */
37
+ readonly pwd: string | null;
38
+ readonly lines: readonly FlsLine[];
39
+ }
40
+
41
+ export interface RunFiles {
42
+ readonly inputs: readonly RecordedInput[];
43
+ readonly written: readonly string[];
44
+ }
45
+
46
+ /**
47
+ * `path` as TeX named it → relative to the paper directory with forward slashes, or null when the file
48
+ * is not in it: the TeX tree's, a file of a sibling directory, an absolute path outside `pwd`.
49
+ */
50
+ function inPaperDir(path: string, pwd: string | null): string | null {
51
+ const rel = isAbsolute(path)
52
+ ? pwd === null
53
+ ? null
54
+ : posix.relative(pwd, path)
55
+ : posix.normalize(path);
56
+ // `..` or `../x`: a file of a sibling directory. (`..x` is a file of this one.)
57
+ return rel === null || rel.split("/")[0] === ".." ? null : rel;
58
+ }
59
+
60
+ const distinct = <T>(xs: readonly T[]): readonly T[] => [...new Set(xs)];
61
+
62
+ /** A file TeX writes that the record names: TeX source of the paper (`.tex`) or a database (`.bib`). */
63
+ const isTexSource = (path: string): boolean => posix.extname(path) === ".tex";
64
+ const isRecordedWrite = (path: string): boolean =>
65
+ isTexSource(path) || posix.extname(path) === ".bib";
66
+
67
+ type Opened = FlsLine & { readonly rel: string };
68
+
69
+ /** The files of the paper directory a pass opened, with the op, in order. */
70
+ const opened = (pass: Fls): readonly Opened[] =>
71
+ pass.lines.flatMap((l) => {
72
+ const rel = inPaperDir(l.path, pass.pwd);
73
+ return rel === null ? [] : [{ ...l, rel }];
74
+ });
75
+
76
+ export interface RunOptions {
77
+ /** The job name: `<jobname>.aux` is the file whose opening marks `\begin{document}`. */
78
+ readonly jobname: string;
79
+ /** Files other programs wrote from TeX's own output, relative to the paper directory (`paper.bbl`). */
80
+ readonly generated: readonly string[];
81
+ }
82
+
83
+ /** One read of a file of the paper, and whether it came after `\begin{document}`. */
84
+ interface Read {
85
+ readonly path: string;
86
+ readonly afterBegin: boolean;
87
+ }
88
+
89
+ /**
90
+ * The reads of the last pass: INPUT lines of files nothing wrote, or that a pass wrote as TeX source,
91
+ * each with its place against the line that opens `<jobname>.aux` — `\begin{document}`. No such line
92
+ * (the run stopped first): none is after it.
93
+ */
94
+ function readsOf(
95
+ last: readonly Opened[],
96
+ outputs: ReadonlySet<string>,
97
+ { jobname, generated }: RunOptions,
98
+ ): readonly Read[] {
99
+ const boundary = last.findIndex(
100
+ (l) => l.op === "OUTPUT" && l.rel === `${jobname}.aux`,
101
+ );
102
+ return last.flatMap((l, i) =>
103
+ l.op === "INPUT" &&
104
+ (!outputs.has(l.rel) || isTexSource(l.rel)) &&
105
+ !generated.includes(l.rel)
106
+ ? [{ path: l.rel, afterBegin: boundary >= 0 && i > boundary }]
107
+ : [],
108
+ );
109
+ }
110
+
111
+ export function filesOfRun(
112
+ passes: readonly Fls[],
113
+ options: RunOptions,
114
+ ): RunFiles {
115
+ const everyPass = passes.flatMap(opened);
116
+ const wrote = (l: Opened): boolean => l.op === "OUTPUT";
117
+ const outputs = new Set(everyPass.filter(wrote).map((l) => l.rel));
118
+ const last = passes.at(-1);
119
+ const reads = readsOf(
120
+ last === undefined ? [] : opened(last),
121
+ outputs,
122
+ options,
123
+ );
124
+ // The main file holds `\begin{document}` itself, so it is body wherever TeX opened it.
125
+ const body = (path: string): boolean =>
126
+ path === reads[0]?.path ||
127
+ reads.some((r) => r.path === path && r.afterBegin);
128
+ return {
129
+ inputs: distinct(reads.map((r) => r.path)).map((path): RecordedInput => ({
130
+ path,
131
+ role: body(path) ? "body" : "preamble",
132
+ })),
133
+ written: distinct(
134
+ everyPass
135
+ .filter((l) => wrote(l) && isRecordedWrite(l.rel))
136
+ .map((l) => l.rel),
137
+ ),
138
+ };
139
+ }
@@ -12,6 +12,7 @@ import { run } from "./cli.ts";
12
12
  import { headingCaseRule } from "./heading-case.ts";
13
13
  import { latexReader } from "./adapters/latex/index.ts";
14
14
  import { useTempDir, writeTree } from "../test/support.ts";
15
+ import { recordedTree } from "../test/recorded-fixture.ts";
15
16
 
16
17
  const Results = z.array(
17
18
  z.looseObject({
@@ -369,10 +370,15 @@ describe("tex/heading-case refuses, when ESLint loads it, a configuration that a
369
370
  });
370
371
 
371
372
  describe("tex/heading-case in the files a paper includes", () => {
372
- const tree = paper(
373
- doc("\\section{Introduction}\n\\input{sections/results}"),
374
- PRESET,
375
- { "papers/p/sections/results.tex": "\\section{Results and discussion}\n" },
373
+ const tree = recordedTree(
374
+ paper(doc("\\section{Introduction}\n\\input{sections/results}"), PRESET, {
375
+ "papers/p/sections/results.tex": "\\section{Results and discussion}\n",
376
+ }),
377
+ "papers/p",
378
+ [
379
+ { path: "paper.tex", role: "body" },
380
+ { path: "sections/results.tex", role: "body" },
381
+ ],
376
382
  );
377
383
 
378
384
  it("reports a heading at its own file and line, and --fix edits that file", async () => {
@@ -391,12 +397,20 @@ describe("tex/heading-case in the files a paper includes", () => {
391
397
  });
392
398
 
393
399
  it("🔴 reads a \\title kept in a file the body includes, where it stands", async () => {
394
- const withTitle = paper(
395
- `${ACM}\n\\begin{document}\n\\input{front}\n\\maketitle\n\\end{document}\n`,
396
- PRESET,
397
- {
398
- "papers/p/front.tex": "% the front matter\n\\title{Measuring things}\n",
399
- },
400
+ const withTitle = recordedTree(
401
+ paper(
402
+ `${ACM}\n\\begin{document}\n\\input{front}\n\\maketitle\n\\end{document}\n`,
403
+ PRESET,
404
+ {
405
+ "papers/p/front.tex":
406
+ "% the front matter\n\\title{Measuring things}\n",
407
+ },
408
+ ),
409
+ "papers/p",
410
+ [
411
+ { path: "paper.tex", role: "body" },
412
+ { path: "front.tex", role: "body" },
413
+ ],
400
414
  );
401
415
  const { found } = await lint(withTitle);
402
416
  expect(found).toEqual([
package/src/latex-log.ts CHANGED
@@ -146,6 +146,26 @@ export function bibtexExcerpt(stdout: string): string[] {
146
146
  .slice(0, MAX_EXCERPT);
147
147
  }
148
148
 
149
+ /** What a bibtex run read: the databases its `.blg` names, the entries its `.bbl` holds. */
150
+ export interface BibtexRead {
151
+ readonly databases: readonly string[];
152
+ readonly bibitems: readonly string[];
153
+ }
154
+
155
+ /** `\bibitem{key}`, `\bibitem[label]{key}` — a label may hold one level of braces (natbib's `[{A}(2020)]`). */
156
+ const BIBITEM = /\\bibitem\s*(?:\[(?:[^[\]{}]|\{[^{}]*\})*\])?\s*\{([^}]+)\}/g;
157
+
158
+ /**
159
+ * What a bibtex run read, from the files it left: `Database file #n: name` in its `.blg` (the name
160
+ * as the `.aux` gave it, relative to where bibtex ran), and every key its `.bbl` typesets.
161
+ */
162
+ export const bibtexRead = (blg: string, bbl: string): BibtexRead => ({
163
+ databases: [...blg.matchAll(/^Database file #\d+: (.+)$/gm)].flatMap((m) =>
164
+ m.slice(1, 2),
165
+ ),
166
+ bibitems: [...bbl.matchAll(BIBITEM)].flatMap((m) => m.slice(1, 2)),
167
+ });
168
+
149
169
  /** What the `.aux` tells bibtex: the cited keys, the databases and the style. */
150
170
  export interface AuxBib {
151
171
  /** Sorted, unique. `*` (`\nocite{*}`) is a key like any other. */
@@ -51,6 +51,7 @@ const latex = (o: Partial<LatexPass> = {}): LatexPass => ({
51
51
  markers: [],
52
52
  bib: NO_BIB,
53
53
  errorLines: [],
54
+ fls: null,
54
55
  ...o,
55
56
  });
56
57
  const bibtex = (o: Partial<BibtexPass> = {}): BibtexPass => ({
package/src/latex-loop.ts CHANGED
@@ -86,6 +86,8 @@ export type Observation =
86
86
  readonly bib: BibInput;
87
87
  /** The first error and its source context, from the log. Empty on success. */
88
88
  readonly errorLines: readonly string[];
89
+ /** What `-recorder` wrote after this pass (`paper.fls`), or null when pdflatex wrote none. */
90
+ readonly fls: string | null;
89
91
  }
90
92
  | {
91
93
  readonly step: "bibtex";
@@ -20,6 +20,7 @@ import { join, relative } from "node:path";
20
20
  import { afterEach, describe, expect, it } from "vitest";
21
21
  import { run } from "./cli.ts";
22
22
  import { lintReport } from "../test/lint-report.ts";
23
+ import { recordedTree } from "../test/recorded-fixture.ts";
23
24
 
24
25
  const dirs: string[] = [];
25
26
  afterEach(() => {
@@ -87,7 +88,14 @@ function project(): string {
87
88
  "papers/a/sections/intro.tex": "Intro.\n",
88
89
  ...NEIGHBOURS,
89
90
  };
90
- for (const [p, text] of Object.entries(all)) {
91
+ // As a build left it: TeX read paper.tex, the macro file in the preamble and the section in the body
92
+ // (`paper-guards` is paperlint's own input, outside the paper's directory, so the record has none).
93
+ const built = recordedTree(all, "papers/a", [
94
+ { path: "paper.tex", role: "body" },
95
+ { path: "macros.tex", role: "preamble" },
96
+ { path: "sections/intro.tex", role: "body" },
97
+ ]);
98
+ for (const [p, text] of Object.entries(built)) {
91
99
  mkdirSync(join(root, p, ".."), { recursive: true });
92
100
  writeFileSync(join(root, p), text);
93
101
  }
@@ -112,10 +112,11 @@ try {
112
112
  );
113
113
  const l = await lint(join(papers, "demo"));
114
114
  check(
115
- "🔴 `paperlint lint` passes the scaffold — exit 0, not a missing-file error — with the one warning that no venue is chosen yet",
115
+ "🔴 `paperlint lint` passes the scaffold — exit 0, not a missing-file error — with two warnings: no venue is chosen yet, and the paper is not built",
116
116
  l.code === 0 &&
117
117
  /names no venue preset yet/.test(l.text) &&
118
- /1 problem \(0 errors, 1 warning\)/.test(l.text),
118
+ /paper\/sources-fresh/.test(l.text) &&
119
+ /2 problems \(0 errors, 2 warnings\)/.test(l.text),
119
120
  );
120
121
 
121
122
  // What the status hook reads on every paper edit: the verdict line.
@@ -122,10 +122,13 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
122
122
  return { code, messages };
123
123
  }
124
124
 
125
- it("gets exactly one warning naming the file to set, and exits 0", async () => {
125
+ it("gets the warning naming the file to set, besides the one that it is not built, and exits 0", async () => {
126
126
  const { code, messages } = await lintNew(null);
127
127
  expect(code).toBe(0);
128
- expect(messages).toHaveLength(1);
128
+ expect(messages.map((m) => m.ruleId)).toEqual([
129
+ "pdf/measured",
130
+ "paper/sources-fresh",
131
+ ]);
129
132
  expect(messages[0]?.ruleId).toBe("pdf/measured");
130
133
  expect(messages[0]?.severity).toBe(1);
131
134
  expect(messages[0]?.message).toMatch(/names no venue preset yet/);
@@ -254,10 +257,11 @@ describe("paperlint new --venue msr", () => {
254
257
  log: (s: string) => out.push(s),
255
258
  err: () => {},
256
259
  });
257
- // Guards: what remains is the reminder that the PDF is not built yet — a warning, not a finding
258
- // about the paper.
259
- expect(out.join("\n")).toMatch(/\(0 errors, 1 warning\)/);
260
+ // Guards: what remains is the reminder that the paper is not built yet — two warnings, not a
261
+ // finding about the paper: no PDF measured, no record of what TeX read.
262
+ expect(out.join("\n")).toMatch(/\(0 errors, 2 warnings\)/);
260
263
  expect(out.join("\n")).toMatch(/pdf\/measured/);
264
+ expect(out.join("\n")).toMatch(/paper\/sources-fresh/);
261
265
  expect(code).toBe(0);
262
266
  });
263
267
  });