paperlint 7.2.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 (474) 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 +41 -30
  57. package/dist/build.js.map +1 -1
  58. package/dist/claim-provenance.d.ts +2 -2
  59. package/dist/cli.d.ts +9 -5
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +43 -68
  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/latex-language.d.ts.map +1 -1
  80. package/dist/eslint-rules/latex-language.js +10 -13
  81. package/dist/eslint-rules/latex-language.js.map +1 -1
  82. package/dist/eslint-rules/paper-context.d.ts.map +1 -1
  83. package/dist/eslint-rules/paper-context.js +6 -5
  84. package/dist/eslint-rules/paper-context.js.map +1 -1
  85. package/dist/eslint-rules/paper-typography.d.ts +0 -9
  86. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  87. package/dist/eslint-rules/paper-typography.js +34 -96
  88. package/dist/eslint-rules/paper-typography.js.map +1 -1
  89. package/dist/eslint-rules/papers.d.ts +1 -3
  90. package/dist/eslint-rules/papers.d.ts.map +1 -1
  91. package/dist/eslint-rules/papers.js +2 -4
  92. package/dist/eslint-rules/papers.js.map +1 -1
  93. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  94. package/dist/eslint-rules/tex-build.js +8 -25
  95. package/dist/eslint-rules/tex-build.js.map +1 -1
  96. package/dist/init.d.ts +1 -4
  97. package/dist/init.d.ts.map +1 -1
  98. package/dist/init.js +3 -9
  99. package/dist/init.js.map +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/markdown.d.ts +5 -81
  108. package/dist/lib/markdown.d.ts.map +1 -1
  109. package/dist/lib/markdown.js +5 -116
  110. package/dist/lib/markdown.js.map +1 -1
  111. package/dist/lib/paper-config.d.ts.map +1 -1
  112. package/dist/lib/paper-config.js +0 -2
  113. package/dist/lib/paper-config.js.map +1 -1
  114. package/dist/lib/skill-eval-fixture.d.ts +6 -4
  115. package/dist/lib/skill-eval-fixture.d.ts.map +1 -1
  116. package/dist/lib/skill-eval-fixture.js +25 -18
  117. package/dist/lib/skill-eval-fixture.js.map +1 -1
  118. package/dist/new-paper.d.ts +5 -11
  119. package/dist/new-paper.d.ts.map +1 -1
  120. package/dist/new-paper.js +12 -23
  121. package/dist/new-paper.js.map +1 -1
  122. package/dist/package-dirs.d.ts.map +1 -1
  123. package/dist/package-dirs.js +3 -2
  124. package/dist/package-dirs.js.map +1 -1
  125. package/dist/paper-files.d.ts +4 -4
  126. package/dist/paper-includes.d.ts +4 -7
  127. package/dist/paper-includes.d.ts.map +1 -1
  128. package/dist/paper-includes.js +29 -21
  129. package/dist/paper-includes.js.map +1 -1
  130. package/dist/paper-prose.d.ts +17 -0
  131. package/dist/paper-prose.d.ts.map +1 -0
  132. package/dist/paper-prose.js +46 -0
  133. package/dist/paper-prose.js.map +1 -0
  134. package/dist/paper-record.d.ts +25 -0
  135. package/dist/paper-record.d.ts.map +1 -0
  136. package/dist/paper-record.js +39 -0
  137. package/dist/paper-record.js.map +1 -0
  138. package/dist/paper-sources.d.ts +66 -0
  139. package/dist/paper-sources.d.ts.map +1 -0
  140. package/dist/paper-sources.js +92 -0
  141. package/dist/paper-sources.js.map +1 -0
  142. package/dist/ports/bib-reader.d.ts +18 -0
  143. package/dist/ports/bib-reader.d.ts.map +1 -0
  144. package/dist/ports/bib-reader.js +2 -0
  145. package/dist/ports/bib-reader.js.map +1 -0
  146. package/dist/ports/check-references.d.ts +9 -4
  147. package/dist/ports/check-references.d.ts.map +1 -1
  148. package/dist/ports/latex.d.ts +15 -0
  149. package/dist/ports/latex.d.ts.map +1 -1
  150. package/dist/ports/sources-codec.d.ts +11 -0
  151. package/dist/ports/sources-codec.d.ts.map +1 -0
  152. package/dist/ports/sources-codec.js +2 -0
  153. package/dist/ports/sources-codec.js.map +1 -0
  154. package/dist/ports/tex-output.d.ts +17 -0
  155. package/dist/ports/tex-output.d.ts.map +1 -0
  156. package/dist/ports/tex-output.js +2 -0
  157. package/dist/ports/tex-output.js.map +1 -0
  158. package/dist/recorded-bibliography.d.ts +89 -0
  159. package/dist/recorded-bibliography.d.ts.map +1 -0
  160. package/dist/recorded-bibliography.js +136 -0
  161. package/dist/recorded-bibliography.js.map +1 -0
  162. package/dist/reference-rules.d.ts +5 -5
  163. package/dist/reference-rules.d.ts.map +1 -1
  164. package/dist/reference-rules.js +150 -77
  165. package/dist/reference-rules.js.map +1 -1
  166. package/dist/references.d.ts +71 -16
  167. package/dist/references.d.ts.map +1 -1
  168. package/dist/references.js +110 -26
  169. package/dist/references.js.map +1 -1
  170. package/dist/register-bands.d.ts +3 -3
  171. package/dist/register-bands.d.ts.map +1 -1
  172. package/dist/register-bands.js.map +1 -1
  173. package/dist/register.d.ts +2 -2
  174. package/dist/sources-record.d.ts +38 -0
  175. package/dist/sources-record.d.ts.map +1 -0
  176. package/dist/sources-record.js +123 -0
  177. package/dist/sources-record.js.map +1 -0
  178. package/dist/sources-rules.d.ts +44 -0
  179. package/dist/sources-rules.d.ts.map +1 -0
  180. package/dist/sources-rules.js +76 -0
  181. package/dist/sources-rules.js.map +1 -0
  182. package/dist/structure.d.ts.map +1 -1
  183. package/dist/structure.js +4 -3
  184. package/dist/structure.js.map +1 -1
  185. package/dist/tex-paper.d.ts +10 -15
  186. package/dist/tex-paper.d.ts.map +1 -1
  187. package/dist/tex-paper.js +24 -40
  188. package/dist/tex-paper.js.map +1 -1
  189. package/dist/tex-venue-rules.d.ts +3 -3
  190. package/dist/tex-venue-rules.d.ts.map +1 -1
  191. package/dist/tex-venue-rules.js.map +1 -1
  192. package/dist/types.d.ts +0 -2
  193. package/dist/types.d.ts.map +1 -1
  194. package/docs/configuration.md +18 -12
  195. package/docs/design/paper-sources.md +197 -0
  196. package/docs/e2e.md +4 -6
  197. package/docs/package-shape-options.md +1 -1
  198. package/docs/prior-art/paper-folder-scaffolding.md +24 -29
  199. package/docs/references.md +34 -8
  200. package/docs/rules/bib/reachable-entry.md +64 -0
  201. package/docs/rules/paper/sources-fresh.md +81 -0
  202. package/docs/rules/tex/claim-provenance.md +0 -1
  203. package/docs/rules.md +62 -61
  204. package/eslint-rules/latex-language.harness.mjs +3 -3
  205. package/eslint-rules/latex-language.ts +10 -13
  206. package/eslint-rules/paper-context.ts +6 -5
  207. package/eslint-rules/paper-typography.test.mjs +29 -157
  208. package/eslint-rules/paper-typography.ts +35 -104
  209. package/eslint-rules/papers.harness.mjs +1 -1
  210. package/eslint-rules/papers.ts +2 -4
  211. package/eslint-rules/tex-build.harness.mjs +2 -2
  212. package/eslint-rules/tex-build.ts +8 -25
  213. package/eslint.config.mjs +22 -1
  214. package/fixtures/build-e2e/aidc/paper.tex +1 -1
  215. package/fixtures/build-e2e/anon-ack/paper.tex +1 -1
  216. package/fixtures/build-e2e/anon-author/paper.tex +1 -1
  217. package/fixtures/build-e2e/anon-bib/paper.tex +1 -1
  218. package/fixtures/build-e2e/anon-href/paper.tex +1 -1
  219. package/fixtures/build-e2e/anon-macro/paper.tex +1 -1
  220. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +1 -1
  221. package/fixtures/paper-sources/p1/paper.tex +30 -0
  222. package/fixtures/paper-sources/p1/refs.bib +6 -0
  223. package/fixtures/paper-sources/p1/sections/dead.tex +6 -0
  224. package/fixtures/paper-sources/p1/sections/intro.tex +7 -0
  225. package/fixtures/paper-sources/p1/sections/parked.tex +6 -0
  226. package/fixtures/paper-sources/p1/tex-truth.json +37 -0
  227. package/fixtures/paper-sources/p1/versions/2026-01-01-review.tex +4 -0
  228. package/fixtures/paper-sources/v1-stale/paper.tex +14 -0
  229. package/fixtures/paper-sources/v1-stale/refs.bib +6 -0
  230. package/fixtures/paper-sources/v1-stale/tex-truth.json +32 -0
  231. package/fixtures/paper-sources/v10-ifthenelse/anon.bib +1 -0
  232. package/fixtures/paper-sources/v10-ifthenelse/paper.tex +9 -0
  233. package/fixtures/paper-sources/v10-ifthenelse/real.bib +1 -0
  234. package/fixtures/paper-sources/v10-ifthenelse/tex-truth.json +28 -0
  235. package/fixtures/paper-sources/v11-two-blocks/paper.tex +12 -0
  236. package/fixtures/paper-sources/v11-two-blocks/tex-truth.json +30 -0
  237. package/fixtures/paper-sources/v13-percent-text/paper.tex +6 -0
  238. package/fixtures/paper-sources/v13-percent-text/refs.bib +3 -0
  239. package/fixtures/paper-sources/v13-percent-text/tex-truth.json +30 -0
  240. package/fixtures/paper-sources/v15-after-end/paper.tex +6 -0
  241. package/fixtures/paper-sources/v15-after-end/refs.bib +1 -0
  242. package/fixtures/paper-sources/v15-after-end/tex-truth.json +17 -0
  243. package/fixtures/paper-sources/v16-included-block/bibblock.tex +3 -0
  244. package/fixtures/paper-sources/v16-included-block/paper.tex +7 -0
  245. package/fixtures/paper-sources/v16-included-block/refs.bib +1 -0
  246. package/fixtures/paper-sources/v16-included-block/tex-truth.json +35 -0
  247. package/fixtures/paper-sources/v17-include-order/bibsetup.tex +1 -0
  248. package/fixtures/paper-sources/v17-include-order/first.bib +1 -0
  249. package/fixtures/paper-sources/v17-include-order/later.bib +1 -0
  250. package/fixtures/paper-sources/v17-include-order/paper.tex +7 -0
  251. package/fixtures/paper-sources/v17-include-order/tex-truth.json +40 -0
  252. package/fixtures/paper-sources/v2-overwrite/paper.tex +14 -0
  253. package/fixtures/paper-sources/v2-overwrite/refs.bib +6 -0
  254. package/fixtures/paper-sources/v2-overwrite/tex-truth.json +30 -0
  255. package/fixtures/paper-sources/v22-parked-thebibliography/paper.tex +7 -0
  256. package/fixtures/paper-sources/v22-parked-thebibliography/tex-truth.json +17 -0
  257. package/fixtures/paper-sources/v23-parked-include/old.bib +1 -0
  258. package/fixtures/paper-sources/v23-parked-include/paper.tex +7 -0
  259. package/fixtures/paper-sources/v23-parked-include/parked.tex +1 -0
  260. package/fixtures/paper-sources/v23-parked-include/refs.bib +1 -0
  261. package/fixtures/paper-sources/v23-parked-include/tex-truth.json +28 -0
  262. package/fixtures/paper-sources/v24-quoted-fields/paper.tex +6 -0
  263. package/fixtures/paper-sources/v24-quoted-fields/refs.bib +5 -0
  264. package/fixtures/paper-sources/v24-quoted-fields/tex-truth.json +32 -0
  265. package/fixtures/paper-sources/v25-string-differs/paper.tex +10 -0
  266. package/fixtures/paper-sources/v25-string-differs/refs.bib +2 -0
  267. package/fixtures/paper-sources/v25-string-differs/tex-truth.json +32 -0
  268. package/fixtures/paper-sources/v26-comment-in-names/one.bib +1 -0
  269. package/fixtures/paper-sources/v26-comment-in-names/paper.tex +7 -0
  270. package/fixtures/paper-sources/v26-comment-in-names/tex-truth.json +32 -0
  271. package/fixtures/paper-sources/v26-comment-in-names/two.bib +1 -0
  272. package/fixtures/paper-sources/v27-shared-string/abbreviations.bib +2 -0
  273. package/fixtures/paper-sources/v27-shared-string/paper.tex +6 -0
  274. package/fixtures/paper-sources/v27-shared-string/references.bib +6 -0
  275. package/fixtures/paper-sources/v27-shared-string/tex-truth.json +31 -0
  276. package/fixtures/paper-sources/v28-shared-string-block/abbreviations.bib +2 -0
  277. package/fixtures/paper-sources/v28-shared-string-block/paper.tex +14 -0
  278. package/fixtures/paper-sources/v28-shared-string-block/tex-truth.json +33 -0
  279. package/fixtures/paper-sources/v29-generated-input/paper.tex +9 -0
  280. package/fixtures/paper-sources/v29-generated-input/tex-truth.json +24 -0
  281. package/fixtures/paper-sources/v3-declared/paper.bib +6 -0
  282. package/fixtures/paper-sources/v3-declared/paper.tex +6 -0
  283. package/fixtures/paper-sources/v3-declared/tex-truth.json +28 -0
  284. package/fixtures/paper-sources/v30-name-next-line/paper.tex +14 -0
  285. package/fixtures/paper-sources/v30-name-next-line/tex-truth.json +30 -0
  286. package/fixtures/paper-sources/v4-commented/paper.tex +14 -0
  287. package/fixtures/paper-sources/v4-commented/refs.bib +6 -0
  288. package/fixtures/paper-sources/v4-commented/tex-truth.json +28 -0
  289. package/fixtures/paper-sources/v5-percent-entry/paper.tex +20 -0
  290. package/fixtures/paper-sources/v5-percent-entry/tex-truth.json +37 -0
  291. package/fixtures/paper-sources/v6-unclosed/paper.tex +6 -0
  292. package/fixtures/paper-sources/v6-unclosed/refs.bib +5 -0
  293. package/fixtures/paper-sources/v6-unclosed/tex-truth.json +37 -0
  294. package/fixtures/paper-sources/v8-jobname/paper.tex +9 -0
  295. package/fixtures/paper-sources/v8-jobname/tex-truth.json +30 -0
  296. package/fixtures/paper-sources/v9-two-bibliographies/one.bib +1 -0
  297. package/fixtures/paper-sources/v9-two-bibliographies/paper.tex +7 -0
  298. package/fixtures/paper-sources/v9-two-bibliographies/tex-truth.json +35 -0
  299. package/fixtures/paper-sources/v9-two-bibliographies/two.bib +1 -0
  300. package/fixtures/paper-typography/clean-paper/paper.tex +1 -1
  301. package/fixtures/paper-typography/messy-paper/paper.tex +1 -1
  302. package/hooks/hooks.harness.ts +15 -15
  303. package/hooks/paper-edit-guard.hook.mjs +5 -8
  304. package/hooks/paper-edit-guard.test.ts +12 -3
  305. package/hooks/paper-skills-nudge.hook.mjs +2 -3
  306. package/hooks/paper-skills-nudge.test.ts +13 -3
  307. package/hooks/paper-status-gates.hook.mjs +2 -2
  308. package/hooks/paper-status-gates.test.ts +14 -2
  309. package/lib/markdown.test.mjs +5 -60
  310. package/lib/markdown.ts +9 -136
  311. package/lib/paper-config.ts +0 -2
  312. package/lib/skill-checks.test.mjs +4 -1
  313. package/lib/skill-eval-fixture.ts +25 -18
  314. package/package.json +2 -12
  315. package/presets/realm.jsonc +1 -1
  316. package/scripts/check.test.ts +23 -1
  317. package/scripts/check.ts +7 -3
  318. package/scripts/coverage-config.test.ts +19 -10
  319. package/scripts/harness-api.frozen.json +0 -5
  320. package/scripts/layer-legacy-frozen.test.ts +41 -2
  321. package/scripts/layer-legacy-frozen.ts +31 -2
  322. package/skills/cold-read-diff/SKILL.md +3 -3
  323. package/skills/cold-read-diff/SKILL.md.spec.ts +2 -2
  324. package/skills/grade-paper-writing/SKILL.md +15 -22
  325. package/skills/grade-paper-writing/SKILL.md.spec.ts +14 -21
  326. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +40 -26
  327. package/skills/grade-paper-writing/prose-lint.mjs +117 -278
  328. package/skills/grade-paper-writing/prose-lint.test.ts +230 -79
  329. package/skills/paper-adversarial-review/SKILL.md +2 -2
  330. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  331. package/skills/paper-pipeline/SKILL.md +9 -12
  332. package/skills/paper-pipeline/SKILL.md.spec.ts +8 -11
  333. package/skills/paper-pipeline/description-language.eval.mjs +25 -15
  334. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +25 -15
  335. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +25 -15
  336. package/skills/paper-pipeline/pipeline-firing.eval.mjs +27 -16
  337. package/skills/paper-pipeline/pipeline-language.eval.mjs +25 -15
  338. package/skills/paper-pipeline/references/acceptance-gate.md +16 -20
  339. package/skills/paper-pipeline/references/artifact-checklist.md +4 -5
  340. package/skills/paper-pipeline/references/pipeline-status-template.md +1 -1
  341. package/skills/paper-pipeline/scripts/README.md +0 -4
  342. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +91 -105
  343. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +85 -241
  344. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +166 -153
  345. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +3 -3
  346. package/skills/paper-status/SKILL.md +2 -2
  347. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  348. package/skills/render-paper/SKILL.md +2 -2
  349. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  350. package/skills/render-paper/extract-pdf-facts.mjs +2 -2
  351. package/skills/tighten-paper/SKILL.md +29 -42
  352. package/skills/tighten-paper/SKILL.md.spec.ts +28 -41
  353. package/skills/verify-citations/SKILL.md +7 -4
  354. package/skills/verify-citations/SKILL.md.spec.ts +6 -3
  355. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +17 -32
  356. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +138 -43
  357. package/skills/verify-citations/scripts/bib-authors.d.mts +22 -2
  358. package/skills/verify-citations/scripts/bib-authors.mjs +54 -193
  359. package/skills/verify-citations/scripts/bib-authors.test.mjs +1 -44
  360. package/skills/verify-citations/scripts/verify-cites.cli.test.ts +16 -0
  361. package/skills/verify-citations/scripts/verify-cites.d.mts +19 -3
  362. package/skills/verify-citations/scripts/verify-cites.mjs +42 -108
  363. package/skills/verify-citations/scripts/verify-cites.test.mjs +23 -36
  364. package/src/adapters/bibtex/index.test.ts +163 -0
  365. package/src/adapters/bibtex/index.ts +155 -0
  366. package/src/adapters/latex/filecontents.ts +114 -0
  367. package/src/adapters/latex/includes.ts +18 -10
  368. package/src/adapters/latex/index.test.ts +73 -0
  369. package/src/adapters/latex/index.ts +2 -0
  370. package/src/adapters/latex/nodes.ts +64 -0
  371. package/src/adapters/latex/parse.test.ts +38 -0
  372. package/src/adapters/latex/parse.ts +67 -6
  373. package/src/adapters/memory/index.ts +17 -0
  374. package/src/adapters/references/index.test.ts +75 -21
  375. package/src/adapters/references/index.ts +37 -32
  376. package/src/adapters/sources-record/index.test.ts +87 -0
  377. package/src/adapters/sources-record/index.ts +82 -0
  378. package/src/adapters/tex-output/blg.ts +27 -0
  379. package/src/adapters/tex-output/fls.ts +24 -0
  380. package/src/adapters/tex-output/index.test.ts +171 -0
  381. package/src/adapters/tex-output/index.ts +12 -0
  382. package/src/bib-rules.test.ts +356 -0
  383. package/src/bib-rules.ts +228 -0
  384. package/src/bibliography-at.test.ts +286 -0
  385. package/src/build.harness.mjs +2 -2
  386. package/src/build.test.ts +211 -1
  387. package/src/build.ts +101 -41
  388. package/src/claim-provenance.test.ts +33 -7
  389. package/src/claim-provenance.ts +2 -2
  390. package/src/cli.harness.mjs +46 -56
  391. package/src/cli.test.ts +44 -48
  392. package/src/cli.ts +51 -82
  393. package/src/domain/paper-source.test.ts +58 -20
  394. package/src/domain/paper-source.ts +38 -42
  395. package/src/domain/paper-sources.ts +94 -0
  396. package/src/domain/sources-record.test.ts +71 -0
  397. package/src/domain/sources-record.ts +136 -0
  398. package/src/domain/tex-run.test.ts +228 -0
  399. package/src/domain/tex-run.ts +139 -0
  400. package/src/folder-venue-rule.test.ts +1 -1
  401. package/src/heading-case.test.ts +24 -10
  402. package/src/init.test.ts +6 -7
  403. package/src/init.ts +3 -22
  404. package/src/latex-log.ts +20 -0
  405. package/src/latex-loop.harness.ts +1 -0
  406. package/src/latex-loop.ts +2 -0
  407. package/src/lint-fix.test.ts +10 -4
  408. package/src/linted-set.test.ts +9 -1
  409. package/src/new-paper.harness.mjs +12 -30
  410. package/src/new-paper.test.ts +15 -11
  411. package/src/new-paper.ts +11 -30
  412. package/src/package-dirs.ts +3 -2
  413. package/src/paper-files.test.ts +5 -4
  414. package/src/paper-files.ts +4 -4
  415. package/src/paper-includes.test.ts +157 -38
  416. package/src/paper-includes.ts +39 -35
  417. package/src/paper-prose.test.ts +78 -0
  418. package/src/paper-prose.ts +67 -0
  419. package/src/paper-record.test.ts +173 -0
  420. package/src/paper-record.ts +66 -0
  421. package/src/paper-settings-commands.test.ts +5 -2
  422. package/src/paper-sources.ts +151 -0
  423. package/src/parse-args.test.ts +0 -1
  424. package/src/ports/bib-reader.ts +27 -0
  425. package/src/ports/check-references.ts +7 -3
  426. package/src/ports/latex.ts +19 -0
  427. package/src/ports/sources-codec.ts +11 -0
  428. package/src/ports/tex-output.ts +17 -0
  429. package/src/recorded-bibliography.ts +297 -0
  430. package/src/reference-rules.ts +217 -104
  431. package/src/references.test.ts +520 -177
  432. package/src/references.ts +185 -37
  433. package/src/register-bands.test.ts +5 -1
  434. package/src/register-bands.ts +3 -5
  435. package/src/register.test.ts +6 -1
  436. package/src/register.ts +2 -2
  437. package/src/rule-cases.test.ts +68 -12
  438. package/src/rule-docs.test.ts +0 -1
  439. package/src/sources-record.test.ts +308 -0
  440. package/src/sources-record.ts +231 -0
  441. package/src/sources-rules.test.ts +152 -0
  442. package/src/sources-rules.ts +117 -0
  443. package/src/structure.harness.ts +5 -11
  444. package/src/structure.ts +4 -3
  445. package/src/tex-paper.test.ts +148 -78
  446. package/src/tex-paper.ts +31 -61
  447. package/src/tex-venue-rules.test.ts +36 -8
  448. package/src/tex-venue-rules.ts +3 -5
  449. package/src/types.ts +0 -2
  450. package/dist/eslint-rules/bib-reachable-entry.d.ts +0 -22
  451. package/dist/eslint-rules/bib-reachable-entry.d.ts.map +0 -1
  452. package/dist/eslint-rules/bib-reachable-entry.js +0 -75
  453. package/dist/eslint-rules/bib-reachable-entry.js.map +0 -1
  454. package/eslint-rules/bib-reachable-entry.ts +0 -76
  455. package/fixtures/real-markdown-paper/baseline.json +0 -22
  456. package/fixtures/real-markdown-paper/baseline.ts +0 -70
  457. package/skills/paper-pipeline/PIPELINE-MAP.md +0 -381
  458. package/skills/paper-pipeline/pipeline-firing.baseline.json +0 -270
  459. package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +0 -585
  460. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +0 -492
  461. package/skills/paper-pipeline/scripts/artifact-coverage.mjs +0 -397
  462. package/skills/paper-pipeline/scripts/artifact-coverage.test.ts +0 -86
  463. package/skills/paper-pipeline/scripts/check-provenance.mjs +0 -184
  464. package/skills/paper-pipeline/scripts/population-map.harness.mjs +0 -135
  465. package/skills/paper-pipeline/scripts/population-map.mjs +0 -188
  466. package/skills/paper-pipeline/scripts/population-map.test.mjs +0 -154
  467. package/skills/paper-pipeline/scripts/provenance.harness.mjs +0 -266
  468. package/skills/paper-pipeline/scripts/round-diff.harness.ts +0 -907
  469. package/skills/paper-pipeline/scripts/round-diff.mjs +0 -583
  470. package/skills/paper-pipeline/scripts/round-diff.test.mjs +0 -159
  471. package/skills/tighten-paper/__snapshots__/structure.test.ts.snap +0 -133
  472. package/skills/tighten-paper/structure.mjs +0 -364
  473. package/skills/tighten-paper/structure.test.ts +0 -117
  474. package/templates/paper/paper.md +0 -4
@@ -697,3 +697,76 @@ describe("bodyEmphasis — each style, and edge cases", () => {
697
697
  expect(e && src.slice(e.span.start, e.span.end)).toBe("\\textbf{see it}");
698
698
  });
699
699
  });
700
+
701
+ describe("latexReader.filecontents — the blocks that write a file, where they stand", () => {
702
+ const SRC =
703
+ "\\documentclass{x}\n" +
704
+ "\\begin{filecontents*}[overwrite]{refs.bib}\n@misc{a, title={A}}\n\\end{filecontents*}\n" +
705
+ "\\begin{document}\n" +
706
+ "\\begin{filecontents}{\\jobname.bib}\n@misc{b, title={B}}\n\\end{filecontents}\n" +
707
+ "\\end{document}\n" +
708
+ "\\begin{filecontents}{late.bib}\n@misc{c}\n\\end{filecontents}\n";
709
+
710
+ it("each live block, in order: the file it writes (\\jobname expanded), [overwrite], its span and body", () => {
711
+ const blocks = latexReader.filecontents(SRC, "paper");
712
+ expect(
713
+ blocks.map((b) => [
714
+ b.writes,
715
+ b.overwrite,
716
+ SRC.slice(b.body.start, b.body.end),
717
+ ]),
718
+ ).toEqual([
719
+ ["refs.bib", true, "@misc{a, title={A}}\n"],
720
+ ["paper.bib", false, "@misc{b, title={B}}\n"],
721
+ ]);
722
+ expect(SRC.slice(blocks[0]?.span.start, blocks[0]?.span.end)).toMatch(
723
+ /^\\begin\{filecontents\*\}.*\\end\{filecontents\*\}$/s,
724
+ );
725
+ });
726
+
727
+ it("a block inside a comment, or after \\end{document}, is not one TeX runs", () => {
728
+ expect(
729
+ latexReader.filecontents(
730
+ "% \\begin{filecontents}{x.bib}\n% @misc{z}\n% \\end{filecontents}\n\\begin{document}\n\\end{document}\n",
731
+ "paper",
732
+ ),
733
+ ).toEqual([]);
734
+ });
735
+
736
+ it("a block that names no file writes none: a group after its body's first text is no name, and a body may hold none", () => {
737
+ expect(
738
+ [
739
+ "\\begin{filecontents}\n@misc{z}\n\\end{filecontents}\n",
740
+ "\\begin{filecontents}\nplain text\n\\end{filecontents}\n",
741
+ ].flatMap((block) =>
742
+ latexReader.filecontents(
743
+ `${block}\\begin{document}\n\\end{document}\n`,
744
+ "paper",
745
+ ),
746
+ ),
747
+ ).toEqual([]);
748
+ });
749
+ });
750
+
751
+ describe("latexReader.filecontents — a file name on a line of its own", () => {
752
+ it("🔴 a name on the line after \\begin, the option before it or not: TeX takes it, and the body starts on the line after the name", () => {
753
+ // Measured with pdflatex: `\\begin{filecontents*}` then `{refs.bib}` on the next line writes
754
+ // refs.bib from the lines after the name (fixtures/paper-sources/v30-name-next-line).
755
+ const src =
756
+ "\\begin{filecontents*}\n{refs.bib}\n@misc{a, title={A}}\n\\end{filecontents*}\n" +
757
+ "\\begin{filecontents}[overwrite]\n {two.bib}\n@misc{b}\n\\end{filecontents}\n" +
758
+ "\\begin{document}\n\\end{document}\n";
759
+ expect(
760
+ latexReader
761
+ .filecontents(src, "paper")
762
+ .map((b) => [
763
+ b.writes,
764
+ b.overwrite,
765
+ src.slice(b.body.start, b.body.end),
766
+ ]),
767
+ ).toEqual([
768
+ ["refs.bib", false, "@misc{a, title={A}}\n"],
769
+ ["two.bib", true, "@misc{b}\n"],
770
+ ]);
771
+ });
772
+ });
@@ -33,6 +33,7 @@ import { parseLatex } from "./parse.ts";
33
33
  import { bodyEmphasis, bodyProse } from "./prose.ts";
34
34
  import { renderedRuns } from "./rendered.ts";
35
35
  import { layoutOverridesOf } from "./layout.ts";
36
+ import { filecontentsOf } from "./filecontents.ts";
36
37
 
37
38
  export {
38
39
  documentClassOf,
@@ -69,4 +70,5 @@ export const latexReader: LatexReader = {
69
70
  template: parseTemplate,
70
71
  withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
71
72
  layoutOverrides: (src) => layoutOverridesOf(parseLatex(src)),
73
+ filecontents: (src, jobname) => filecontentsOf(parseLatex(src), jobname),
72
74
  };
@@ -156,3 +156,67 @@ export const isArgument = (v: Visited): v is Argument =>
156
156
  /** A node, as opposed to a node list or an argument (which visit also reports). */
157
157
  export const isNode = (v: Visited): v is Node =>
158
158
  !isList(v) && v.type !== "argument" && v.type !== "root";
159
+
160
+ /** A piece of an argument as TeX reads it: text, or a macro this reader leaves as written (`\x`). */
161
+ export interface ArgumentPiece {
162
+ readonly text: string;
163
+ readonly macro: boolean;
164
+ }
165
+
166
+ /**
167
+ * An argument's nodes as TeX reads them, off the tree: a comment is no part of it, a group's braces
168
+ * are none, and white space is one space. `\jobname` is the main file's name when `jobname` is given;
169
+ * every other macro — `\jobnamebib` included, a control sequence of its own — is a piece of its own,
170
+ * written as it stands, and so is anything else (math, verbatim) a name cannot hold.
171
+ */
172
+ export function argumentPieces(
173
+ nodes: readonly Node[],
174
+ jobname: string | null,
175
+ ): readonly ArgumentPiece[] {
176
+ return nodes.flatMap((n) => pieceOf(n, jobname));
177
+ }
178
+
179
+ /** The nodes a name cannot hold as text: an environment, math, verbatim. */
180
+ type Nameless = Extract<
181
+ Node,
182
+ {
183
+ readonly type:
184
+ | "root"
185
+ | "environment"
186
+ | "mathenv"
187
+ | "inlinemath"
188
+ | "displaymath"
189
+ | "verb"
190
+ | "verbatim";
191
+ }
192
+ >;
193
+ const NAMELESS: ReadonlySet<string> = new Set([
194
+ "root",
195
+ "environment",
196
+ "mathenv",
197
+ "inlinemath",
198
+ "displaymath",
199
+ "verb",
200
+ "verbatim",
201
+ ]);
202
+ const isNameless = (n: Node): n is Nameless => NAMELESS.has(n.type);
203
+
204
+ /** One node of an argument as `argumentPieces` reads it. */
205
+ function pieceOf(n: Node, jobname: string | null): readonly ArgumentPiece[] {
206
+ if (isNameless(n)) return [{ text: `\\${n.type}`, macro: true }];
207
+ switch (n.type) {
208
+ case "comment":
209
+ return [];
210
+ case "string":
211
+ return [{ text: n.content, macro: false }];
212
+ case "whitespace":
213
+ case "parbreak":
214
+ return [{ text: " ", macro: false }];
215
+ case "group":
216
+ return argumentPieces(n.content, jobname);
217
+ case "macro":
218
+ return n.content === "jobname" && jobname !== null
219
+ ? [{ text: jobname, macro: false }]
220
+ : [{ text: `\\${n.content}`, macro: true }];
221
+ }
222
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `parseLatex` keeps the trees of a bounded amount of source text: an editor session must not hold a
3
+ * tree for every version of the paper it has seen.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { parseLatex } from "./index.ts";
7
+
8
+ describe("parseLatex's memo", () => {
9
+ /** A distinct source of `kib` KiB that parses quickly: one long comment. */
10
+ const source = (tag: string, kib: number) =>
11
+ `${tag}\n%${"x".repeat(kib * 1024)}`;
12
+
13
+ it("returns the same tree for the same text while it is kept", () => {
14
+ const s = source("same", 1);
15
+ expect(parseLatex(s)).toBe(parseLatex(s));
16
+ });
17
+
18
+ it("keeps up to 512 KiB of source: a paper's ten files stay, read in a cycle", () => {
19
+ const files = Array.from({ length: 11 }, (_, i) =>
20
+ source(`f${String(i)}`, 30),
21
+ );
22
+ const first = files.map((f) => parseLatex(f));
23
+ expect(files.map((f) => parseLatex(f))).toEqual(first);
24
+ expect(files.every((f, i) => parseLatex(f) === first[i])).toBe(true);
25
+ });
26
+
27
+ it("past 512 KiB the text used least recently goes first, a recently used one stays", () => {
28
+ const big = Array.from({ length: 6 }, (_, i) =>
29
+ source(`b${String(i)}`, 100),
30
+ );
31
+ const trees = big.slice(0, 5).map((f) => parseLatex(f));
32
+ // Touch the oldest: it becomes the most recent, so the second is the one evicted.
33
+ expect(parseLatex(big[0] ?? "")).toBe(trees[0]);
34
+ parseLatex(big[5] ?? "");
35
+ expect(parseLatex(big[0] ?? "")).toBe(trees[0]);
36
+ expect(parseLatex(big[1] ?? "")).not.toBe(trees[1]);
37
+ });
38
+ });
@@ -59,10 +59,13 @@ export const KEY_SIGNATURES: Readonly<
59
59
  "subfile",
60
60
  "bibliography",
61
61
  "bibliographystyle",
62
- "addbibresource",
63
62
  ],
64
63
  "m",
65
64
  ),
65
+ // biblatex's resources take options (`[location=remote]`) before the name.
66
+ ...signed(["addbibresource", "addglobalbib", "addsectionbib"], "o m"),
67
+ // bibentry's: writes `\bibdata` as `\bibliography` does, and typesets nothing.
68
+ ...signed(["nobibliography"], "m"),
66
69
  ...signed(["includegraphics"], "s o o m"),
67
70
  ...signed(["usepackage", "RequirePackage", "documentclass"], "o m o"),
68
71
  ]);
@@ -134,9 +137,67 @@ const parser = once(() =>
134
137
  }),
135
138
  );
136
139
 
140
+ /**
141
+ * The trees parsed last, by their source: a tree is a pure function of its text, and one lint of a
142
+ * paper asks for the same texts many times — every rule that reads the paper (`readPaper`)
143
+ * parses `paper.tex`, its includes and the assembled whole again. Measured 2026-10-06 on the
144
+ * accepted ACM paper of the corpus: 0.9 s per reading of the paper's sources, seven of them per lint,
145
+ * 50–76 ms with the memo.
146
+ *
147
+ * Bounded by the SOURCE TEXT it holds (UTF-16 units, about bytes for TeX), least recently used out.
148
+ * A count bound is wrong both ways: 64 trees held 64 versions of one file in an editor (186 MiB for a
149
+ * 48 KiB paper), and 8 trees thrashed on a paper with ten includes — the rules read them in a cycle,
150
+ * so every read missed and the corpus lint timed out. `MEMO_BYTES` holds the largest paper of the corpus
151
+ * (150 KB of sources, and the assembled whole) and about ten versions of a 48 KiB file.
152
+ */
153
+ const MEMO_BYTES = 512 * 1024;
154
+ const memo = new Map<string, ParsedTex>();
155
+
156
+ /** The texts to evict, least recent first, so that `adding` more fits under `MEMO_BYTES`. */
157
+ const evicted = (adding: number): readonly string[] => {
158
+ const keys = [...memo.keys()];
159
+ const total = keys.reduce((n, k) => n + k.length, adding);
160
+ const { out } = keys.reduce<{
161
+ readonly left: number;
162
+ readonly out: readonly string[];
163
+ }>(
164
+ (acc, k) =>
165
+ acc.left > MEMO_BYTES
166
+ ? { left: acc.left - k.length, out: [...acc.out, k] }
167
+ : acc,
168
+ { left: total, out: [] },
169
+ );
170
+ return out;
171
+ };
172
+
137
173
  /** A LaTeX source → its tree, without the branches `\iffalse … \fi` hides (`conditionals.ts`). */
138
- export const parseLatex = (src: string): ParsedTex => ({
139
- src,
140
- root: withoutFalseBranches(src, parser().parse(src)),
141
- [PARSED]: true,
142
- });
174
+ export function parseLatex(src: string): ParsedTex {
175
+ const hit = memo.get(src);
176
+ const parsed: ParsedTex = hit ?? {
177
+ src,
178
+ root: withoutFalseBranches(src, parser().parse(src)),
179
+ [PARSED]: true,
180
+ };
181
+ // Most recent last: a hit moves to the end, and the first keys are the ones used least recently.
182
+ // eslint-disable-next-line functional/immutable-data -- the bounded memo, see above
183
+ memo.delete(src);
184
+ // eslint-disable-next-line functional/immutable-data -- the bounded memo, see above
185
+ evicted(src.length).forEach((k) => memo.delete(k));
186
+ // eslint-disable-next-line functional/immutable-data -- the bounded memo, see above
187
+ memo.set(src, parsed);
188
+ return parsed;
189
+ }
190
+
191
+ /**
192
+ * The part of a source's tree TeX reads: everything up to and with the `document` environment.
193
+ * TeX stops at `\end{document}`, so a `\bibliography`, a `thebibliography`, a block or an `\input`
194
+ * parked after it is never read. A source with no `document` environment is read whole.
195
+ */
196
+ export function liveRoot(t: ParsedTex): Readonly<Ast.Root> {
197
+ const end = t.root.content.findIndex(
198
+ (n) => n.type === "environment" && n.env === "document",
199
+ );
200
+ return end < 0
201
+ ? t.root
202
+ : { ...t.root, content: t.root.content.slice(0, end + 1) };
203
+ }
@@ -5,6 +5,7 @@
5
5
  import { absolutePath } from "../../domain/paths.ts";
6
6
  import type { Download } from "../../ports/download.ts";
7
7
  import type { Files } from "../../ports/files.ts";
8
+ import type { ListDir } from "../../ports/talk-media.ts";
8
9
  import type { Command, ProcessExit, RunProcess } from "../../ports/process.ts";
9
10
  import type { NotAPromise, Scratch, Workspace } from "../../ports/workspace.ts";
10
11
  import { err, ok } from "../../domain/result.ts";
@@ -55,6 +56,22 @@ export function memoryFiles(
55
56
  };
56
57
  }
57
58
 
59
+ /** `ListDir` over `memoryFiles`: the names directly under a directory, none when it holds no file. */
60
+ export const memoryListDir =
61
+ (files: MemoryFiles): ListDir =>
62
+ (dir) => [
63
+ ...new Set(
64
+ [...files.map.keys()]
65
+ .filter((k) => k.startsWith(`${dir}/`))
66
+ .map((k) =>
67
+ k
68
+ .slice(dir.length + 1)
69
+ .split("/", 1)
70
+ .join(""),
71
+ ),
72
+ ),
73
+ ];
74
+
58
75
  export interface MemoryWorkspace extends Workspace {
59
76
  /** Every file written into a scratch directory, in order. */
60
77
  readonly written: {
@@ -11,10 +11,16 @@ import {
11
11
  type LookupCache,
12
12
  } from "../../domain/lookup-cache.ts";
13
13
  import * as cites from "../../../skills/verify-citations/scripts/verify-cites.mjs";
14
+ import { absolutePath } from "../../domain/paths.ts";
15
+ import { bibReader } from "../bibtex/index.ts";
16
+
17
+ /** A BibTeX text's entries, as the bibtex reader reads them. */
18
+ const read = (bib: string) =>
19
+ bibReader.readFile(absolutePath("/p/refs.bib"), bib).entries;
14
20
 
15
21
  /** The verdicts of a cold run — an empty cache, the adapter the CLI wires. */
16
22
  const checkOnly = async (bib: string) =>
17
- (await onlineReferences(bib, EMPTY_LOOKUP_CACHE)).check;
23
+ (await onlineReferences(read(bib), EMPTY_LOOKUP_CACHE)).check;
18
24
 
19
25
  afterEach(() => {
20
26
  vi.unstubAllGlobals();
@@ -131,23 +137,75 @@ test("each entry gets its existence and its authors: confirmed, fabricated, mism
131
137
  );
132
138
  });
133
139
 
134
- test("an author finding names what is extra and what is out of order; an entry only the author check read is unresolvable", async () => {
140
+ test("one key in two databases bibtex opened: each entry gets its own verdict, in the order given", async () => {
141
+ vi.useFakeTimers();
142
+ fakeFetch({
143
+ "https://api.crossref.org/works/10.1%2Fgood": () =>
144
+ json(200, {
145
+ message: { title: ["Good Paper"], issued: { "date-parts": [[2024]] } },
146
+ }),
147
+ "https://doi.org/api/handles/10.1/good": () =>
148
+ json(200, { responseCode: 1 }),
149
+ "https://doi.org/api/handles/10.1/fake": () => json(404),
150
+ // Crossref knows nothing of the fake: a 404, not a refusal that would stop the run asking it.
151
+ "https://api.crossref.org/works/10.1%2Ffake": () => json(404),
152
+ "https://dblp.org/search/publ/api/?q=Good%20Paper": () =>
153
+ dblp("Good Paper", ["Ada Lovelace"]),
154
+ "https://api.crossref.org/": () => json(200),
155
+ "https://dblp.org/": () => json(200, {}),
156
+ });
157
+ // Two databases of one `\bibliography`: both define `k` (bibtex takes the first), and only one
158
+ // copy fails — its existence, or its authors. The failing verdict is the failing copy's own.
159
+ const good =
160
+ "@inproceedings{k, author={Ada Lovelace}, title={Good Paper}, booktitle={ICSE}, doi={10.1/good}}";
161
+ const fake =
162
+ "@inproceedings{k, author={Ada Lovelace}, title={Fake Paper}, booktitle={ICSE}, doi={10.1/fake}}";
163
+ const drift =
164
+ "@inproceedings{k, author={Ada Lovelace and Alan Turing}, title={Good Paper}, booktitle={ICSE}, doi={10.1/good}}";
165
+ const verdicts = async (bib: string) => {
166
+ const r = await settle(checkOnly(bib));
167
+ return (r.kind === "checked" ? r.entries : []).map((e) => [
168
+ e.key,
169
+ e.exists,
170
+ e.authors,
171
+ ]);
172
+ };
173
+ assert.deepEqual(
174
+ [
175
+ await verdicts(`${good}\n${fake}`),
176
+ await verdicts(`${fake}\n${good}`),
177
+ await verdicts(`${good}\n${drift}`),
178
+ ],
179
+ [
180
+ [
181
+ ["k", "true", "match"],
182
+ ["k", "false", "skipped"],
183
+ ],
184
+ [
185
+ ["k", "false", "skipped"],
186
+ ["k", "true", "match"],
187
+ ],
188
+ [
189
+ ["k", "true", "match"],
190
+ ["k", "true", "mismatch"],
191
+ ],
192
+ ],
193
+ );
194
+ });
195
+
196
+ test("an author finding names what is extra and what is out of order", async () => {
135
197
  vi.useFakeTimers();
136
198
  fakeFetch({
137
199
  "https://dblp.org/search/publ/api/?q=Extra%20Paper": () =>
138
200
  dblp("Extra Paper", ["Ada Lovelace"]),
139
201
  "https://dblp.org/search/publ/api/?q=Swapped%20Paper": () =>
140
202
  dblp("Swapped Paper", ["Ada Lovelace", "Alan Turing"]),
141
- "https://dblp.org/search/publ/api/?q=Comment%20Paper": () =>
142
- dblp("Comment Paper", ["Grace Hopper"]),
143
203
  "https://dblp.org/": () => json(200, {}),
144
204
  "https://api.crossref.org/": () => json(200, { message: { items: [] } }),
145
205
  });
146
206
  const bib = [
147
207
  "@inproceedings{extra, author={Ada Lovelace and Grace Hopper}, title={Extra Paper}, booktitle={ICSE}}",
148
208
  "@inproceedings{swapped, author={Alan Turing and Ada Lovelace}, title={Swapped Paper}, booktitle={ICSE}}",
149
- // verify-cites skips @comment blocks; bib-authors reads them — so only one checker sees this key.
150
- "@comment{commented, author={Ada Lovelace}, title={Comment Paper}, booktitle={ICSE}}",
151
209
  ].join("\n");
152
210
  const r = await settle(checkOnly(bib));
153
211
  const why = Object.fromEntries(
@@ -165,10 +223,6 @@ test("an author finding names what is extra and what is out of order; an entry o
165
223
  "unresolvable",
166
224
  "not found by title in any database (no resolvable id to disprove) — could be a legit unindexed/regional/pre-digital work, NOT fabrication; order differs (DBLP: ICSE 2024)",
167
225
  ],
168
- commented: [
169
- "unresolvable",
170
- "missing hopper; extra lovelace (DBLP: ICSE 2024)",
171
- ],
172
226
  });
173
227
  });
174
228
 
@@ -208,7 +262,7 @@ test(
208
262
  slowFetch(40);
209
263
  const bib = manyBib(20);
210
264
  const serial: string[] = [];
211
- for (const c of cites.parseBib(bib))
265
+ for (const c of cites.citationsOf(read(bib)))
212
266
  serial.push((await cites.verifyCitationLive(c, { cache: {} })).verdict);
213
267
  const t0 = Date.now();
214
268
  const r = await checkOnly(bib);
@@ -265,7 +319,7 @@ const answering = () =>
265
319
  async function cold(bib: string) {
266
320
  vi.useFakeTimers();
267
321
  answering();
268
- return settle(cached(bib, EMPTY_LOOKUP_CACHE));
322
+ return settle(cached(read(bib), EMPTY_LOOKUP_CACHE));
269
323
  }
270
324
 
271
325
  test("cold: every answer is stored with the day it was fetched — responses, never verdicts", async () => {
@@ -324,7 +378,7 @@ test("🔴 warm: a fully cached bibliography asks NOTHING — no lookup, no reac
324
378
  // Fake timers with NO advancing: a single DBLP pause would never resolve and time the test out.
325
379
  vi.useFakeTimers();
326
380
  const calls = fakeFetch({});
327
- const second = await cached(bib, first.cache);
381
+ const second = await cached(read(bib), first.cache);
328
382
  assert.deepEqual(
329
383
  // Nothing fetched, so the cache comes back as the very object passed in — nothing to write.
330
384
  { calls, check: second.check, sameCache: second.cache === first.cache },
@@ -338,7 +392,7 @@ test("🔴 one entry edited: only that entry is asked again, and the old answers
338
392
  vi.useFakeTimers();
339
393
  const calls = answering();
340
394
  const edited = PAPERS.join("\n").replace("Other Paper", "Renamed Paper");
341
- const second = await settle(cached(edited, first.cache));
395
+ const second = await settle(cached(read(edited), first.cache));
342
396
  assert.deepEqual(
343
397
  {
344
398
  // The lookups run concurrently, so the order of requests is not the subject.
@@ -373,14 +427,14 @@ test("an answer older than 30 days is asked again and re-dated; at 30 days it is
373
427
  vi.useFakeTimers();
374
428
  const quiet = fakeFetch({});
375
429
  const at30 = await referencesChecker({ today: () => "2026-10-27" })(
376
- bib,
430
+ read(bib),
377
431
  first.cache,
378
432
  );
379
433
  vi.useRealTimers();
380
434
  vi.useFakeTimers();
381
435
  const calls = answering();
382
436
  const at31 = await settle(
383
- referencesChecker({ today: () => "2026-10-28" })(bib, first.cache),
437
+ referencesChecker({ today: () => "2026-10-28" })(read(bib), first.cache),
384
438
  );
385
439
  assert.deepEqual(
386
440
  {
@@ -408,7 +462,7 @@ test("a failed lookup is not cached: the next run asks it again", async () => {
408
462
  "https://dblp.org/": () => json(500),
409
463
  });
410
464
  const bib = PAPERS[1];
411
- const r = await settle(cached(bib, EMPTY_LOOKUP_CACHE));
465
+ const r = await settle(cached(read(bib), EMPTY_LOOKUP_CACHE));
412
466
  const failed: LookupCache = r.cache;
413
467
  assert.deepEqual(
414
468
  {
@@ -423,7 +477,7 @@ test("offline with an incomplete cache: not-checked, and the cache comes back un
423
477
  vi.stubGlobal("fetch", () =>
424
478
  Promise.reject(new Error("getaddrinfo ENOTFOUND api.crossref.org")),
425
479
  );
426
- const r = await cached(PAPERS[0], EMPTY_LOOKUP_CACHE);
480
+ const r = await cached(read(PAPERS[0]), EMPTY_LOOKUP_CACHE);
427
481
  assert.deepEqual(r, {
428
482
  check: {
429
483
  kind: "not-checked",
@@ -461,7 +515,7 @@ test("🔴 #120: DBLP answering HTML is asked ONCE — no retries, no pause —
461
515
  () => new Response("<!doctype html><title>429</title>"),
462
516
  () => json(429),
463
517
  );
464
- const { check } = await cached(PUBLISHED, EMPTY_LOOKUP_CACHE);
518
+ const { check } = await cached(read(PUBLISHED), EMPTY_LOOKUP_CACHE);
465
519
  const entries = check.kind === "checked" ? check.entries : [];
466
520
  assert.deepEqual(
467
521
  {
@@ -507,14 +561,14 @@ test("#120: a DBLP 429 trips it as well; DBLP answering keeps its 900 ms pace be
507
561
  () => json(429),
508
562
  () => json(200, { data: [] }),
509
563
  );
510
- await cached(PUBLISHED, EMPTY_LOOKUP_CACHE);
564
+ await cached(read(PUBLISHED), EMPTY_LOOKUP_CACHE);
511
565
  vi.useRealTimers();
512
566
  vi.useFakeTimers();
513
567
  const answering = refusing(
514
568
  () => dblp("First Paper", ["Ada Lovelace"]),
515
569
  () => json(200, { data: [] }),
516
570
  );
517
- const pending = cached(PUBLISHED, EMPTY_LOOKUP_CACHE);
571
+ const pending = cached(read(PUBLISHED), EMPTY_LOOKUP_CACHE);
518
572
  // One compared entry, then the pause: without advancing time, the run cannot finish.
519
573
  let done = false;
520
574
  void pending.finally(() => (done = true));
@@ -43,6 +43,7 @@ import type {
43
43
  AuthorFinding,
44
44
  BibEntry,
45
45
  } from "../../../skills/verify-citations/scripts/bib-authors.mjs";
46
+ import { zip } from "remeda";
46
47
  import { messageOf } from "../../domain/text.ts";
47
48
  import { isRecord } from "../../domain/record.ts";
48
49
 
@@ -55,12 +56,13 @@ const describeAuthors = (f: AuthorFinding): string =>
55
56
  .filter(Boolean)
56
57
  .join("; ") + ` (DBLP: ${f.venue})`;
57
58
 
58
- const authorsOf = (key: string, a: AuthorBuckets): EntryVerdict["authors"] =>
59
- a.findings.some((f) => f.key === key)
59
+ /** bib-authors' answer for one entry, from its buckets for that entry alone. */
60
+ const authorsOf = (a: AuthorBuckets): EntryVerdict["authors"] =>
61
+ a.findings.length > 0
60
62
  ? "mismatch"
61
- : a.unchecked.some((u) => u.key === key)
63
+ : a.unchecked.length > 0
62
64
  ? "unchecked"
63
- : a.matched.includes(key)
65
+ : a.matched.length > 0
64
66
  ? "match"
65
67
  : "skipped";
66
68
 
@@ -69,36 +71,45 @@ const authorsOf = (key: string, a: AuthorBuckets): EntryVerdict["authors"] =>
69
71
  * one, so "unresolvable" is not read as "every registry looked and found nothing". Nothing for a
70
72
  * confirmed work: a refusal after a confirmation changed nothing.
71
73
  */
72
- const unconfirmed = (
73
- c: CiteVerdict | undefined,
74
- ): readonly (string | undefined)[] =>
75
- c && c.verdict !== "true"
74
+ const unconfirmed = (c: CiteVerdict): readonly (string | undefined)[] =>
75
+ c.verdict !== "true"
76
76
  ? [c.reason, ...(c.refused ?? []).map((r) => `not asked: ${r}`)]
77
77
  : [];
78
78
 
79
- /** One entry's verdict from the two checkers' answers. */
80
- function entryVerdict(
81
- key: string,
82
- found: readonly CiteVerdict[],
83
- a: AuthorBuckets,
84
- ): EntryVerdict {
85
- const c = found.find((x) => x.id === key);
86
- const mismatch = a.findings.find((f) => f.key === key);
79
+ /** One entry's verdict: verify-cites' answer for it, and bib-authors' buckets for it alone. */
80
+ function entryVerdict(c: CiteVerdict, a: AuthorBuckets): EntryVerdict {
87
81
  const why = [
88
82
  ...unconfirmed(c),
89
- mismatch ? describeAuthors(mismatch) : undefined,
90
- a.unchecked.find((u) => u.key === key)?.why,
83
+ ...a.findings.map(describeAuthors),
84
+ ...a.unchecked.map((u) => u.why),
91
85
  ]
92
86
  .filter(Boolean)
93
87
  .join("; ");
94
88
  return {
95
- key,
96
- exists: c?.verdict ?? "unresolvable",
97
- authors: authorsOf(key, a),
89
+ key: c.id,
90
+ exists: c.verdict,
91
+ authors: authorsOf(a),
98
92
  ...(why ? { why } : {}),
99
93
  };
100
94
  }
101
95
 
96
+ /**
97
+ * bib-authors over each entry on its own, one after another as it would run them: its buckets name
98
+ * an entry by key, and two entries of a bibliography may share a key (two databases bibtex
99
+ * opened may each define it, with other metadata).
100
+ */
101
+ const authorsOfEach = (
102
+ parsed: readonly BibEntry[],
103
+ d: Pick<ReturnType<typeof cachedDblp>, "lookup" | "pause">,
104
+ ): Promise<readonly AuthorBuckets[]> =>
105
+ parsed.reduce<Promise<readonly AuthorBuckets[]>>(
106
+ async (done, e) => [
107
+ ...(await done),
108
+ await authors.checkAuthors([e], { lookup: d.lookup, pause: d.pause }),
109
+ ],
110
+ Promise.resolve([]),
111
+ );
112
+
102
113
  /** Whether bib-authors will ask DBLP about this entry — the filter its `checkAuthors` applies. */
103
114
  const asksDblp = (e: BibEntry): boolean =>
104
115
  Boolean(e.title && e.author) &&
@@ -212,8 +223,8 @@ export const referencesChecker =
212
223
  async (bib, cache) => {
213
224
  // Answers past MAX_AGE_DAYS are left out, so the run asks them again (and `grown` re-dates them).
214
225
  const usable = freshPart(cache, today());
215
- const citations = cites.parseBib(bib);
216
- const parsed = authors.parseBib(bib);
226
+ const citations = cites.citationsOf(bib);
227
+ const parsed = authors.authorEntries(bib);
217
228
  const store: Record<string, unknown> = Object.fromEntries(
218
229
  [...usable.citations].map(([k, v]) => [k, v.response]),
219
230
  );
@@ -237,16 +248,10 @@ export const referencesChecker =
237
248
  breaker,
238
249
  }),
239
250
  ),
240
- authors.checkAuthors(parsed, {
241
- lookup: d.lookup,
242
- pause: d.pause,
243
- }),
244
- ]);
245
- const keys = new Set([
246
- ...found.map((c) => c.id),
247
- ...a.findings.map((f) => f.key),
251
+ authorsOfEach(parsed, d),
248
252
  ]);
249
- const entries = [...keys].map((key) => entryVerdict(key, found, a));
253
+ // Both lists are one per entry, in its order: so is the verdict list.
254
+ const entries = zip(found, a).map(([c, b]) => entryVerdict(c, b));
250
255
  return {
251
256
  check: { kind: "checked", entries },
252
257
  cache: grown({ cache, usable }, { store, dblp: d.dblp }, today),