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
@@ -46,6 +46,15 @@ const check = createChecker();
46
46
  const tex = (body) =>
47
47
  `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
48
48
 
49
+ /**
50
+ * A run that linted a paper nobody built clean: exit 0, and the one finding is the warning that no
51
+ * build recorded what TeX read (`paper/sources-fresh`) — what "no findings" meant before that rule.
52
+ */
53
+ const cleanButUnbuilt = (r) =>
54
+ r.code === 0 &&
55
+ /paper\/sources-fresh/.test(r.out) &&
56
+ /1 problem \(0 errors, 1 warning\)/.test(r.out);
57
+
49
58
  /** Runs the utility with output captured — quieter and faster than spawning a process. */
50
59
  async function cli(args, cwd) {
51
60
  // 🔴 THE STREAMS ARE SPLIT, AND THAT IS LOAD-BEARING. As long as the harness dumped log and
@@ -196,7 +205,7 @@ check(
196
205
  const bare = await cli(["lint"], root);
197
206
  check(
198
207
  "🔴 NO paperlint.json IS A VALID PROJECT — the default papers directory is linted",
199
- bare.code === 0 && /no findings/.test(bare.out),
208
+ cleanButUnbuilt(bare),
200
209
  );
201
210
  check(
202
211
  "and no config line is printed when there is no file to name",
@@ -208,7 +217,7 @@ check(
208
217
  const empty = await cli(["lint"], root);
209
218
  check(
210
219
  `an empty paperlint.json — no \`${PAPERS_DIR_FIELD}\`, so the default`,
211
- empty.code === 0 && /no findings/.test(empty.out),
220
+ cleanButUnbuilt(empty),
212
221
  );
213
222
 
214
223
  // 🔴 THE DECLARED DIRECTORY IS NOT THE DEFAULT: with `papers` declared, a CLI that ignored
@@ -221,7 +230,7 @@ check(
221
230
  const r = await cli(["lint"], root);
222
231
  check(
223
232
  "🔴 THE UTILITY READS THE DECLARATION FROM paperlint.json — the file `paperlint init` writes and the hooks read",
224
- r.code === 0 && /no findings/.test(r.out),
233
+ cleanButUnbuilt(r),
225
234
  );
226
235
  check(
227
236
  "and NAMES the carrier out loud — swapping settings is never silent",
@@ -713,7 +722,7 @@ check(
713
722
  const clean = await cli(["lint", "papers"], root);
714
723
  check(
715
724
  "on a clean corpus — zero and a legible report",
716
- clean.code === 0 && /no findings/.test(clean.out),
725
+ cleanButUnbuilt(clean),
717
726
  );
718
727
 
719
728
  writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(999));
@@ -822,7 +831,7 @@ check(
822
831
  const found = await cli(["lint"], root);
823
832
  check(
824
833
  "the config is FOUND on its own: `lint` with no argument at all runs",
825
- found.code === 0 && /no findings/.test(found.out),
834
+ cleanButUnbuilt(found),
826
835
  );
827
836
  check(
828
837
  "and the found file is NAMED out loud — swapping settings silently is not acceptable",
@@ -836,7 +845,7 @@ check(
836
845
  const fromSub = await cli(["lint"], paper);
837
846
  check(
838
847
  "the path from the config resolves relative to the CONFIG'S DIRECTORY, not the current one",
839
- fromSub.code === 0 && /no findings/.test(fromSub.out),
848
+ cleanButUnbuilt(fromSub),
840
849
  );
841
850
 
842
851
  // Consumer data from the found config actually reaches the rules, not just gets read.
@@ -865,7 +874,7 @@ check(
865
874
  const noPapers = await cli(["lint"], root);
866
875
  check(
867
876
  "a config WITHOUT the papers-directory field lints the default `papers/`",
868
- noPapers.code === 0 && /no findings/.test(noPapers.out),
877
+ cleanButUnbuilt(noPapers),
869
878
  );
870
879
  check(
871
880
  'an empty string as the papers directory is refused, not read as the directory ""',
@@ -925,7 +934,7 @@ check(
925
934
  );
926
935
  check(
927
936
  'and the failure names the NUMBER and the THRESHOLD, not just "too many"',
928
- /3 warning\(s\) exceed the --max-warnings limit of 0/.test(strict.out),
937
+ /4 warning\(s\) exceed the --max-warnings limit of 0/.test(strict.out),
929
938
  );
930
939
  const generous = await cli(["lint", "--max-warnings", "5"], root);
931
940
  check(
@@ -1597,13 +1606,14 @@ console.log(
1597
1606
  );
1598
1607
  const r = await cli(["new", "demo"], root);
1599
1608
  check(
1600
- "🔴 `paperlint new demo` creates writing/demo/ and its lint is the verdict — exit 0, and only the warning that no venue is chosen yet",
1609
+ "🔴 `paperlint new demo` creates writing/demo/ and its lint is the verdict — exit 0, and only two warnings: no venue is chosen yet, the paper is not built",
1601
1610
  r.code === 0 &&
1602
1611
  existsSync(join(root, "writing", "demo", "PIPELINE-STATUS.md")) &&
1603
1612
  existsSync(join(root, "writing", "demo", "paper.tex")) &&
1604
1613
  existsSync(join(root, "writing", "demo", "paperlint.json")) &&
1605
1614
  /names no venue preset yet/.test(r.out) &&
1606
- /\(0 errors, 1 warning\)/.test(r.out),
1615
+ /paper\/sources-fresh/.test(r.out) &&
1616
+ /\(0 errors, 2 warnings\)/.test(r.out),
1607
1617
  );
1608
1618
  const again = await cli(["new", "demo"], root);
1609
1619
  check(
package/src/cli.test.ts CHANGED
@@ -33,13 +33,18 @@ import { texInputsDir } from "./package-dirs.ts";
33
33
  const root = useTempDir("cli-test-");
34
34
 
35
35
  /** `run(argv)` from `cwd`, the output as one string per stream. */
36
- async function cli(argv: string[], cwd: string) {
36
+ async function cli(
37
+ argv: string[],
38
+ cwd: string,
39
+ deps: Pick<NonNullable<Parameters<typeof run>[1]>, "loadTexLanguage"> = {},
40
+ ) {
37
41
  const out: string[] = [];
38
42
  const err: string[] = [];
39
43
  const code = await run(argv, {
40
44
  log: (...a: unknown[]) => out.push(a.join(" ")),
41
45
  err: (...a: unknown[]) => err.push(a.join(" ")),
42
46
  cwd,
47
+ ...deps,
43
48
  });
44
49
  return { code, out: out.join("\n"), err: err.join("\n") };
45
50
  }
@@ -617,21 +622,20 @@ test("init's TeX Live install runs the toolchain over the project's papers, neve
617
622
  assert.equal(typeof here.installed(), "boolean");
618
623
  });
619
624
 
620
- test("lint with its LaTeX language missing from the install still lints the scorecards", () => {
625
+ test("lint with its LaTeX language missing from the install still lints the scorecards", async () => {
621
626
  const dir = writeTree(join(root, "lint-no-tex-language"), {
622
627
  "papers/p/PIPELINE-STATUS.md": "---\nstages: []\n---\n# S\n",
623
- "no-latex.mjs":
624
- "import { register } from 'node:module';\n" +
625
- "register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '#eslint-rules/latex-language') throw Object.assign(new Error('gone'), { code: 'ERR_MODULE_NOT_FOUND' }); return next(s, c); }\"));\n",
626
628
  });
627
- const without = runNode(BIN, ["lint", "papers"], {
628
- cwd: dir,
629
- nodeArgs: ["--import", join(dir, "no-latex.mjs")],
629
+ const gone = Object.assign(new Error("gone"), {
630
+ code: "ERR_MODULE_NOT_FOUND",
630
631
  });
631
- const whole = runNode(BIN, ["lint", "papers"], { cwd: dir });
632
+ const without = await cli(["lint", "papers"], dir, {
633
+ loadTexLanguage: () => Promise.reject(gone),
634
+ });
635
+ const whole = await cli(["lint", "papers"], dir);
632
636
  // A scorecard needs no LaTeX language: the same report, the same code.
633
637
  assert.deepEqual(without, whole);
634
- assert.match(whole.stdout, /missing all of `paper\.tex`/);
638
+ assert.match(whole.out, /missing all of `paper\.tex`/);
635
639
  });
636
640
 
637
641
  test("`paperlint hook` with no name: exit 2, and the usage names an example", async () => {
package/src/cli.ts CHANGED
@@ -46,11 +46,11 @@ import {
46
46
  FRAGMENT_FILES,
47
47
  includeBlocks,
48
48
  paperBodies,
49
- unreadLines,
50
49
  } from "./paper-includes.ts";
51
50
  import { prepareEngine } from "./build-engine.ts";
52
51
  import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
53
52
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
53
+ import { bibReader } from "./adapters/bibtex/index.ts";
54
54
  import { curlDownload } from "./adapters/curl/index.ts";
55
55
  import { latexReader } from "./adapters/latex/index.ts";
56
56
  import {
@@ -93,6 +93,12 @@ import {
93
93
  type PaperSettings,
94
94
  } from "./paper-settings.ts";
95
95
  import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
96
+ import { SOURCES_RULE_LEVELS, sourcesRules } from "./sources-rules.ts";
97
+ import { sourcesRecorder } from "./sources-record.ts";
98
+ import { sourcesCodec } from "./adapters/sources-record/index.ts";
99
+ import { texOutput } from "./adapters/tex-output/index.ts";
100
+ import { BIB_RULE_LEVELS, bibRules } from "./bib-rules.ts";
101
+ import { bibliographyReader } from "./references.ts";
96
102
  import { onlineReferences } from "./adapters/references/index.ts";
97
103
  import { hotcrpPortal } from "./adapters/hotcrp/index.ts";
98
104
  import { runSubmission } from "./submission.ts";
@@ -166,7 +172,6 @@ import {
166
172
  } from "./folder-venue-rule.ts";
167
173
  import typography from "#eslint-rules/paper-typography";
168
174
  import texBuild from "#eslint-rules/tex-build";
169
- import bibReachable from "#eslint-rules/bib-reachable-entry";
170
175
  import reviewFrontmatter from "#eslint-rules/review-frontmatter";
171
176
  import siblingFrontmatter from "#eslint-rules/sibling-frontmatter";
172
177
  import pdfRules from "#eslint-rules/pdf-last-page-balance";
@@ -302,11 +307,17 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
302
307
  unknown key, in either file, is an error.
303
308
  `;
304
309
 
305
- /** What the rules over a paper.tex read with: the disk, the shipped presets, the LaTeX reader. */
310
+ /** What reads the build's record of a paper: the disk, and the schema its text is parsed with. */
311
+ const RECORD_DEPS = { files: nodeFiles, codec: sourcesCodec };
312
+
313
+ /** What reads the bibliography the last build recorded: the record, and the readers of its databases and blocks. */
314
+ const RECORDED_DEPS = { ...RECORD_DEPS, latex: latexReader, bib: bibReader };
315
+
316
+ /** What the rules over a paper.tex read with: the paper's disk and LaTeX reader, the build's record, and the shipped presets. */
306
317
  const TEX_RULE_DEPS = {
307
- files: nodeFiles,
308
- venuesDir: presetsDir(),
318
+ ...RECORD_DEPS,
309
319
  latex: latexReader,
320
+ venuesDir: presetsDir(),
310
321
  };
311
322
 
312
323
  /** The register band rules and `tex/heading-case`: the rules of the `tex` plugin that read a paper's words. */
@@ -354,7 +365,8 @@ export function buildConfig(
354
365
  // The reference rules judge `_build/references.json`, and only on `paper.tex`.
355
366
  const texPaperRules = {
356
367
  ...paperRules,
357
- ...referenceRules({ files: nodeFiles }),
368
+ ...referenceRules(RECORDED_DEPS),
369
+ ...sourcesRules(RECORD_DEPS),
358
370
  };
359
371
  // Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
360
372
  const prose = {
@@ -445,7 +457,7 @@ export function buildConfig(
445
457
  },
446
458
  },
447
459
  paper: { rules: texPaperRules },
448
- bib: bibReachable,
460
+ bib: { rules: bibRules(RECORDED_DEPS) },
449
461
  talk: { rules: talkRules(TALK_RULE_DEPS) },
450
462
  format: {
451
463
  rules: {
@@ -459,8 +471,9 @@ export function buildConfig(
459
471
  rules: {
460
472
  ...prose,
461
473
  ...REFERENCE_RULE_LEVELS,
474
+ ...SOURCES_RULE_LEVELS,
462
475
  "paper/figure-ref-style": "warn",
463
- "bib/reachable-entry": "warn",
476
+ ...BIB_RULE_LEVELS,
464
477
  "tex/future-promise": "warn",
465
478
  // A number with no owner: a prose rule, on beside the others above.
466
479
  "tex/claim-provenance": "warn",
@@ -1443,6 +1456,12 @@ async function runBuild(
1443
1456
  dryRun: a.dryRun,
1444
1457
  log,
1445
1458
  checkReferences,
1459
+ readBibliography: bibliographyReader(RECORDED_DEPS),
1460
+ recordSources: sourcesRecorder({
1461
+ files: nodeFiles,
1462
+ listDir: nodeListDir,
1463
+ texOutput,
1464
+ }),
1446
1465
  engine: () => engineEnv(targets, a, { log, err }),
1447
1466
  });
1448
1467
  if (out.kind === "no-engine") return 1;
@@ -1656,10 +1675,17 @@ export async function run(
1656
1675
  err = console.error,
1657
1676
  cwd = process.cwd(),
1658
1677
  checkReferences = onlineReferences,
1678
+ loadTexLanguage = async () =>
1679
+ (await import("#eslint-rules/latex-language")).texLanguage,
1659
1680
  }: {
1660
1681
  log?: typeof console.log;
1661
1682
  err?: typeof console.error;
1662
1683
  cwd?: string;
1684
+ /**
1685
+ * `lint`'s LaTeX language, loaded lazily because only `lint` needs it. A test hands one that
1686
+ * fails, as a broken install would.
1687
+ */
1688
+ loadTexLanguage?: () => Promise<unknown>;
1663
1689
  /**
1664
1690
  * `build`'s online reference check — the real services by default. The e2e build passes a
1665
1691
  * fake: no test depends on Crossref, Semantic Scholar or DBLP answering.
@@ -1765,31 +1791,14 @@ export async function run(
1765
1791
  // Loaded lazily because only `lint` needs it. If the module cannot be loaded (a broken install:
1766
1792
  // the file missing from the package), lint still runs over the scorecards and the review notes
1767
1793
  // rather than failing outright — the LaTeX rules simply have no language to run in.
1768
- let texLanguage: unknown = null;
1769
- try {
1770
- ({ texLanguage } = await import("#eslint-rules/latex-language"));
1771
- } catch {
1772
- texLanguage = null;
1773
- }
1794
+ const texLanguage: unknown = await loadTexLanguage().catch(() => null);
1774
1795
 
1775
- // Each paper's body files, resolved the way the build resolves them — only when the LaTeX language
1796
+ // Each paper's body files, as the last build recorded TeX reading them — only when the LaTeX language
1776
1797
  // loaded, since only its rules read them. The project's papers too: `paperlint lint .` reaches
1777
1798
  // papers two levels down, which a directory's immediate papers do not.
1778
1799
  const bodies = texLanguage
1779
- ? paperBodies(paperDirs([...paths, ...papersRoots(cfg)]), {
1780
- files: nodeFiles,
1781
- latex: latexReader,
1782
- })
1800
+ ? paperBodies(paperDirs([...paths, ...papersRoots(cfg)]), RECORD_DEPS)
1783
1801
  : [];
1784
- // Named only for the papers this run lints: a paper elsewhere in the project is not this run's.
1785
- const inScope = (dir: string) =>
1786
- paths.some((p) => dir === p || dir.startsWith(`${p}${sep}`));
1787
- unreadLines(
1788
- bodies.filter((b) => inScope(b.dir)),
1789
- (p) => shown(cwd, p),
1790
- ).forEach((l) => {
1791
- err(l);
1792
- });
1793
1802
 
1794
1803
  const eslint = new ESLint({
1795
1804
  cwd: lintRoot(root, paths),
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The paper as one text: includes spliced where they stand, every character mapped back to its
3
- * file, a missing file reported, a subfile's body only, and a file that includes itself entered once.
3
+ * file, a file with nothing behind it left out, a subfile's body only, and a file that includes itself entered once.
4
4
  * The reader is a stand-in that finds `\input{…}` by position, so these tests are about assembling,
5
5
  * not about parsing LaTeX (the LaTeX adapter's own tests cover `includes`).
6
6
  */
@@ -41,8 +41,7 @@ describe("assemblePaper — the text TeX reads", () => {
41
41
  main,
42
42
  deps({ "s/one.tex": "one", "two.tex": "two" }),
43
43
  );
44
- expect(p.text).toBe("AoneBtwoC");
45
- expect(p.missing).toEqual([]);
44
+ expect(p.text).toBe("Aone\nBtwo\nC");
46
45
  expect([...new Set(p.segments.map((g) => g.file))]).toEqual([
47
46
  "paper.tex",
48
47
  "s/one.tex",
@@ -57,7 +56,7 @@ describe("assemblePaper — the text TeX reads", () => {
57
56
  main,
58
57
  deps({ "a.tex": "[\\input{s/b}]", "s/b.tex": "BB" }),
59
58
  );
60
- expect(p.text).toBe("x[BB]y");
59
+ expect(p.text).toBe("x[BB\n]\ny");
61
60
  expect(originOf(p, { start: 2, end: 4 })).toEqual({
62
61
  file: "s/b.tex",
63
62
  source: "BB",
@@ -66,18 +65,10 @@ describe("assemblePaper — the text TeX reads", () => {
66
65
  });
67
66
  });
68
67
 
69
- it("🔴 a missing file is reported where it stands, and left out", () => {
70
- const main = "a\\input{gone}b";
71
- const p = assemblePaper("paper.tex", main, deps({}));
68
+ it("a file the reader has none of contributes nothing", () => {
69
+ const p = assemblePaper("paper.tex", "a\\input{gone}b", deps({}));
72
70
  expect(p.text).toBe("ab");
73
- expect(p.missing).toEqual([
74
- {
75
- file: "paper.tex",
76
- target: "gone",
77
- span: { start: 1, end: 13 },
78
- via: { start: 1, end: 13 },
79
- },
80
- ]);
71
+ expect(p.segments.map((g) => g.file)).toEqual(["paper.tex", "paper.tex"]);
81
72
  });
82
73
  });
83
74
 
@@ -99,7 +90,7 @@ describe("assemblePaper — subfiles and a file that includes itself", () => {
99
90
  "<\\subfile{s}>",
100
91
  deps({ "s.tex": "ALL" }),
101
92
  );
102
- expect(p.text).toBe("<ALL>");
93
+ expect(p.text).toBe("<ALL\n>");
103
94
  });
104
95
 
105
96
  it("a file that includes itself is entered once", () => {
@@ -108,7 +99,54 @@ describe("assemblePaper — subfiles and a file that includes itself", () => {
108
99
  "m\\input{a}",
109
100
  deps({ "a.tex": "a\\input{a}\\input{paper.tex}" }),
110
101
  );
111
- expect(p.text).toBe("ma");
102
+ expect(p.text).toBe("ma\n");
103
+ });
104
+ });
105
+
106
+ describe("assemblePaper — TeX's end of line after the last line of an included file", () => {
107
+ // Measured with TeX Live 2026: `a\input f b` with f = `foo`, no final newline, typesets «afoo b» —
108
+ // the file's last line is ended like any other; `f` = `bar` plus a newline typesets the same; an
109
+ // empty file reads no line and adds nothing.
110
+ it("a file whose last line has no newline is ended, so what follows it is a new word", () => {
111
+ const p = assemblePaper(
112
+ "paper.tex",
113
+ "a\\input{f}b",
114
+ deps({ "f.tex": "foo" }),
115
+ );
116
+ expect(p.text).toBe("afoo\nb");
117
+ });
118
+
119
+ it("a file that ends with a newline gets no second one", () => {
120
+ const p = assemblePaper(
121
+ "paper.tex",
122
+ "a\\input{f}b",
123
+ deps({ "f.tex": "foo\n" }),
124
+ );
125
+ expect(p.text).toBe("afoo\nb");
126
+ });
127
+
128
+ it("an empty file reads no line: it adds nothing", () => {
129
+ const p = assemblePaper("paper.tex", "a\\input{f}b", deps({ "f.tex": "" }));
130
+ expect(p.text).toBe("ab");
131
+ });
132
+
133
+ it("the main file is not ended: its text is the caller's, as given", () => {
134
+ const p = assemblePaper("paper.tex", "no newline", deps({}));
135
+ expect(p.text).toBe("no newline");
136
+ });
137
+
138
+ it("the end of line belongs to the file it ends, at the end of that file", () => {
139
+ const p = assemblePaper(
140
+ "paper.tex",
141
+ "a\\input{f}b",
142
+ deps({ "f.tex": "foo" }),
143
+ );
144
+ expect(originOf(p, { start: 4, end: 5 })).toEqual({
145
+ file: "f.tex",
146
+ source: "foo",
147
+ span: { start: 3, end: 4 },
148
+ via: { start: 1, end: 10 },
149
+ });
112
150
  });
113
151
  });
114
152
 
@@ -129,7 +167,7 @@ describe("originOf — where a span of the assembled text comes from", () => {
129
167
  });
130
168
 
131
169
  it("after the include: offsets in the main file past the macro", () => {
132
- expect(originOf(p, { start: 5, end: 7 })).toEqual({
170
+ expect(originOf(p, { start: 6, end: 8 })).toEqual({
133
171
  file: "paper.tex",
134
172
  source: "ab\\input{x}cd",
135
173
  span: { start: 11, end: 13 },
@@ -147,10 +185,10 @@ describe("originOf — where a span of the assembled text comes from", () => {
147
185
  });
148
186
 
149
187
  it("an empty span at the very end belongs to the last stretch; outside the text there is none", () => {
150
- expect(originOf(p, { start: 7, end: 7 })?.span).toEqual({
188
+ expect(originOf(p, { start: 8, end: 8 })?.span).toEqual({
151
189
  start: 13,
152
190
  end: 13,
153
191
  });
154
- expect(originOf(p, { start: 9, end: 9 })).toBe(null);
192
+ expect(originOf(p, { start: 10, end: 10 })).toBe(null);
155
193
  });
156
194
  });
@@ -9,12 +9,19 @@
9
9
  * answers about one file (the class, the outline, the rendered text, the body's passages) is
10
10
  * answered about the paper, unchanged. A subfile is a document of its own; only its body is spliced.
11
11
  *
12
+ * ── THE END OF AN INCLUDED FILE'S LAST LINE ─────────────────────────────────────
13
+ * TeX ends every line it reads with its end-of-line character, the last line of a file included:
14
+ * `a\input{f}b` with `f` holding `foo` and no final newline typesets "afoo b" (measured, TeX Live
15
+ * 2026), not "afoob". The splice does the same: an included file whose text does not end in a newline
16
+ * gets one, mapped to the end of that file; an empty file read no line and gets nothing. The main file
17
+ * is the caller's text and is left as given.
18
+ *
12
19
  * ── WHAT IS NOT DONE ─────────────────────────────────────────────────────────────
13
20
  * No macro is expanded, so a path built from a macro (`\input{\dir/intro}`) is read as written. A
14
- * file that cannot be found is left out and named in `missing`; a file that includes itself,
15
- * directly or through others, is spliced once.
21
+ * file the caller's `read` has none of contributes nothing; a file that includes itself, directly or
22
+ * through others, is spliced once.
16
23
  */
17
- import { extname, normalize } from "node:path";
24
+ import { posix } from "node:path";
18
25
  import type { Span } from "./tex-document.ts";
19
26
 
20
27
  /** One `\input`, `\include` or `\subfile` in a source: the path as written and where the macro stands. */
@@ -46,27 +53,17 @@ export interface Segment {
46
53
  readonly via: Span | null;
47
54
  }
48
55
 
49
- /** An include whose file was not found, where it stands. */
50
- export interface MissingInclude {
51
- readonly file: string;
52
- readonly target: string;
53
- readonly span: Span;
54
- /** Where it stands in the main file (the include itself, or the one that brought its file in). */
55
- readonly via: Span;
56
- }
57
-
58
- /** The paper as one text, the map back to its files, and the includes that were not found. */
56
+ /** The paper as one text, and the map back to its files. */
59
57
  export interface PaperSource {
60
58
  readonly main: string;
61
59
  readonly text: string;
62
60
  readonly segments: readonly Segment[];
63
- readonly missing: readonly MissingInclude[];
64
61
  }
65
62
 
66
63
  /** The paths TeX tries for a target: `name.tex`, then `name` — or the name alone if it has one. */
67
64
  const candidates = (target: string): readonly string[] => {
68
- const t = normalize(target.trim());
69
- return extname(t) === "" ? [`${t}.tex`, t] : [t];
65
+ const t = posix.normalize(target.trim());
66
+ return posix.extname(t) === "" ? [`${t}.tex`, t] : [t];
70
67
  };
71
68
 
72
69
  /** The first candidate path that exists, with its text. */
@@ -81,11 +78,10 @@ function found(
81
78
  return hits[0] ?? null;
82
79
  }
83
80
 
84
- /** An assembled piece of text: the text, its segments from 0, and the includes not found. */
81
+ /** An assembled piece of text: the text and its segments from 0. */
85
82
  interface Piece {
86
83
  readonly text: string;
87
84
  readonly segments: readonly Segment[];
88
- readonly missing: readonly MissingInclude[];
89
85
  }
90
86
 
91
87
  /** `segments` moved by `by` characters. */
@@ -111,11 +107,10 @@ const copied = ({ file, text, from, to, via }: Stretch): Piece => ({
111
107
  to > from
112
108
  ? [{ start: 0, end: to - from, file, source: text, from, via }]
113
109
  : [],
114
- missing: [],
115
110
  });
116
111
 
117
112
  /** Nothing: an include left out. */
118
- const NOTHING: Piece = { text: "", segments: [], missing: [] };
113
+ const NOTHING: Piece = { text: "", segments: [] };
119
114
 
120
115
  /** Pieces joined in order. */
121
116
  const joined = (pieces: readonly Piece[]): Piece =>
@@ -123,7 +118,6 @@ const joined = (pieces: readonly Piece[]): Piece =>
123
118
  (acc, p) => ({
124
119
  text: acc.text + p.text,
125
120
  segments: [...acc.segments, ...shifted(p.segments, acc.text.length)],
126
- missing: [...acc.missing, ...p.missing],
127
121
  }),
128
122
  NOTHING,
129
123
  );
@@ -153,7 +147,7 @@ function spliced(file: string, text: string, range: Span, ctx: Context): Piece {
153
147
  pieces: [
154
148
  ...acc.pieces,
155
149
  at(acc.cursor, inc.span.start),
156
- included(file, inc, ctx),
150
+ included(inc, ctx),
157
151
  ],
158
152
  cursor: inc.span.end,
159
153
  }),
@@ -162,23 +156,30 @@ function spliced(file: string, text: string, range: Span, ctx: Context): Piece {
162
156
  return joined([...pieces, at(cursor, range.end)]);
163
157
  }
164
158
 
165
- /** One include: the file it names, spliced, or — when not found — nothing, and a report. */
166
- function included(file: string, inc: Include, ctx: Context): Piece {
159
+ /** TeX's end of line after the last line of `file`, standing where its text ends: one newline. */
160
+ const endOfLine = (file: string, text: string, via: Span | null): Piece => ({
161
+ text: "\n",
162
+ segments: [{ start: 0, end: 1, file, source: text, from: text.length, via }],
163
+ });
164
+
165
+ /** One include: the file it names, spliced, or — when the reader has none of it — nothing. */
166
+ function included(inc: Include, ctx: Context): Piece {
167
167
  const via = ctx.via ?? inc.span;
168
168
  const hit = found(inc.target, ctx.deps.read);
169
- if (hit === null)
170
- return {
171
- ...NOTHING,
172
- missing: [{ file, target: inc.target, span: inc.span, via }],
173
- };
174
- if (ctx.stack.includes(hit.path)) return NOTHING;
169
+ if (hit === null || ctx.stack.includes(hit.path)) return NOTHING;
175
170
  const body = inc.macro === "subfile" ? ctx.deps.documentBody(hit.text) : null;
176
- return spliced(
177
- hit.path,
178
- hit.text,
179
- body ?? { start: 0, end: hit.text.length },
180
- { deps: ctx.deps, stack: [...ctx.stack, hit.path], via },
181
- );
171
+ const range = body ?? { start: 0, end: hit.text.length };
172
+ const piece = spliced(hit.path, hit.text, range, {
173
+ deps: ctx.deps,
174
+ stack: [...ctx.stack, hit.path],
175
+ via,
176
+ });
177
+ // The last line of the file is read to its end; a subfile's body ends inside its last line.
178
+ const endsLine =
179
+ range.end === hit.text.length &&
180
+ hit.text !== "" &&
181
+ !hit.text.endsWith("\n");
182
+ return endsLine ? joined([piece, endOfLine(hit.path, hit.text, via)]) : piece;
182
183
  }
183
184
 
184
185
  /** The paper whose main file is `main` (a path relative to the paper directory) with `text`. */
@@ -193,12 +194,7 @@ export function assemblePaper(
193
194
  { start: 0, end: text.length },
194
195
  { deps, stack: [main], via: null },
195
196
  );
196
- return {
197
- main,
198
- text: p.text,
199
- segments: p.segments,
200
- missing: p.missing,
201
- };
197
+ return { main, text: p.text, segments: p.segments };
202
198
  }
203
199
 
204
200
  /** Where a span of the assembled text stands: in which file, at which offsets, and via which include. */