paperlint 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/CLAUDE.md +9 -25
  2. package/CONTRIBUTING.md +5 -4
  3. package/README.md +4 -3
  4. package/dist/adapters/bibtex/index.d.ts +3 -0
  5. package/dist/adapters/bibtex/index.d.ts.map +1 -0
  6. package/dist/adapters/bibtex/index.js +103 -0
  7. package/dist/adapters/bibtex/index.js.map +1 -0
  8. package/dist/adapters/latex/filecontents.d.ts +33 -0
  9. package/dist/adapters/latex/filecontents.d.ts.map +1 -0
  10. package/dist/adapters/latex/filecontents.js +65 -0
  11. package/dist/adapters/latex/filecontents.js.map +1 -0
  12. package/dist/adapters/latex/includes.d.ts +3 -3
  13. package/dist/adapters/latex/includes.d.ts.map +1 -1
  14. package/dist/adapters/latex/includes.js +18 -10
  15. package/dist/adapters/latex/includes.js.map +1 -1
  16. package/dist/adapters/latex/index.d.ts.map +1 -1
  17. package/dist/adapters/latex/index.js +2 -0
  18. package/dist/adapters/latex/index.js.map +1 -1
  19. package/dist/adapters/latex/nodes.d.ts +12 -0
  20. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  21. package/dist/adapters/latex/nodes.js +39 -0
  22. package/dist/adapters/latex/nodes.js.map +1 -1
  23. package/dist/adapters/latex/parse.d.ts +7 -1
  24. package/dist/adapters/latex/parse.d.ts.map +1 -1
  25. package/dist/adapters/latex/parse.js +55 -6
  26. package/dist/adapters/latex/parse.js.map +1 -1
  27. package/dist/adapters/memory/index.d.ts +3 -0
  28. package/dist/adapters/memory/index.d.ts.map +1 -1
  29. package/dist/adapters/memory/index.js +9 -0
  30. package/dist/adapters/memory/index.js.map +1 -1
  31. package/dist/adapters/references/index.d.ts.map +1 -1
  32. package/dist/adapters/references/index.js +27 -24
  33. package/dist/adapters/references/index.js.map +1 -1
  34. package/dist/adapters/sources-record/index.d.ts +9 -0
  35. package/dist/adapters/sources-record/index.d.ts.map +1 -0
  36. package/dist/adapters/sources-record/index.js +68 -0
  37. package/dist/adapters/sources-record/index.js.map +1 -0
  38. package/dist/adapters/tex-output/blg.d.ts +9 -0
  39. package/dist/adapters/tex-output/blg.d.ts.map +1 -0
  40. package/dist/adapters/tex-output/blg.js +20 -0
  41. package/dist/adapters/tex-output/blg.js.map +1 -0
  42. package/dist/adapters/tex-output/fls.d.ts +8 -0
  43. package/dist/adapters/tex-output/fls.d.ts.map +1 -0
  44. package/dist/adapters/tex-output/fls.js +16 -0
  45. package/dist/adapters/tex-output/fls.js.map +1 -0
  46. package/dist/adapters/tex-output/index.d.ts +7 -0
  47. package/dist/adapters/tex-output/index.d.ts.map +1 -0
  48. package/dist/adapters/tex-output/index.js +7 -0
  49. package/dist/adapters/tex-output/index.js.map +1 -0
  50. package/dist/bib-rules.d.ts +47 -0
  51. package/dist/bib-rules.d.ts.map +1 -0
  52. package/dist/bib-rules.js +127 -0
  53. package/dist/bib-rules.js.map +1 -0
  54. package/dist/build.d.ts +29 -1
  55. package/dist/build.d.ts.map +1 -1
  56. package/dist/build.js +40 -24
  57. package/dist/build.js.map +1 -1
  58. package/dist/claim-provenance.d.ts +2 -2
  59. package/dist/cli.d.ts +6 -1
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +30 -26
  62. package/dist/cli.js.map +1 -1
  63. package/dist/domain/paper-source.d.ts +1 -10
  64. package/dist/domain/paper-source.d.ts.map +1 -1
  65. package/dist/domain/paper-source.js +34 -24
  66. package/dist/domain/paper-source.js.map +1 -1
  67. package/dist/domain/paper-sources.d.ts +72 -0
  68. package/dist/domain/paper-sources.d.ts.map +1 -0
  69. package/dist/domain/paper-sources.js +18 -0
  70. package/dist/domain/paper-sources.js.map +1 -0
  71. package/dist/domain/sources-record.d.ts +102 -0
  72. package/dist/domain/sources-record.d.ts.map +1 -0
  73. package/dist/domain/sources-record.js +38 -0
  74. package/dist/domain/sources-record.js.map +1 -0
  75. package/dist/domain/tex-run.d.ts +25 -0
  76. package/dist/domain/tex-run.d.ts.map +1 -0
  77. package/dist/domain/tex-run.js +79 -0
  78. package/dist/domain/tex-run.js.map +1 -0
  79. package/dist/eslint-rules/paper-context.d.ts.map +1 -1
  80. package/dist/eslint-rules/paper-context.js +4 -3
  81. package/dist/eslint-rules/paper-context.js.map +1 -1
  82. package/dist/eslint-rules/paper-typography.d.ts +0 -9
  83. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  84. package/dist/eslint-rules/paper-typography.js +12 -5
  85. package/dist/eslint-rules/paper-typography.js.map +1 -1
  86. package/dist/latex-log.d.ts +10 -0
  87. package/dist/latex-log.d.ts.map +1 -1
  88. package/dist/latex-log.js +10 -0
  89. package/dist/latex-log.js.map +1 -1
  90. package/dist/latex-loop.d.ts +2 -0
  91. package/dist/latex-loop.d.ts.map +1 -1
  92. package/dist/latex-loop.js.map +1 -1
  93. package/dist/package-dirs.d.ts.map +1 -1
  94. package/dist/package-dirs.js +3 -2
  95. package/dist/package-dirs.js.map +1 -1
  96. package/dist/paper-includes.d.ts +4 -7
  97. package/dist/paper-includes.d.ts.map +1 -1
  98. package/dist/paper-includes.js +29 -21
  99. package/dist/paper-includes.js.map +1 -1
  100. package/dist/paper-prose.d.ts +2 -2
  101. package/dist/paper-record.d.ts +25 -0
  102. package/dist/paper-record.d.ts.map +1 -0
  103. package/dist/paper-record.js +39 -0
  104. package/dist/paper-record.js.map +1 -0
  105. package/dist/paper-sources.d.ts +66 -0
  106. package/dist/paper-sources.d.ts.map +1 -0
  107. package/dist/paper-sources.js +92 -0
  108. package/dist/paper-sources.js.map +1 -0
  109. package/dist/ports/bib-reader.d.ts +18 -0
  110. package/dist/ports/bib-reader.d.ts.map +1 -0
  111. package/dist/ports/bib-reader.js +2 -0
  112. package/dist/ports/bib-reader.js.map +1 -0
  113. package/dist/ports/check-references.d.ts +9 -4
  114. package/dist/ports/check-references.d.ts.map +1 -1
  115. package/dist/ports/latex.d.ts +15 -0
  116. package/dist/ports/latex.d.ts.map +1 -1
  117. package/dist/ports/sources-codec.d.ts +11 -0
  118. package/dist/ports/sources-codec.d.ts.map +1 -0
  119. package/dist/ports/sources-codec.js +2 -0
  120. package/dist/ports/sources-codec.js.map +1 -0
  121. package/dist/ports/tex-output.d.ts +17 -0
  122. package/dist/ports/tex-output.d.ts.map +1 -0
  123. package/dist/ports/tex-output.js +2 -0
  124. package/dist/ports/tex-output.js.map +1 -0
  125. package/dist/recorded-bibliography.d.ts +89 -0
  126. package/dist/recorded-bibliography.d.ts.map +1 -0
  127. package/dist/recorded-bibliography.js +136 -0
  128. package/dist/recorded-bibliography.js.map +1 -0
  129. package/dist/reference-rules.d.ts +5 -5
  130. package/dist/reference-rules.d.ts.map +1 -1
  131. package/dist/reference-rules.js +150 -77
  132. package/dist/reference-rules.js.map +1 -1
  133. package/dist/references.d.ts +71 -16
  134. package/dist/references.d.ts.map +1 -1
  135. package/dist/references.js +110 -26
  136. package/dist/references.js.map +1 -1
  137. package/dist/register-bands.d.ts +3 -3
  138. package/dist/register-bands.d.ts.map +1 -1
  139. package/dist/register-bands.js.map +1 -1
  140. package/dist/register.d.ts +2 -2
  141. package/dist/sources-record.d.ts +38 -0
  142. package/dist/sources-record.d.ts.map +1 -0
  143. package/dist/sources-record.js +123 -0
  144. package/dist/sources-record.js.map +1 -0
  145. package/dist/sources-rules.d.ts +44 -0
  146. package/dist/sources-rules.d.ts.map +1 -0
  147. package/dist/sources-rules.js +76 -0
  148. package/dist/sources-rules.js.map +1 -0
  149. package/dist/tex-paper.d.ts +10 -15
  150. package/dist/tex-paper.d.ts.map +1 -1
  151. package/dist/tex-paper.js +24 -40
  152. package/dist/tex-paper.js.map +1 -1
  153. package/dist/tex-venue-rules.d.ts +3 -3
  154. package/dist/tex-venue-rules.d.ts.map +1 -1
  155. package/dist/tex-venue-rules.js.map +1 -1
  156. package/docs/configuration.md +12 -4
  157. package/docs/design/paper-sources.md +197 -0
  158. package/docs/package-shape-options.md +1 -1
  159. package/docs/references.md +34 -8
  160. package/docs/rules/bib/reachable-entry.md +64 -0
  161. package/docs/rules/paper/sources-fresh.md +81 -0
  162. package/docs/rules.md +62 -57
  163. package/eslint-rules/paper-context.ts +4 -3
  164. package/eslint-rules/paper-typography.test.mjs +12 -72
  165. package/eslint-rules/paper-typography.ts +13 -6
  166. package/eslint.config.mjs +22 -0
  167. package/fixtures/build-e2e/aidc/paper.tex +1 -1
  168. package/fixtures/build-e2e/anon-ack/paper.tex +1 -1
  169. package/fixtures/build-e2e/anon-author/paper.tex +1 -1
  170. package/fixtures/build-e2e/anon-bib/paper.tex +1 -1
  171. package/fixtures/build-e2e/anon-href/paper.tex +1 -1
  172. package/fixtures/build-e2e/anon-macro/paper.tex +1 -1
  173. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +1 -1
  174. package/fixtures/paper-sources/p1/paper.tex +30 -0
  175. package/fixtures/paper-sources/p1/refs.bib +6 -0
  176. package/fixtures/paper-sources/p1/sections/dead.tex +6 -0
  177. package/fixtures/paper-sources/p1/sections/intro.tex +7 -0
  178. package/fixtures/paper-sources/p1/sections/parked.tex +6 -0
  179. package/fixtures/paper-sources/p1/tex-truth.json +37 -0
  180. package/fixtures/paper-sources/p1/versions/2026-01-01-review.tex +4 -0
  181. package/fixtures/paper-sources/v1-stale/paper.tex +14 -0
  182. package/fixtures/paper-sources/v1-stale/refs.bib +6 -0
  183. package/fixtures/paper-sources/v1-stale/tex-truth.json +32 -0
  184. package/fixtures/paper-sources/v10-ifthenelse/anon.bib +1 -0
  185. package/fixtures/paper-sources/v10-ifthenelse/paper.tex +9 -0
  186. package/fixtures/paper-sources/v10-ifthenelse/real.bib +1 -0
  187. package/fixtures/paper-sources/v10-ifthenelse/tex-truth.json +28 -0
  188. package/fixtures/paper-sources/v11-two-blocks/paper.tex +12 -0
  189. package/fixtures/paper-sources/v11-two-blocks/tex-truth.json +30 -0
  190. package/fixtures/paper-sources/v13-percent-text/paper.tex +6 -0
  191. package/fixtures/paper-sources/v13-percent-text/refs.bib +3 -0
  192. package/fixtures/paper-sources/v13-percent-text/tex-truth.json +30 -0
  193. package/fixtures/paper-sources/v15-after-end/paper.tex +6 -0
  194. package/fixtures/paper-sources/v15-after-end/refs.bib +1 -0
  195. package/fixtures/paper-sources/v15-after-end/tex-truth.json +17 -0
  196. package/fixtures/paper-sources/v16-included-block/bibblock.tex +3 -0
  197. package/fixtures/paper-sources/v16-included-block/paper.tex +7 -0
  198. package/fixtures/paper-sources/v16-included-block/refs.bib +1 -0
  199. package/fixtures/paper-sources/v16-included-block/tex-truth.json +35 -0
  200. package/fixtures/paper-sources/v17-include-order/bibsetup.tex +1 -0
  201. package/fixtures/paper-sources/v17-include-order/first.bib +1 -0
  202. package/fixtures/paper-sources/v17-include-order/later.bib +1 -0
  203. package/fixtures/paper-sources/v17-include-order/paper.tex +7 -0
  204. package/fixtures/paper-sources/v17-include-order/tex-truth.json +40 -0
  205. package/fixtures/paper-sources/v2-overwrite/paper.tex +14 -0
  206. package/fixtures/paper-sources/v2-overwrite/refs.bib +6 -0
  207. package/fixtures/paper-sources/v2-overwrite/tex-truth.json +30 -0
  208. package/fixtures/paper-sources/v22-parked-thebibliography/paper.tex +7 -0
  209. package/fixtures/paper-sources/v22-parked-thebibliography/tex-truth.json +17 -0
  210. package/fixtures/paper-sources/v23-parked-include/old.bib +1 -0
  211. package/fixtures/paper-sources/v23-parked-include/paper.tex +7 -0
  212. package/fixtures/paper-sources/v23-parked-include/parked.tex +1 -0
  213. package/fixtures/paper-sources/v23-parked-include/refs.bib +1 -0
  214. package/fixtures/paper-sources/v23-parked-include/tex-truth.json +28 -0
  215. package/fixtures/paper-sources/v24-quoted-fields/paper.tex +6 -0
  216. package/fixtures/paper-sources/v24-quoted-fields/refs.bib +5 -0
  217. package/fixtures/paper-sources/v24-quoted-fields/tex-truth.json +32 -0
  218. package/fixtures/paper-sources/v25-string-differs/paper.tex +10 -0
  219. package/fixtures/paper-sources/v25-string-differs/refs.bib +2 -0
  220. package/fixtures/paper-sources/v25-string-differs/tex-truth.json +32 -0
  221. package/fixtures/paper-sources/v26-comment-in-names/one.bib +1 -0
  222. package/fixtures/paper-sources/v26-comment-in-names/paper.tex +7 -0
  223. package/fixtures/paper-sources/v26-comment-in-names/tex-truth.json +32 -0
  224. package/fixtures/paper-sources/v26-comment-in-names/two.bib +1 -0
  225. package/fixtures/paper-sources/v27-shared-string/abbreviations.bib +2 -0
  226. package/fixtures/paper-sources/v27-shared-string/paper.tex +6 -0
  227. package/fixtures/paper-sources/v27-shared-string/references.bib +6 -0
  228. package/fixtures/paper-sources/v27-shared-string/tex-truth.json +31 -0
  229. package/fixtures/paper-sources/v28-shared-string-block/abbreviations.bib +2 -0
  230. package/fixtures/paper-sources/v28-shared-string-block/paper.tex +14 -0
  231. package/fixtures/paper-sources/v28-shared-string-block/tex-truth.json +33 -0
  232. package/fixtures/paper-sources/v29-generated-input/paper.tex +9 -0
  233. package/fixtures/paper-sources/v29-generated-input/tex-truth.json +24 -0
  234. package/fixtures/paper-sources/v3-declared/paper.bib +6 -0
  235. package/fixtures/paper-sources/v3-declared/paper.tex +6 -0
  236. package/fixtures/paper-sources/v3-declared/tex-truth.json +28 -0
  237. package/fixtures/paper-sources/v30-name-next-line/paper.tex +14 -0
  238. package/fixtures/paper-sources/v30-name-next-line/tex-truth.json +30 -0
  239. package/fixtures/paper-sources/v4-commented/paper.tex +14 -0
  240. package/fixtures/paper-sources/v4-commented/refs.bib +6 -0
  241. package/fixtures/paper-sources/v4-commented/tex-truth.json +28 -0
  242. package/fixtures/paper-sources/v5-percent-entry/paper.tex +20 -0
  243. package/fixtures/paper-sources/v5-percent-entry/tex-truth.json +37 -0
  244. package/fixtures/paper-sources/v6-unclosed/paper.tex +6 -0
  245. package/fixtures/paper-sources/v6-unclosed/refs.bib +5 -0
  246. package/fixtures/paper-sources/v6-unclosed/tex-truth.json +37 -0
  247. package/fixtures/paper-sources/v8-jobname/paper.tex +9 -0
  248. package/fixtures/paper-sources/v8-jobname/tex-truth.json +30 -0
  249. package/fixtures/paper-sources/v9-two-bibliographies/one.bib +1 -0
  250. package/fixtures/paper-sources/v9-two-bibliographies/paper.tex +7 -0
  251. package/fixtures/paper-sources/v9-two-bibliographies/tex-truth.json +35 -0
  252. package/fixtures/paper-sources/v9-two-bibliographies/two.bib +1 -0
  253. package/fixtures/paper-typography/clean-paper/paper.tex +1 -1
  254. package/fixtures/paper-typography/messy-paper/paper.tex +1 -1
  255. package/package.json +2 -10
  256. package/scripts/check.test.ts +23 -1
  257. package/scripts/check.ts +7 -3
  258. package/scripts/coverage-config.test.ts +19 -10
  259. package/scripts/layer-legacy-frozen.test.ts +41 -2
  260. package/scripts/layer-legacy-frozen.ts +31 -2
  261. package/skills/grade-paper-writing/SKILL.md +4 -3
  262. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -2
  263. package/skills/grade-paper-writing/prose-lint.mjs +11 -2
  264. package/skills/grade-paper-writing/prose-lint.test.ts +52 -4
  265. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +83 -33
  266. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +67 -115
  267. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +128 -121
  268. package/skills/verify-citations/SKILL.md +7 -4
  269. package/skills/verify-citations/SKILL.md.spec.ts +6 -3
  270. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +17 -19
  271. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +129 -46
  272. package/skills/verify-citations/scripts/bib-authors.d.mts +22 -2
  273. package/skills/verify-citations/scripts/bib-authors.mjs +57 -111
  274. package/skills/verify-citations/scripts/verify-cites.cli.test.ts +16 -0
  275. package/skills/verify-citations/scripts/verify-cites.d.mts +19 -3
  276. package/skills/verify-citations/scripts/verify-cites.mjs +42 -108
  277. package/skills/verify-citations/scripts/verify-cites.test.mjs +23 -36
  278. package/src/adapters/bibtex/index.test.ts +163 -0
  279. package/src/adapters/bibtex/index.ts +155 -0
  280. package/src/adapters/latex/filecontents.ts +114 -0
  281. package/src/adapters/latex/includes.ts +18 -10
  282. package/src/adapters/latex/index.test.ts +73 -0
  283. package/src/adapters/latex/index.ts +2 -0
  284. package/src/adapters/latex/nodes.ts +64 -0
  285. package/src/adapters/latex/parse.test.ts +38 -0
  286. package/src/adapters/latex/parse.ts +67 -6
  287. package/src/adapters/memory/index.ts +17 -0
  288. package/src/adapters/references/index.test.ts +75 -21
  289. package/src/adapters/references/index.ts +37 -32
  290. package/src/adapters/sources-record/index.test.ts +87 -0
  291. package/src/adapters/sources-record/index.ts +82 -0
  292. package/src/adapters/tex-output/blg.ts +27 -0
  293. package/src/adapters/tex-output/fls.ts +24 -0
  294. package/src/adapters/tex-output/index.test.ts +171 -0
  295. package/src/adapters/tex-output/index.ts +12 -0
  296. package/src/bib-rules.test.ts +356 -0
  297. package/src/bib-rules.ts +228 -0
  298. package/src/bibliography-at.test.ts +286 -0
  299. package/src/build.test.ts +210 -0
  300. package/src/build.ts +100 -35
  301. package/src/claim-provenance.test.ts +33 -7
  302. package/src/claim-provenance.ts +2 -2
  303. package/src/cli.harness.mjs +20 -10
  304. package/src/cli.test.ts +14 -10
  305. package/src/cli.ts +37 -28
  306. package/src/domain/paper-source.test.ts +58 -20
  307. package/src/domain/paper-source.ts +38 -42
  308. package/src/domain/paper-sources.ts +94 -0
  309. package/src/domain/sources-record.test.ts +71 -0
  310. package/src/domain/sources-record.ts +136 -0
  311. package/src/domain/tex-run.test.ts +228 -0
  312. package/src/domain/tex-run.ts +139 -0
  313. package/src/heading-case.test.ts +24 -10
  314. package/src/latex-log.ts +20 -0
  315. package/src/latex-loop.harness.ts +1 -0
  316. package/src/latex-loop.ts +2 -0
  317. package/src/linted-set.test.ts +9 -1
  318. package/src/new-paper.harness.mjs +3 -2
  319. package/src/new-paper.test.ts +9 -5
  320. package/src/package-dirs.ts +3 -2
  321. package/src/paper-includes.test.ts +157 -38
  322. package/src/paper-includes.ts +39 -35
  323. package/src/paper-prose.test.ts +34 -5
  324. package/src/paper-prose.ts +2 -2
  325. package/src/paper-record.test.ts +173 -0
  326. package/src/paper-record.ts +66 -0
  327. package/src/paper-settings-commands.test.ts +5 -2
  328. package/src/paper-sources.ts +151 -0
  329. package/src/ports/bib-reader.ts +27 -0
  330. package/src/ports/check-references.ts +7 -3
  331. package/src/ports/latex.ts +19 -0
  332. package/src/ports/sources-codec.ts +11 -0
  333. package/src/ports/tex-output.ts +17 -0
  334. package/src/recorded-bibliography.ts +297 -0
  335. package/src/reference-rules.ts +217 -104
  336. package/src/references.test.ts +520 -177
  337. package/src/references.ts +185 -37
  338. package/src/register-bands.test.ts +5 -1
  339. package/src/register-bands.ts +3 -5
  340. package/src/register.test.ts +6 -1
  341. package/src/register.ts +2 -2
  342. package/src/rule-cases.test.ts +68 -12
  343. package/src/rule-docs.test.ts +0 -1
  344. package/src/sources-record.test.ts +308 -0
  345. package/src/sources-record.ts +231 -0
  346. package/src/sources-rules.test.ts +152 -0
  347. package/src/sources-rules.ts +117 -0
  348. package/src/tex-paper.test.ts +148 -78
  349. package/src/tex-paper.ts +31 -61
  350. package/src/tex-venue-rules.test.ts +36 -8
  351. package/src/tex-venue-rules.ts +3 -5
  352. package/dist/eslint-rules/bib-reachable-entry.d.ts +0 -22
  353. package/dist/eslint-rules/bib-reachable-entry.d.ts.map +0 -1
  354. package/dist/eslint-rules/bib-reachable-entry.js +0 -75
  355. package/dist/eslint-rules/bib-reachable-entry.js.map +0 -1
  356. package/eslint-rules/bib-reachable-entry.ts +0 -76
package/scripts/check.ts CHANGED
@@ -30,8 +30,10 @@
30
30
  *
31
31
  * ── NO `--fast` FLAG, ON PURPOSE ───────────────────────────────────────────────────────────
32
32
  * A subset flag re-creates the exact failure above: the cheap half gets run and reported as
33
- * "the gates". The slow steps instead SKIP LOUDLY when their prerequisite is genuinely absent
34
- * (no TeX, no network), and the tail names every skip. "I ran check" then means one thing.
33
+ * "the gates". Every gate runs with CI=true, as in CI, so a local check is the CI check: the e2e
34
+ * steps are strict (`test/e2e/need.ts`) — a missing prerequisite (no TeX) FAILS with what to
35
+ * install, as it does in CI — and a test that still skips is named in the tail, never counted as a
36
+ * pass. "I ran check" then means one thing.
35
37
  */
36
38
  import { spawnSync } from "node:child_process";
37
39
  import { existsSync, readFileSync, rmSync } from "node:fs";
@@ -293,7 +295,9 @@ function runEach(
293
295
  cwd: ROOT,
294
296
  stdio: "inherit",
295
297
  encoding: "utf8",
296
- env: { ...process.env, PATH: BIN_FIRST_PATH },
298
+ // CI=true, as CI runs every gate: libraries read it (typescript-eslint infers a single run and
299
+ // parses from disk), so without it a local check is not the CI check (2026-10-07).
300
+ env: { ...process.env, CI: "true", PATH: BIN_FIRST_PATH },
297
301
  });
298
302
  // 🔴 The exit code is read from the command itself, never from a pipe. `cmd | tail && …`
299
303
  // reports the FILTER's status, which is almost always zero — that is how a red harness
@@ -21,7 +21,7 @@
21
21
  import assert from "node:assert/strict";
22
22
  import { spawnSync } from "node:child_process";
23
23
  import { globSync, readFileSync } from "node:fs";
24
- import { dirname, join, matchesGlob, resolve } from "node:path";
24
+ import { basename, dirname, join, matchesGlob, resolve } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import yaml from "js-yaml";
27
27
  import ts from "typescript";
@@ -146,16 +146,23 @@ test("every typeOnly exclusion has no runtime code", () => {
146
146
  });
147
147
 
148
148
  test("every evalOnly exclusion is imported only by an eval or another evalOnly module", () => {
149
- const evalOnly = new Set(expand(CONFIG.evalOnly).map((f) => join(ROOT, f)));
149
+ const only = expand(CONFIG.evalOnly);
150
+ const evalOnly = new Set(only.map((f) => join(ROOT, f)));
151
+ // Any import of one names it — `./x.mjs`, `#lib/x` — so a source whose text holds none of their
152
+ // base names imports none of them, and only the others are parsed.
153
+ const names = only.map((f) => basename(f).replace(/\.[^.]+$/, ""));
150
154
  const sources = globSync("**/*.{mjs,ts}", {
151
155
  cwd: ROOT,
152
156
  exclude: (p) => p === "node_modules" || p === "dist" || p === "coverage",
153
157
  });
154
158
  const importers = sources.flatMap((f) => {
155
159
  const abs = join(ROOT, f);
156
- return importedPaths(abs, readFileSync(abs, "utf8"))
157
- .filter((target) => evalOnly.has(target))
158
- .map(() => f);
160
+ const text = readFileSync(abs, "utf8");
161
+ return names.some((n) => text.includes(n))
162
+ ? importedPaths(abs, text)
163
+ .filter((target) => evalOnly.has(target))
164
+ .map(() => f)
165
+ : [];
159
166
  });
160
167
  const outsiders = importers.filter(
161
168
  (f) => !f.endsWith(".eval.mjs") && !evalOnly.has(join(ROOT, f)),
@@ -258,11 +265,13 @@ test("no measured source carries a coverage-ignore comment", () => {
258
265
  // The scan covers exactly what c8 measures: the same set, derived from the tracked files.
259
266
  assert.deepEqual(files, trackedSources().filter(isMeasured));
260
267
  assert.ok(files.length > 0);
261
- const offenders = files.filter((f) =>
262
- comments(f, readFileSync(join(ROOT, f), "utf8")).some((c) =>
263
- IGNORE.test(c),
264
- ),
265
- );
268
+ // An ignore comment's text matches IGNORE, so a source whose text does not holds none, and only
269
+ // the others have their comments read off the tree.
270
+ const offenders = files
271
+ .map((f) => [f, readFileSync(join(ROOT, f), "utf8")] as const)
272
+ .filter(([, text]) => IGNORE.test(text))
273
+ .filter(([f, text]) => comments(f, text).some((c) => IGNORE.test(c)))
274
+ .map(([f]) => f);
266
275
  assert.deepEqual(offenders, []);
267
276
  });
268
277
 
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { test } from "vitest";
10
10
  import {
11
11
  checkFrozen,
12
+ filesToLint,
12
13
  FROZEN_FILE,
13
14
  judge,
14
15
  main,
@@ -164,8 +165,9 @@ test("main: problems are listed and fail; a clean count passes", async () => {
164
165
  );
165
166
  });
166
167
 
167
- // Lints the whole of src/ with the real config: ~3 s alone, 5.6 s measured under c8 with the
168
- // suite running in parallel — past vitest's 5 s default, so the budget is stated here.
168
+ // Loads the real config (about 2.5 s) and lints the src/ files that carry a directive (21 of 223),
169
+ // measured 2026-10-07: 5.5 s alone, 10–11 s under c8 — past vitest's 5 s default, so the budget is
170
+ // stated here. Linting all of src/ took 22–23 s under c8 and timed out with the suite in parallel.
169
171
  test(
170
172
  "checkFrozen on this repository: nothing new, nothing grown — and on a tree with no src/, ESLint refuses loudly",
171
173
  {
@@ -186,6 +188,43 @@ test(
186
188
  },
187
189
  );
188
190
 
191
+ test("filesToLint: every src/ file whose text carries an eslint-disable directive, in any form — and no other", () => {
192
+ const root = useTempDir("layer-frozen-files-");
193
+ writeTree(root, {
194
+ "src/line.ts": "export const a = 1; // eslint-disable-line no-console\n",
195
+ "src/next.ts":
196
+ "// eslint-disable-next-line no-console\nexport const b = 1;\n",
197
+ "src/block.ts": "/* eslint-disable no-console */\nexport const c = 1;\n",
198
+ "src/deep/inner.ts":
199
+ "// eslint-disable-next-line x -- why\nexport const d = 1;\n",
200
+ "src/plain.ts": "export const e = 1;\n",
201
+ "lib/outside.ts": "// eslint-disable-next-line no-console\n",
202
+ });
203
+ assert.deepEqual(filesToLint(root), [
204
+ "src/block.ts",
205
+ "src/deep/inner.ts",
206
+ "src/line.ts",
207
+ "src/next.ts",
208
+ ]);
209
+ });
210
+
211
+ test("checkFrozen with the real ESLint on a tree: a new legacy directive in a file the list does not name still fails", async () => {
212
+ const root = useTempDir("layer-frozen-real-");
213
+ writeTree(root, {
214
+ [FROZEN_FILE]: JSON.stringify({ files: {} }),
215
+ "eslint.config.mjs":
216
+ 'export default [{ files: ["src/**/*.ts"], rules: { "no-restricted-globals": ["error", "process"] } }];\n',
217
+ "src/plain.ts": "export const quiet = 1;\n",
218
+ "src/new.ts": `// eslint-disable-next-line no-restricted-globals -- ${IO}\nexport const env = process;\n`,
219
+ });
220
+ const r = await checkFrozen(root);
221
+ assert.deepEqual(r.counts, { "src/new.ts": { "no-restricted-globals": 1 } });
222
+ assert.match(
223
+ r.problems.join("\n"),
224
+ /^src\/new\.ts: a new legacy layer exemption/,
225
+ );
226
+ });
227
+
189
228
  test("checkFrozen: a lint that returns no result reports that nothing was counted", async () => {
190
229
  const root = useTempDir("layer-frozen-empty-");
191
230
  writeTree(root, {
@@ -32,10 +32,15 @@
32
32
  * counted by construction — and a range directive (`/* eslint-disable … *\/` around a multi-line
33
33
  * import) counts exactly what it silences.
34
34
  *
35
+ * ESLint lints only the files that can carry one (`filesToLint`): a suppressed finding comes from a
36
+ * directive, and every directive's text holds `eslint-disable` (`-line`, `-next-line`, the block
37
+ * form). The bulk-suppressions file is not applied here. So the narrowing drops only files that
38
+ * cannot add to the count — 21 of 223 today — and the count still comes from ESLint's report.
39
+ *
35
40
  * Run: `node scripts/layer-legacy-frozen.ts` (also part of `npm run check`)
36
41
  */
37
42
  import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
38
- import { readFileSync } from "node:fs";
43
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
39
44
  import { dirname, join, relative, sep } from "node:path";
40
45
  import { fileURLToPath } from "node:url";
41
46
  import { ESLint } from "eslint";
@@ -195,11 +200,35 @@ function layerLint(cwd: string): ESLint {
195
200
  });
196
201
  }
197
202
 
203
+ /**
204
+ * The `src/**\/*.ts` files of `root` whose text holds `eslint-disable` — every file that can carry a
205
+ * suppressed finding — relative to `root`, POSIX separators, sorted.
206
+ */
207
+ export function filesToLint(root: string): string[] {
208
+ const src = join(root, "src");
209
+ const all = readdirSync(src, { recursive: true, withFileTypes: true })
210
+ .filter((e) => e.isFile() && e.name.endsWith(".ts"))
211
+ .map((e) => join(e.parentPath, e.name));
212
+ return all
213
+ .filter((f) => readFileSync(f, "utf8").includes("eslint-disable"))
214
+ .map((f) => relative(root, f).split(sep).join("/"))
215
+ .sort();
216
+ }
217
+
218
+ /**
219
+ * The files ESLint lints: those that can carry a suppression, or — when there are none, or no
220
+ * `src/` — the whole glob, so ESLint itself refuses a tree with no source.
221
+ */
222
+ const lintTargets = (root: string): string[] => {
223
+ const narrowed = existsSync(join(root, "src")) ? filesToLint(root) : [];
224
+ return narrowed.length > 0 ? narrowed : ["src/**/*.ts"];
225
+ };
226
+
198
227
  /** Lint `src/` with the repository's configuration and run the whole check. */
199
228
  export async function checkFrozen(
200
229
  root: string = ROOT,
201
230
  {
202
- lint = (cwd: string) => layerLint(cwd).lintFiles(["src/**/*.ts"]),
231
+ lint = (cwd: string) => layerLint(cwd).lintFiles(lintTargets(cwd)),
203
232
  }: { lint?: (cwd: string) => Promise<readonly Suppressed[]> } = {},
204
233
  ): Promise<FrozenCheck> {
205
234
  const data = FrozenFile.parse(
@@ -4,7 +4,7 @@ description: Use when asking "does this paper read well?" / "is the writing any
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:6401b9117bbf899f compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:21c8899648c9b223 compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
8
8
 
9
9
  # grade-paper-writing — grade how the paper READS, then fix it sentence by sentence
10
10
 
@@ -222,8 +222,9 @@ is what happened.
222
222
  node ${CLAUDE_SKILL_DIR}/prose-lint.mjs --headings <paper-dir>/paper.tex # the headings as a set
223
223
  ```
224
224
 
225
- It measures the prose a reader sees — includes spliced, no preamble, comments, floats or inline
226
- `.bib` — so its rates are the paper's, not the source file's. The skill is what must ACT on it.
225
+ It measures the prose a reader sees — the files the last build read spliced in, no preamble,
226
+ comments, floats or inline `.bib` — so its rates are the paper's, not the source file's. Run
227
+ `npx paperlint build` first: with no current build record it reads `paper.tex` alone and says so. The skill is what must ACT on it.
227
228
  Its thresholds are sourced; a FLAG means the text sits above a published human or machine
228
229
  baseline, which is a defect with a number attached, not an opinion.
229
230
  Two entries in `THRESHOLDS` exist because this skill previously ran five times without either
@@ -228,8 +228,9 @@ is what happened.
228
228
  node \${CLAUDE_SKILL_DIR}/prose-lint.mjs --headings <paper-dir>/paper.tex # the headings as a set
229
229
  \`\`\`
230
230
 
231
- It measures the prose a reader sees — includes spliced, no preamble, comments, floats or inline
232
- \`.bib\` — so its rates are the paper's, not the source file's. The skill is what must ACT on it.
231
+ It measures the prose a reader sees — the files the last build read spliced in, no preamble,
232
+ comments, floats or inline \`.bib\` — so its rates are the paper's, not the source file's. Run
233
+ \`npx paperlint build\` first: with no current build record it reads \`paper.tex\` alone and says so. The skill is what must ACT on it.
233
234
  Its thresholds are sourced; a FLAG means the text sits above a published human or machine
234
235
  baseline, which is a defect with a number attached, not an opinion.
235
236
  Two entries in \`THRESHOLDS\` exist because this skill previously ran five times without either
@@ -11,7 +11,9 @@
11
11
  * `8127 words` against ~4636 real ones — and every metric here is a fraction of that count, so all
12
12
  * of them came out understated by about half and printed `ok`. `paperProse` (src/paper-prose.ts)
13
13
  * gives the body as the `tex/register` rule reads it: includes spliced, the abstract to the back
14
- * matter, no headings, captions, floats, code or citation marks.
14
+ * matter, no headings, captions, floats, code or citation marks. The files spliced in are the ones the
15
+ * paper's last build recorded TeX reading (`_build/sources.json`); a paper with no current record is
16
+ * read as `paper.tex` alone, and the run says so on stderr rather than print rates of half a paper.
15
17
  *
16
18
  * A `.txt` is the text of the rendered PDF — the page a reviewer actually reads.
17
19
  *
@@ -21,8 +23,10 @@
21
23
  * Advisory: it prints findings; `--flags-only` exits with code 1 if anything was found.
22
24
  */
23
25
  import { paperProse } from "#src/paper-prose";
26
+ import { paperRecord } from "#src/paper-record";
24
27
  import { latexReader } from "#src/adapters/latex/index";
25
28
  import { nodeFiles } from "#src/adapters/node/index";
29
+ import { sourcesCodec } from "#src/adapters/sources-record/index";
26
30
  import { texToMdast } from "#eslint-rules/latex-language";
27
31
 
28
32
  const THRESHOLDS = {
@@ -481,9 +485,14 @@ const flagsOnly = args.includes("--flags-only");
481
485
  let flagged = 0;
482
486
  for (const f of args.filter((a) => !a.startsWith("--"))) {
483
487
  const raw = fs.readFileSync(f, "utf8");
488
+ const deps = { files: nodeFiles, latex: latexReader, codec: sourcesCodec };
484
489
  const tex = f.endsWith(".tex")
485
- ? paperProse(path.resolve(f), raw, { files: nodeFiles, latex: latexReader })
490
+ ? paperProse(path.resolve(f), raw, deps)
486
491
  : null;
492
+ if (tex && paperRecord(path.dirname(path.resolve(f)), deps).kind !== "fresh")
493
+ console.error(
494
+ `prose-lint: ${f} has no current build record (_build/sources.json), so only paper.tex was read, not the files it includes — run \`npx paperlint build\` first`,
495
+ );
487
496
  // 🔴 NOTHING LEFT IS NOT "CLEAN". A body that strips to no words used to be analysed anyway: every
488
497
  // metric is a fraction of the word count, so the report printed NaN, FLAGged three metrics and
489
498
  // exited 0. Refused — no number is better than a meaningless one. The test is on sentences: a
@@ -8,6 +8,8 @@ import { join } from "node:path";
8
8
  import { dirname } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { expect, test } from "vitest";
11
+ import { cpSync } from "node:fs";
12
+ import { layRecord, recordedTree } from "../../test/recorded-fixture.ts";
11
13
  import { runNode, useTempDir, writeTree } from "../../test/support.ts";
12
14
 
13
15
  const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "prose-lint.mjs");
@@ -17,16 +19,17 @@ const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "prose-lint.mjs");
17
19
  */
18
20
  const SPAWN_LATEX_MS = 60_000;
19
21
  /** An accepted paper whose bibliography sits inline in `filecontents`, beside a long preamble. */
20
- const ACCEPTED = join(
22
+ const ACCEPTED_DIR = join(
21
23
  dirname(fileURLToPath(import.meta.url)),
22
24
  "..",
23
25
  "..",
24
26
  "fixtures",
25
27
  "accepted-papers",
26
28
  "agenticdev-acm26",
27
- "paper.tex",
28
29
  );
29
30
  const root = useTempDir("prose-lint-");
31
+ /** The accepted paper as a build left it: a copy with TeX's record laid at `_build/sources.json`. */
32
+ const ACCEPTED = join(root, "accepted", "paper.tex");
30
33
 
31
34
  const LONG_CAPTION_SENTENCE = Array.from(
32
35
  { length: 45 },
@@ -37,7 +40,7 @@ const LONG_CAPTION_SENTENCE = Array.from(
37
40
  const tex = (body: string): string =>
38
41
  `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
39
42
 
40
- writeTree(root, {
43
+ const TREE = {
41
44
  "p/paper.tex": tex(
42
45
  "\\section{Introduction}\nIn this paper we present a linter. It might possibly help.\n\\input{figures/fig1}\n\\input{figures/fig2}\n\\input{figures/fig4}\n\\input{figures/fig5}",
43
46
  ),
@@ -66,7 +69,36 @@ writeTree(root, {
66
69
  "page.txt":
67
70
  "A rendered page. It was hyphen-\nated across a line.\n\n12\n\fNext page text here.\nReferences\n[1] cut here.\n",
68
71
  "notes.rtf": "not a paper",
69
- });
72
+ };
73
+ /** What each paper's last build read: its paper.tex, and the files it includes. */
74
+ const READ: Readonly<Record<string, readonly string[]>> = {
75
+ p: [
76
+ "paper.tex",
77
+ "figures/fig1.tex",
78
+ "figures/fig2.tex",
79
+ "figures/fig4.tex",
80
+ "figures/fig5.tex",
81
+ ],
82
+ clean: ["paper.tex"],
83
+ inline: ["paper.tex", "sec/results.tex"],
84
+ commented: ["paper.tex"],
85
+ "ends-on-marks": ["paper.tex"],
86
+ empty: ["paper.tex"],
87
+ };
88
+ writeTree(
89
+ root,
90
+ Object.entries(READ).reduce<Record<string, string>>(
91
+ (tree, [dir, files]) =>
92
+ recordedTree(
93
+ tree,
94
+ dir,
95
+ files.map((path) => ({ path, role: "body" as const })),
96
+ ),
97
+ TREE,
98
+ ),
99
+ );
100
+ cpSync(ACCEPTED_DIR, join(root, "accepted"), { recursive: true });
101
+ layRecord(join(root, "accepted"), join(ACCEPTED_DIR, "tex-truth.json"));
70
102
 
71
103
  test("no arguments: the usage line, exit 0", () => {
72
104
  assert.deepEqual(runNode(SCRIPT), {
@@ -157,6 +189,22 @@ test("--flags-only: a paper with no figures directory is clean, exit 0 and silen
157
189
  );
158
190
  });
159
191
 
192
+ test("🔴 a paper no build has recorded is read as paper.tex alone, and the run says so", () => {
193
+ writeTree(root, {
194
+ "unbuilt/paper.tex": tex("Short and plain. Nothing else."),
195
+ });
196
+ const file = join(root, "unbuilt", "paper.tex");
197
+ const r = runNode(SCRIPT, ["--flags-only", file]);
198
+ assert.deepEqual(
199
+ { status: r.status, stdout: r.stdout, stderr: r.stderr },
200
+ {
201
+ status: 0,
202
+ stdout: "",
203
+ stderr: `prose-lint: ${file} has no current build record (_build/sources.json), so only paper.tex was read, not the files it includes — run \`npx paperlint build\` first\n`,
204
+ },
205
+ );
206
+ });
207
+
160
208
  test("a .tex with no prose in its body is refused with exit 2, not reported as NaN", () => {
161
209
  const file = join(root, "empty", "paper.tex");
162
210
  assert.deepEqual(runNode(SCRIPT, [file]), {
@@ -9,7 +9,7 @@
9
9
  * 🔴 THREE MEASUREMENTS HERE — THESE ARE THREE DEFECTS THAT LIVED THE ENTIRE LIFE OF THE PREDECESSOR. Each
10
10
  * is pinned by an assertion on a REAL repository file, not a fixture, because all three
11
11
  * held exactly because nobody ever passed the real file:
12
- * 1. `refs.bib` was not in the list of sources (`resolveSource`);
12
+ * 1. `refs.bib` was never opened;
13
13
  * 2. the regex `.bib` required `}` on a separate line and gave 0 entries on both our files;
14
14
  * 3. in `.bib` an author is written "LastName, FirstName", but comparison took the last token.
15
15
  *
@@ -39,11 +39,18 @@ import {
39
39
  import { tmpdir } from "node:os";
40
40
  import { dirname, join } from "node:path";
41
41
  import { fileURLToPath } from "node:url";
42
+ import { bibReader } from "#src/adapters/bibtex/index";
43
+ import { absolutePath } from "#src/domain/paths";
44
+ import { sha256Hex } from "#src/domain/sha256";
45
+ import { serializeSourcesRecord } from "#src/domain/sources-record";
42
46
  import { consumerRoot } from "./consumer.mjs";
43
47
 
44
48
  const HERE = dirname(fileURLToPath(import.meta.url));
45
49
  const ROOT = consumerRoot();
46
50
  const X = await import(join(HERE, "extract-ref-facts.mjs"));
51
+ /** The facts' entries of a BibTeX text, read by the one bibtex reader. */
52
+ const factsOf = (text) =>
53
+ X.factEntries(bibReader.readFile(absolutePath("/p/refs.bib"), text));
47
54
 
48
55
  // 🔴 PAPERS ROOT — FROM THE DECLARATION, NOT BY THE FIRST CONSUMER'S DIRECTORY NAME (12.09.2026).
49
56
  // `package.json` → `paperlint.papersDir`, default `papers`. The same key is read by
@@ -70,38 +77,71 @@ const REAL_PAPERS = existsSync(PAPERS_ROOT)
70
77
  : [];
71
78
 
72
79
  const TMP = realpathSync(mkdtempSync(join(tmpdir(), "extract-ref-facts-")));
80
+
81
+ /**
82
+ * What `paperlint build` leaves in a paper's folder (`_build/sources.json`): bibtex opened `refs.bib`.
83
+ * The paper is read through that record and refused without it, so every paper here is built first.
84
+ */
85
+ function built(dir) {
86
+ const digest = (name) => sha256Hex(readFileSync(join(dir, name)));
87
+ mkdirSync(join(dir, "_build"), { recursive: true });
88
+ writeFileSync(
89
+ join(dir, "_build", "sources.json"),
90
+ serializeSourcesRecord({
91
+ schema: 1,
92
+ inputs: [
93
+ { path: "paper.tex", role: "body" },
94
+ { path: "refs.bib", role: "preamble" },
95
+ ],
96
+ written: [],
97
+ bibdata: ["refs"],
98
+ bibtex: {
99
+ ran: true,
100
+ databases: ["refs.bib"],
101
+ keys: [],
102
+ exit: 0,
103
+ errors: [],
104
+ },
105
+ sha256: {
106
+ "paper.tex": digest("paper.tex"),
107
+ "refs.bib": digest("refs.bib"),
108
+ },
109
+ }),
110
+ );
111
+ }
73
112
  // Cleanup is attached IMMEDIATELY: assertions throw, and "rmSync at end of file" does not execute precisely in those
74
113
  // runs that are red.
75
114
  process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
76
115
 
77
- // ── 1. DEFECT #1: `refs.bib` must be in the list of sources ─────────────────
116
+ // ── 1. DEFECT #1: the `.bib` a paper declares is opened ──────────────────────
117
+ //
118
+ // The build recorded that bibtex opened `refs.bib`, and `bibliographyAt` opens it. A fixture paper
119
+ // holds the frozen real `.bib`, so the leg is proven on a real file in every checkout.
78
120
  {
79
- assert.ok(
80
- X.SOURCE_ORDER.includes("refs.bib"),
81
- `refs.bib missing from SOURCE_ORDER — this is the defect that kept the .bib leg from ever opening: ${X.SOURCE_ORDER.join(", ")}`,
121
+ const dir = join(TMP, "declares-real-bib");
122
+ mkdirSync(dir, { recursive: true });
123
+ writeFileSync(
124
+ join(dir, "paper.tex"),
125
+ "\\documentclass{article}\\begin{document}x\\bibliography{refs}\\end{document}\n",
82
126
  );
83
- // 🔴 SUBJECT — FIXTURE, real papers ADDITION (12.09.2026). There used to be only a loop over
84
- // our two papers with `continue` if file doesn't exist. In this repo they exist, and test is green;
85
- // in the extracted pipeline their directories WON'T EXIST — both loops make no iteration, and
86
- // harness prints success having checked zero files. "Green over void", a class this base has paid for thrice. Fixture makes an empty run INEXPRESSIBLE: it always lies nearby.
87
- //
88
- // ⚠️ Directory, not file: `resolveSource` returns the path itself if it IS A FILE, and only for
89
- // a directory descends the `SOURCE_ORDER`. A fixture-file would check the wrong branch.
90
- {
91
- const dir = join(HERE, "fixtures", "real-bib");
92
- assert.equal(
93
- X.resolveSource(dir),
94
- join(dir, "refs.bib"),
95
- "resolveSource cannot find the frozen real .bib — .bib leg is dead again",
96
- );
97
- }
127
+ writeFileSync(
128
+ join(dir, "refs.bib"),
129
+ readFileSync(join(HERE, "fixtures", "real-bib", "refs.bib")),
130
+ );
131
+ built(dir);
132
+ const found = X.bibliographyFrom(dir);
133
+ assert.deepEqual(
134
+ found.ok && found.texts.map((t) => t.path),
135
+ [join(dir, "refs.bib")],
136
+ `a paper declaring \\bibliography{refs} does not open its refs.bib — the .bib leg is dead again: ${JSON.stringify(found.why ?? "")}`,
137
+ );
138
+ // Real consumer papers: whatever each declares is either read or refused WITH A REASON — never a
139
+ // silent nothing.
98
140
  for (const paper of REAL_PAPERS) {
99
- const dir = join(PAPERS_ROOT, paper);
100
- if (!existsSync(join(dir, "refs.bib"))) continue;
101
- assert.equal(
102
- X.resolveSource(dir),
103
- join(dir, "refs.bib"),
104
- `resolveSource cannot find real refs.bib for ${paper} — .bib leg is dead again`,
141
+ const r = X.bibliographyFrom(join(PAPERS_ROOT, paper));
142
+ assert.ok(
143
+ r.ok ? r.texts.length > 0 : r.why.length > 0,
144
+ `${paper}: neither a bibliography nor a reason`,
105
145
  );
106
146
  }
107
147
  }
@@ -128,7 +168,7 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
128
168
  parsed++;
129
169
  const text = readFileSync(f, "utf8");
130
170
  const want = (text.match(/^@\w+\{/gm) ?? []).length;
131
- const got = await X.parseBib(text);
171
+ const got = factsOf(text);
132
172
  assert.ok(
133
173
  want > 0,
134
174
  `${paper}/refs.bib: no @… entries in file — fixture is broken`,
@@ -170,7 +210,7 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
170
210
  @misc{inst2,
171
211
  author={{sh-guard contributors}},
172
212
  title={Another}, year={2026}}`;
173
- const es = await X.parseBib(bib);
213
+ const es = factsOf(bib);
174
214
  assert.deepEqual(
175
215
  es.map((e) => e.authors),
176
216
  [["Adversa AI"], ["sh-guard contributors"]],
@@ -185,7 +225,7 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
185
225
  // single brace — ordinary author, and order "LastName, FirstName" must be unwound.
186
226
  {
187
227
  const bib = `@misc{human, author={Adversa, Alice}, title={T}, year={2026}}`;
188
- const [e] = await X.parseBib(bib);
228
+ const [e] = factsOf(bib);
189
229
  assert.deepEqual(
190
230
  e.authors,
191
231
  ["Alice Adversa"],
@@ -201,7 +241,7 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
201
241
  const bib = `@inproceedings{k1,
202
242
  author={Jimenez, Carlos E. and Di Penta, Massimiliano and others},
203
243
  title={A Title}, booktitle={ICLR}, year={2024}, note={arXiv:2310.06770}}`;
204
- const [e] = await X.parseBib(bib);
244
+ const [e] = factsOf(bib);
205
245
  assert.deepEqual(
206
246
  e.authors,
207
247
  ["Carlos E. Jimenez", "Massimiliano Di Penta"],
@@ -313,7 +353,12 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
313
353
  const { spawnSync } = await import("node:child_process");
314
354
  const dir = join(TMP, "empty");
315
355
  mkdirSync(dir, { recursive: true });
356
+ writeFileSync(
357
+ join(dir, "paper.tex"),
358
+ "\\documentclass{article}\\begin{document}x\\bibliography{refs}\\end{document}\n",
359
+ );
316
360
  writeFileSync(join(dir, "refs.bib"), "% no entries at all\n");
361
+ built(dir);
317
362
  const r = spawnSync(
318
363
  "node",
319
364
  [join(HERE, "extract-ref-facts.mjs"), dir, "--offline"],
@@ -346,8 +391,8 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
346
391
  );
347
392
  assert.equal(r.status, 1);
348
393
  assert.ok(
349
- /refs\.bib/u.test(r.stderr),
350
- "message must list sources, including refs.bib",
394
+ /no paper\.tex/u.test(r.stderr),
395
+ `message must say what is missing: ${r.stderr}`,
351
396
  );
352
397
  }
353
398
 
@@ -360,6 +405,11 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
360
405
  "@misc{lovelace2025,\n author={Lovelace, Ada and Hopper, Grace},\n title={A Study},\n" +
361
406
  " note={arXiv:2503.09002},\n year={2025}}\n";
362
407
  writeFileSync(join(dir, "refs.bib"), paper);
408
+ writeFileSync(
409
+ join(dir, "paper.tex"),
410
+ "\\documentclass{article}\\begin{document}x\\bibliography{refs}\\end{document}\n",
411
+ );
412
+ built(dir);
363
413
  writeFileSync(
364
414
  join(dir, "repro", "refs-cache.json"),
365
415
  JSON.stringify({
@@ -390,6 +440,6 @@ process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
390
440
  }
391
441
 
392
442
  console.log(
393
- "✓ extract-ref-facts: refs.bib in sources, both real .bib parse completely, name order unwound, " +
443
+ "✓ extract-ref-facts: the declared refs.bib is opened, both real .bib parse completely, name order unwound, " +
394
444
  "arXiv-DOI does not go to CrossRef, registry readers run on raw bodies, zero entries = refusal",
395
445
  );