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
@@ -1,95 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * prose-lint.mjs — countable checks for "reads machine-written" expository prose.
4
- * Usage: node prose-lint.mjs <file.md|file.txt> [--flags-only|--headings]
3
+ * prose-lint.mjs — countable checks for "reads machine-written" expository prose, on a paper's
4
+ * LaTeX source or on the text of its rendered PDF.
5
+ * Usage: node prose-lint.mjs <paper.tex|page.txt> [more files] [--flags-only|--headings]
5
6
  *
6
7
  * Every check counts something. Thresholds and their provenance are in THRESHOLDS.
7
8
  *
8
- * 🔴 WHAT LEFT THIS FILE ON 2026-08-26 AND WHERE IT WENT. Seven of the twelve gating metrics were
9
- * moved into `@eslint/markdown` rules — `eslint-rules/paper-craft.mjs`:
10
- * citation-density · unexplained-jargon · multi-claim-sentence · conceits ·
11
- * hedge-density · discourse-subject · undefined-coinage.
12
- * Parity was proven on a real paper BEFORE the deletion (the sets of findings matched, the sentence
13
- * heads byte for byte); the measurement and the classification of all twelve are in
14
- * `papers/research/2026-08-26-klassifikatsiya-prose-lint.md`.
15
- * Do NOT add them back: a fact has one home, and `paper-craft.harness.mjs` fails if the number of
16
- * gating metrics here grows.
9
+ * A `.tex` IS READ THROUGH THE PAPER'S PROSE, NOT AS TEXT. Read raw, a `.tex` put the preamble, the
10
+ * `%` comments and the `filecontents` bibliography into the word count — measured on one paper:
11
+ * `8127 words` against ~4636 real ones — and every metric here is a fraction of that count, so all
12
+ * of them came out understated by about half and printed `ok`. `paperProse` (src/paper-prose.ts)
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. 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.
17
17
  *
18
- * 🔴 THE SECOND BATCH, 2026-09-06: TWO MORE LEFT, AND THE RECEIVING RULES WERE EXTENDED FIRST.
19
- * frameMarkersPer1000 → `paper/metadiscourse`, the `density` finding. The dictionary here was wider
20
- * (17 patterns against 9), so the dictionaries were merged first and the threshold removed after.
21
- * ⚠️ NOT all seventeen were merged: the patterns `\bfirst(ly)?\b(?=[ ,])` / `second` / `third`
22
- * gave 23 matches out of 27 on `compile-rules-2026/paper.md`, and ZERO of them were REAL
23
- * ("Only the second one runs", "The first two rows", "A third group ignores"). Those are
24
- * ordinary adjectives. What went into the rule is Hyland's form — "First," at the start of a
25
- * clause; over four papers it gives 9 matches, all real;
26
- * epanorthosisPer10k → `paper/ai-tells`, together with all four forms and with Boggia's baseline.
27
- * The receiving rule knew only one form, and that regex caught "is not X," WITHOUT the
28
- * substituted replacement — five of its matches on a real paper were not the construction at
29
- * all. Parity: the rule prints the same `17.2/10k` and the same 10 sentences.
18
+ * A `.txt` is the text of the rendered PDF — the page a reviewer actually reads.
30
19
  *
31
- * 🔴 RETRACTED 2026-09-06: "A SECOND ENTITY ON THE FILESYSTEM ⇒ UNAVAILABLE TO AN ESLINT RULE" IS
32
- * WRONG. The paragraph below is kept as a record of what used to be written here, but IT MUST NOT BE
33
- * ACTED ON. Measured: `eslint-rules/paper-registry.mjs` makes 8 disk accesses and reads neighbouring
34
- * files of the paper's directory; `pdf-facts.mjs` opens a PDF and computes sha256; `paper-texcount.mjs`
35
- * RUNS an external program through `execFileSync`. An ESLint rule executes in node, and `readFileSync`
36
- * is available to it exactly as it is to this script. The real "one file at a time" model is about
37
- * WHERE a rule reports, not about what it reads.
38
- * ⇒ All three metrics were reclassified as MOVABLE and are queued as work:
39
- * `node .claude/lib/refactor-state.mjs --index`, the "MOVABLE" section.
40
- * `captionSentenceWords` / `captionWords` — after one edit of `files:` in `eslint.config.mjs`
41
- * (today only the paper's own `paper.tex` is listed there, while the captions live in `figures/`).
42
- *
43
- * ✅ `abstractVsVenueMedian` WAS MOVED 2026-09-07 → `eslint-rules/paper-craft.mjs`, the rule
44
- * `paper/abstract-length` (`warn`). It was the one "without a single condition": the median is read
45
- * from the neighbouring `CLAUDE.md` exactly as `pdf/profile` reads a venue profile, and the address
46
- * of the finding is the `## Abstract` line in the paper itself. Parity was taken BEFORE the deletion
47
- * on `compile-rules-2026/paper.md`: 306 words against a ceiling of 305, one finding in both places,
48
- * the same four numbers. The test is `eslint-rules/paper-craft.harness.mjs`.
49
- *
50
- * WHAT IS LEFT AND WHY — TWO. The paragraph below was written when there were three, and is kept as
51
- * a record: they read a SECOND ENTITY on the filesystem, while an ESLint rule sees one linted file:
52
- * ← RETRACTED, see above
53
- * captionSentenceWords, captionWords → the input is `figures/*.tex` NEXT TO the paper.
54
- *
55
- * ⚠️ `.tex` IS NO LONGER ACCEPTED, AND THAT IS FIXING A LIE, NOT NARROWING THE SCOPE. The header
56
- * declared `<file.md|file.tex|->` from the very beginning, while there is not one line of LaTeX
57
- * stripping in the file. Measured 2026-08-26 on `agenticdev-2026/paper.tex`: it prints `8127 words`
58
- * against ~4636 real ones — 43% of that is the preamble, `%` comments and `filecontents`
59
- * bibliography entries, and the first items in the list of long sentences are `.bib` lines. Every
60
- * metric in this file is a fraction of the word count, so on `.tex` they were all understated by
61
- * roughly half and printed `ok`. Nobody ever called it with `.tex` anyway (CI explicitly prints
62
- * `NOT COVERED` for .tex papers), so the cost of refusing is zero, while the cost of a confident
63
- * wrong number is not. A LaTeX parser is deliberately not built here: the occupancy of the tools was
64
- * measured separately, see `papers/research/2026-08-26-proza-v-latex-zanyatost.md`.
20
+ * The figure captions are measured too (`--flags-only`): every `\caption{}` in `paper.tex` and the
21
+ * files it includes, or, beside a rendered page, in `figures/*.tex`.
65
22
  *
66
23
  * Advisory: it prints findings; `--flags-only` exits with code 1 if anything was found.
67
24
  */
68
- import {
69
- headings as mdHeadings,
70
- stripFences,
71
- requireMarkdown,
72
- stripFrontmatter,
73
- } from "#lib/markdown";
74
-
75
- // Markup is parsed with a PARSER (`CLAUDE.md`, 2026-08-11). We fail rather than degrade, even
76
- // though this file is advisory: without a parser the paper's body would collapse to empty, and from
77
- // empty this linter derives zero for EVERY metric — that is, a report saying "the prose is perfect".
78
- // `paper-lint.mjs` runs us as a subprocess and prints our stderr, so the reason is visible, and its
79
- // own gate does not suffer: it swallows our exit code deliberately.
80
- requireMarkdown();
25
+ import { paperProse } from "#src/paper-prose";
26
+ import { paperRecord } from "#src/paper-record";
27
+ import { latexReader } from "#src/adapters/latex/index";
28
+ import { nodeFiles } from "#src/adapters/node/index";
29
+ import { sourcesCodec } from "#src/adapters/sources-record/index";
30
+ import { texToMdast } from "#eslint-rules/latex-language";
81
31
 
82
32
  const THRESHOLDS = {
83
- // frameMarkersPer1000 — moved 2026-09-06 into `eslint-rules/paper-prose.mjs`, into the rule
84
- // `paper/metadiscourse` (the `density` finding), together with Hyland's provenance and the
85
- // threshold of 10. The FRAME_MARKERS dictionary below STAYS: it is a term in
86
- // metadiscourseTotalPer1000.
87
33
  metadiscourseTotalPer1000: {
88
34
  warn: 90,
89
35
  src: "Hyland 2005 Table 5.1 total 64.9/1000; Table 7.1 totals 60.0-73.6/1000. Warn ~1.4x.",
90
36
  },
91
- // pureFrameSentencePct / propositionsPerSentence — moved into `eslint-rules/paper-craft.mjs`
92
- // as `discourse-subject` and `multi-claim-sentence` (2026-08-26).
93
37
  firstMentionDefinites: {
94
38
  warn: 3,
95
39
  src: 'No published rate. Operationalises Pinker\'s curse of knowledge: "the X" on first mention presupposes shared knowledge.',
@@ -106,37 +50,17 @@ const THRESHOLDS = {
106
50
  warn: 0,
107
51
  src: "Gopen & Swan 1990 principle 2: put the new information you want emphasised in the stress position. A trailing citation/cross-reference/hedge wastes it.",
108
52
  },
109
- // hedgeWordPct — moved into `eslint-rules/paper-craft.mjs` as `hedge-density` (2026-08-26).
110
- // The HEDGES lexicon STAYS here: it is a term in metadiscourseTotalPer1000 and in the wasted
111
- // stress-position detector. Deleting it would silently understate both metrics.
112
- // epanorthosisPer10k — moved 2026-09-06 into `eslint-rules/paper-prose.mjs`, into the rule
113
- // `paper/ai-tells`, together with the ONLY published baseline in the whole set
114
- // (Boggia 2026, arXiv 2607.21498: humans 3.6, models 7.8 per 10 000 words; threshold 15.6) and
115
- // together with the EPANORTHOSIS lexicon. The metric's history — "it was counted and PRINTED
116
- // WITHOUT A THRESHOLD from the day the linter was written, sat at 29.8 through five editing
117
- // passes, and the corpus owner found the tic by eye before the tool did, because measuring is not
118
- // checking" — moved into the rule's header: it explains why a metric must have a threshold and not
119
- // only a number.
53
+ // The HEDGES lexicon is a term in metadiscourseTotalPer1000 and in the wasted stress-position
54
+ // detector. Deleting it would silently understate both metrics.
120
55
 
121
56
  contrastivePer10k: {
122
57
  warn: 25,
123
58
  src: 'ours. "rather than" is an ordinary connective; a pile of them is a register problem, but it is NOT the construction Boggia measured and must not borrow that baseline.',
124
59
  },
125
- // conceitsPer10k — moved into `eslint-rules/paper-craft.mjs` as `conceits` (2026-08-26).
126
-
127
- // Abstract length, as a multiple of the TARGET VENUE's own median. The corpus owner, 2026-08-05:
128
- // "abstract should be closer to median. no longer than 50% [more than] median". For REALM the measured
129
- // median over all 34 archival papers is 203 words, so the ceiling is ~305. The previous guidance
130
- // in this project pointed at the venue MAXIMUM (518) and was wrong in the way maxima always are:
131
- // it licensed 654 words, longer than any abstract that workshop has ever published, and read as
132
- // an introduction rather than an abstract. Measure the venue, take the median, allow half again.
133
60
 
134
- // citationsPerParagraph / jargonPerSentence — moved into `eslint-rules/paper-craft.mjs` as
135
- // `citation-density` and `unexplained-jargon` (2026-08-26). The provenance of both thresholds,
136
- // including the history of "count GROUPS, not numbers", moved with them.
137
- // 🔴 FIGURE CAPTIONS ARE PROSE THAT NOTHING WAS READING. They live in figures/*.tex, not in
138
- // paper.md, so every writing pass, every persona read and every threshold in paper-lint's pre-edit
139
- // gate was blind to them by construction. Found 2026-08-05 when a reader hit a 136-word caption
61
+ // 🔴 FIGURE CAPTIONS ARE PROSE THAT NOTHING WAS READING. They sit in floats, outside the body
62
+ // the other metrics read, so every writing pass and every persona read was blind to them by
63
+ // construction. Found 2026-08-05 when a reader hit a 136-word caption
140
64
  // containing a 54-word sentence — one word under the limit that would have blocked the same
141
65
  // sentence had it been typed into the paper. An entire text surface, unchecked.
142
66
  captionSentenceWords: {
@@ -149,13 +73,9 @@ const THRESHOLDS = {
149
73
  },
150
74
  };
151
75
 
152
- // CONCEITS — moved into `eslint-rules/paper-craft.mjs` (the `conceits` rule, 2026-08-26)
153
- // together with its whole tuning history.
154
-
155
76
  // --- lexicons ---
156
- // 🔴 FRAME_MARKERS STAYS EVEN THOUGH ITS THRESHOLD LEFT (2026-09-06) — for exactly the same reason
157
- // as HEDGES above: it is a term in `metadiscourseTotalPer1000`, and deleting it would silently
158
- // understate that metric. ⚠️ AND HERE IS A KNOWN DEFECT, measured the same day and NOT fixed: the
77
+ // 🔴 FRAME_MARKERS has no threshold of its own: it is a term in `metadiscourseTotalPer1000`, and
78
+ // deleting it would silently understate that metric. ⚠️ AND HERE IS A KNOWN DEFECT, measured the same day and NOT fixed: the
159
79
  // three ordinal-numeral patterns give 23 matches out of 27 on a real paper, and none of them are
160
80
  // real, while `comes? first` is written TWICE (the second time as `come(s)? (first|before)`), so
161
81
  // every "comes first" is counted as two. That means `metadiscourse/1000w` in the report below is
@@ -284,9 +204,6 @@ const NEGATORS = [
284
204
  /\bfail(s|ed)? to\b/gi,
285
205
  /\bunable\b/gi,
286
206
  ];
287
- // EPANORTHOSIS — moved 2026-09-06 into `eslint-rules/paper-prose.mjs` (the `paper/ai-tells` rule)
288
- // together with all four forms and with their history, including the one that turned the list into a
289
- // list: the appositive "Admission, not translation", which reaches as far as HEADINGS.
290
207
 
291
208
  /**
292
209
  * `rather than` — moved OUT of EPANORTHOSIS on 2026-08-05, and the reason is arithmetic, not taste.
@@ -310,8 +227,6 @@ const CONTRASTIVE = [
310
227
  // nominalisations: -tion/-sion/-ment/-ance/-ence/-ity/-ness/-ing used as head noun (approximate)
311
228
  const NOMINAL = /\b\w{4,}(tion|sion|ment|ance|ence|ity|ness)s?\b/gi;
312
229
 
313
- // JARGON — moved into `eslint-rules/paper-craft.mjs` (the `unexplained-jargon` rule, 2026-08-26).
314
-
315
230
  /** Matches of every pattern in the lexicon `pats`, summed. Every caller passes a lexicon array. */
316
231
  const count = (t, pats) =>
317
232
  pats.reduce((n, p) => n + (t.match(p) || []).length, 0);
@@ -322,46 +237,27 @@ const count = (t, pats) =>
322
237
  // remedy, and it was not wired into the report. Deleted; the hole — "the report prints counters
323
238
  // without the offending lines" — is recorded here.
324
239
 
325
- // 🔴 Paragraph and heading boundaries END a sentence. The first version collapsed all whitespace
326
- // first, so a heading ran into the paragraph under it and the last sentence of one paragraph ran
327
- // into the first of the next. Measured 2026-08-05: that inflated "sentences carrying >2
240
+ // 🔴 Paragraph boundaries END a sentence. The first version collapsed all whitespace first, so a
241
+ // heading ran into the paragraph under it and the last sentence of one paragraph ran into the first
242
+ // of the next. Measured 2026-08-05: that inflated "sentences carrying >2
328
243
  // propositions" to 55, and the top offenders printed by the report were not sentences at all —
329
244
  // they were a title glued to an abstract. The metric therefore looked like noise, which is exactly
330
245
  // why nobody ever wired it into the flags that hooks and pre-commit consume. A miscounted metric
331
246
  // does not get argued with; it gets ignored.
332
- // 2026-08-11: heading lines are struck out BEFORE splitting, and the parser is what strikes them
333
- // out. This used to be done by the filter `!/^#{1,6}\s/.test(l)` inside `flatMap`, and it erred in
334
- // both directions silently: it missed a heading indented by 1-3 spaces (CommonMark counts it as a
335
- // heading) and struck out a `# …` line inside a ``` block — that is, CODE quoted by the paper fell
336
- // soundlessly out of the sentence count, and this file counts nothing but sentences.
337
- // The order is preserved: a line is struck out, not a paragraph, so the paragraph boundaries are
338
- // the same.
339
- function stripHeadingLines(t) {
340
- const drop = new Set(mdHeadings(t).map((h) => h.line));
341
- return drop.size === 0
342
- ? t
343
- : t
344
- .split("\n")
345
- .filter((_, i) => !drop.has(i))
346
- .join("\n");
347
- }
247
+ // Headings never reach this function: the paper's prose comes without them (`paperProse`), and a
248
+ // rendered page sets them as lines of their own.
348
249
  function splitSentences(t) {
349
- return (
350
- stripHeadingLines(t)
351
- .split(/\n\s*\n/) // paragraphs never run together
352
- // A list item is its own unit. Without this the last sentence of one bullet ran into the first
353
- // of the next and the claim counter blamed the pair for the merge.
354
- .flatMap((p) => p.split(/\n(?=\s*(?:[-*]\s|\d+\.\s))/))
355
- .flatMap((p) =>
356
- p
357
- .split("\n")
358
- .join(" ") // headings are already struck out, see above
359
- .replace(/\s+/g, " ")
360
- .split(/(?<=[.!?])\s+(?=[A-Z“"(*`])/),
361
- )
362
- .map((s) => s.trim())
363
- .filter(Boolean)
364
- );
250
+ return t
251
+ .split(/\n\s*\n/) // paragraphs never run together
252
+ .flatMap((p) =>
253
+ p
254
+ .split("\n")
255
+ .join(" ")
256
+ .replace(/\s+/g, " ")
257
+ .split(/(?<=[.!?])\s+(?=[A-Z“"(*`])/),
258
+ )
259
+ .map((s) => s.trim())
260
+ .filter(Boolean);
365
261
  }
366
262
  const words = (t) => (t.match(/[A-Za-z0-9%.'’-]+/g) || []).length;
367
263
 
@@ -390,11 +286,7 @@ function analyse(text, sents) {
390
286
  };
391
287
  const mdTotal = Object.values(cats).reduce((a, b) => a + b, 0);
392
288
 
393
- // per-sentence structure.
394
- // `props` (>2 propositions) and the "discourse subject" moved into
395
- // `eslint-rules/paper-craft.mjs` — the rules `multi-claim-sentence` and `discourse-subject`
396
- // (2026-08-26). What stayed here is the wasted stress position, negation and the topic position:
397
- // they had no thresholds in the gate and still have none, they are printed by the report.
289
+ // Per sentence: the wasted stress position, negation and the topic position.
398
290
  const perSent = sents.map((s) => {
399
291
  const sw = words(s);
400
292
  const frame = count(s, FRAME_MARKERS);
@@ -455,55 +347,35 @@ function analyse(text, sents) {
455
347
  };
456
348
  }
457
349
 
458
- // coinages() + DEFINING_CUE + STOPWORDS — moved into `eslint-rules/paper-craft.mjs`
459
- // (the `undefined-coinage` rule, 2026-08-26) together with the whole justification of the heuristic.
460
-
461
- /**
462
- * An inventory of headings — all that is left of `structure()`.
463
- *
464
- * 🔴 The two checks that lived here (citation density in a paragraph and a pile-up of jargon in a
465
- * sentence) moved on 2026-08-26 into `eslint-rules/paper-craft.mjs` — `citation-density` and
466
- * `unexplained-jargon`. The reason they were SEPARATE from `analyse()` has not gone anywhere and is
467
- * now expressed by construction: an ESLint rule has the document tree, so the question "how many
468
- * stops are in THIS paragraph" is asked directly rather than reconstructed from a collapsed stream
469
- * of words.
470
- */
471
- function structure(body) {
472
- // Not a gate — an INVENTORY. "A heading names a concrete noun" is a judgement call and pretending
473
- // otherwise would be the failure this file exists to stop. What is mechanical, and what was
474
- // missing, is that nobody ever saw all the headings AT ONCE: the whole-document question. Two
475
- // headings a reader called "VAGUE AF" and "why not mention the linter??" survived six passes
476
- // because each pass looked at the section it was editing.
477
- // 2026-08-11: the heading inventory comes from the parser. The previous `/^#{2,4} .+$/gm` also
478
- // printed lines from ``` blocks in this list: an inventory read BY EYE as "all the paper's
479
- // headings" silently mixed them with quotations of someone else's markup. The parser hands back
480
- // the heading text already trimmed at the edges — the previous `.replace(/^#+\s*/, '')` left
481
- // trailing spaces.
482
- const headings = mdHeadings(body)
483
- .filter((h) => h.depth >= 2 && h.depth <= 4)
484
- .map((h) => h.text);
485
-
486
- // 🔴 The document's SHAPE — outline, order, section weights — deliberately does NOT live here.
487
- // It was written here first and the author drew the line correctly: "I thought prose was about
488
- // prose, and structure is something else entirely" (said in Russian). Prose-lint judges sentences.
489
- // Structure belongs to tighten-paper, which had no mechanical leg at all, so the inventory now
490
- // lives there:
491
- // node .claude/skills/tighten-paper/structure.mjs <paper.md> [--section=N]
492
- return { headings };
493
- }
494
-
495
- /** Captions from figures/*.tex beside the paper — the text surface nothing was reading. */
496
- function captions(paperPath, fs, path) {
497
- const dir = path.join(path.dirname(paperPath), "figures");
350
+ /** The `.tex` files of `figures/` beside a rendered page, each as `{ file, text }`. */
351
+ function figureFiles(pagePath, fs, path) {
352
+ const dir = path.join(path.dirname(pagePath), "figures");
498
353
  let files; // no initialiser: the catch branch returns, only try assigns (2026-08-28)
499
354
  try {
500
355
  files = fs.readdirSync(dir).filter((f) => f.endsWith(".tex"));
501
356
  } catch {
502
357
  return [];
503
358
  }
359
+ return files.map((f) => ({
360
+ file: `figures/${f}`,
361
+ text: fs.readFileSync(path.join(dir, f), "utf8"),
362
+ }));
363
+ }
364
+
365
+ /**
366
+ * Every `\caption{…}` of `sources` (`{ file, text }`) a reader sees, measured. The LaTeX
367
+ * language's projection blanks what never typesets — verbatim and code, macro definitions, the
368
+ * preamble — and keeps a caption's argument; it keeps comments too, as `html` nodes of its tree,
369
+ * for the rules that read notes. So a caption is skipped when its argument is blank in the
370
+ * projection or it starts inside a comment: a quoted or commented-out `\caption` is not one.
371
+ */
372
+ function captions(sources) {
504
373
  const out = [];
505
- for (const f of files) {
506
- const s = fs.readFileSync(path.join(dir, f), "utf8");
374
+ for (const { file: f, text: s } of sources) {
375
+ const { root, text: seen } = texToMdast(s);
376
+ const comments = root.children
377
+ .filter((n) => n.type === "html")
378
+ .map((n) => [n.position.start.offset, n.position.end.offset]);
507
379
  for (const m of s.matchAll(/\\caption\{/g)) {
508
380
  let i = m.index + m[0].length,
509
381
  depth = 1,
@@ -513,6 +385,11 @@ function captions(paperPath, fs, path) {
513
385
  else if (s[j] === "}") depth--;
514
386
  j++;
515
387
  }
388
+ if (
389
+ !/\S/.test(seen.slice(i, j - 1)) ||
390
+ comments.some(([a, b]) => m.index >= a && m.index < b)
391
+ )
392
+ continue;
516
393
  const text = s
517
394
  .slice(i, j - 1)
518
395
  .replace(/\\[a-zA-Z]+\s*/g, " ")
@@ -521,13 +398,13 @@ function captions(paperPath, fs, path) {
521
398
  .replace(/\s+/g, " ")
522
399
  .trim();
523
400
  if (!text) continue;
401
+ // A non-empty caption splits into at least one sentence.
524
402
  const sents = splitSentences(text);
525
403
  out.push({
526
404
  file: f,
527
405
  words: words(text),
528
- longest: Math.max(0, ...sents.map(words)),
529
- longestSentence:
530
- sents.slice().sort((a, b) => words(b) - words(a))[0] ?? "",
406
+ longest: Math.max(...sents.map(words)),
407
+ longestSentence: sents.slice().sort((a, b) => words(b) - words(a))[0],
531
408
  });
532
409
  }
533
410
  }
@@ -544,18 +421,9 @@ function report(name, r) {
544
421
  `${flag(r.metadiscoursePer1000 <= THRESHOLDS.metadiscourseTotalPer1000.warn)} all metadiscourse/1000w ${r.metadiscoursePer1000} (Hyland RA baseline 64.9, warn >${THRESHOLDS.metadiscourseTotalPer1000.warn})`,
545
422
  );
546
423
  console.log(` nominalisations/1000w ${r.nominalisationsPer1000}`);
547
- // "not X, but Y" and the frame-marker density were printed from here until 2026-09-06. Both moved
548
- // into `eslint-rules/paper-prose.mjs`, where a finding has a `file:line:col`; printing them here a
549
- // second time would set up a second source of truth:
550
- // npx eslint papers/*/paper.md papers/*/paper.tex
551
424
  console.log(
552
425
  `${flag(r.contrastivePer10k <= THRESHOLDS.contrastivePer10k.warn)} "rather than"/instead-of/10,000w ${r.contrastivePer10k} (warn >${THRESHOLDS.contrastivePer10k.warn}; not epanorthosis, no published baseline)`,
553
426
  );
554
- // Conceits, coined terms, sentences about the text and multi-proposition sentences were printed
555
- // from here until 2026-08-26. They moved into `eslint-rules/paper-craft.mjs`, where every finding
556
- // has a `file:line:col` — whereas this report printed their heads and made you hunt for the
557
- // sentence by eye. Printing them here a second time would set up a second source of truth:
558
- // `npx eslint papers/*/paper.md`.
559
427
  console.log(
560
428
  `${flag(r.wastedStress.length === 0)} sentences ending on a cross-ref/citation/hedge (wasted stress position): ${r.wastedStress.length}`,
561
429
  );
@@ -570,43 +438,32 @@ function report(name, r) {
570
438
 
571
439
  const args = process.argv.slice(2);
572
440
  if (args.length === 0) {
573
- console.error("usage: node prose-lint.mjs <file.md|file.txt> [more files]");
441
+ console.error(
442
+ "usage: node prose-lint.mjs <paper.tex|page.txt> [more files] [--flags-only|--headings]",
443
+ );
574
444
  process.exit(0);
575
445
  }
576
446
 
577
- // 🔴 REFUSE RATHER THAN PRINT A CONFIDENT WRONG NUMBER. The analysis is in the file header: there is
578
- // not one line of LaTeX stripping here, so on `.tex` the preamble, the `%` comments and the
579
- // `filecontents` bibliography entries all landed in the denominator of every metric (measured: 8127
580
- // "words" against ~4636 real ones), and every metric printed `ok`. Silent degradation here reads as
581
- // "the prose is clean" — exactly the class of failure for which this file calls `requireMarkdown()`
582
- // above.
583
- const tex = args.filter((a) => !a.startsWith("--") && a.endsWith(".tex"));
584
- if (tex.length) {
585
- console.error(`prose-lint reads markdown, not LaTeX: ${tex.join(", ")}`);
586
- console.error(
587
- "There is no .tex stripping here — the word count would include the preamble and the .bib,",
588
- );
589
- console.error(
590
- "and every metric (they are all fractions of the word count) would be understated by roughly half.",
591
- );
447
+ // A paper's source is its `paper.tex`, and a `.txt` is its rendered page. Anything else would be
448
+ // measured as if it were prose, with no way to tell its markup from its sentences.
449
+ const unread = args.filter(
450
+ (a) => !a.startsWith("--") && !a.endsWith(".tex") && !a.endsWith(".txt"),
451
+ );
452
+ if (unread.length) {
592
453
  console.error(
593
- "What to cover prose in .tex with — papers/research/2026-08-26-proza-v-latex-zanyatost.md",
454
+ `prose-lint reads a paper.tex or the text of its rendered PDF (.txt), not: ${unread.join(", ")}`,
594
455
  );
595
456
  process.exit(2);
596
457
  }
458
+
597
459
  const fs = await import("node:fs");
598
460
  const path = await import("node:path");
599
461
 
600
- // 🔴 The paper's own working notes are not the paper. Without this, the linter scored TIGHTEN
601
- // comments, the YAML frontmatter and the keyword block as prose -- and its loudest complaints were
602
- // about text no reviewer will ever see. Found 2026-08-04 on its first real run, where it reported
603
- // the frontmatter's `title:` line as a six-proposition sentence. Strip what the converter strips.
604
- // (It also caught something real that way: the frontmatter title was two revisions stale.)
605
- // 🔴 The RENDERED page, not the markdown. Added 2026-08-05 after the author asked why the render
462
+ // 🔴 The RENDERED page, not only the source. Added 2026-08-05 after the author asked why the render
606
463
  // step had not caught a stall he found by opening the PDF. It had not because nothing reads the
607
- // rendered text: build-submission.sh checks FORM — page count, overfull boxes, unresolved
608
- // references, unset characters, the anonymity grep — and never looks at a sentence, while every
609
- // prose check reads paper.md and never sees a typeset page. Between those two layers sat every
464
+ // rendered text: the build checks FORM — page count, overfull boxes, unresolved references, unset
465
+ // characters, the anonymity grep — and never looks at a sentence, while every prose check reads the
466
+ // source and never sees a typeset page. Between those two layers sat every
610
467
  // defect he found by eye, and he was the only reader of the artifact a reviewer actually gets.
611
468
  function pdfTextOnly(t) {
612
469
  const cut = t.search(/^\s*References\s*$/m);
@@ -617,28 +474,6 @@ function pdfTextOnly(t) {
617
474
  .replace(/\f/g, "\n\n");
618
475
  }
619
476
 
620
- // 2026-08-11: code blocks are cut out by `stripFences()`, and the parser finds the body boundary.
621
- // The previous `/^```[\s\S]*?^```/gm`, with an ODD number of fences, glued the end of one block to
622
- // the beginning of the next and ate the prose between them — and eaten prose here means an
623
- // understated density for every metric, that is, a clean verdict over a dirty paper. And
624
- // `/^## (References|Limitations)/m` cut the body at hashes INSIDE such a block.
625
- function bodyOnly(t) {
626
- t = stripFrontmatter(t); // YAML frontmatter
627
- t = t.replace(/<!--[\s\S]*?-->/g, ""); // working comments, incl. TIGHTEN letters
628
- // `blank: true` — the block's lines become empty rather than disappear. Otherwise the paragraph
629
- // before the block gets glued to the paragraph after it, and this file is precisely about
630
- // "Paragraph and heading boundaries END a sentence" (see the comment at `splitSentences`). The
631
- // measurement is in the `stripFences` docstring: on `aisec-2026/README.md` removing the lines gave
632
- // 81 sentences instead of 82, merging the lead paragraph with the text after the block.
633
- t = stripFences(t, { blank: true }); // code blocks
634
- t = t.replace(/^\|.*\|$/gm, ""); // table rows: data, not prose
635
- const freeH = mdHeadings(t).find(
636
- (h) => h.depth === 2 && /^(References|Limitations)/u.test(h.text),
637
- );
638
- const cut = freeH ? freeH.offset : -1; // the `-1` convention and the `> 0` comparison — as before
639
- return cut > 0 ? t.slice(0, cut) : t; // body only; free sections hold a different bar
640
- }
641
-
642
477
  // 🔴 `--flags-only` and a NON-ZERO EXIT, added 2026-08-05, are what let anything downstream act.
643
478
  // Until today this file printed a wall of numbers and always exited 0, and it was wired to no hook
644
479
  // at all — it ran only if a human or a skill remembered it existed. Its epanorthosis metric sat at
@@ -650,46 +485,44 @@ const flagsOnly = args.includes("--flags-only");
650
485
  let flagged = 0;
651
486
  for (const f of args.filter((a) => !a.startsWith("--"))) {
652
487
  const raw = fs.readFileSync(f, "utf8");
653
- const isPdfText = f.endsWith(".txt");
654
- const prep = isPdfText ? pdfTextOnly : bodyOnly;
488
+ const deps = { files: nodeFiles, latex: latexReader, codec: sourcesCodec };
489
+ const tex = f.endsWith(".tex")
490
+ ? paperProse(path.resolve(f), raw, deps)
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
+ );
655
496
  // 🔴 NOTHING LEFT IS NOT "CLEAN". A body that strips to no words used to be analysed anyway: every
656
497
  // metric is a fraction of the word count, so the report printed NaN, FLAGged three metrics and
657
- // exited 0. Refused like `.tex` above — no number is better than a meaningless one.
658
- // A body of headings only has words but no sentence (headings are struck out before splitting),
659
- // and a zero sentence count divides just the same, so the test is on sentences.
660
- const text = prep(raw);
498
+ // exited 0. Refused — no number is better than a meaningless one. The test is on sentences: a
499
+ // zero sentence count divides just the same as a zero word count.
500
+ const text = tex ? tex.body : pdfTextOnly(raw);
661
501
  const sentences = splitSentences(text);
662
502
  if (sentences.length === 0) {
663
503
  console.error(
664
- `prose-lint: ${f} has no prose to measure — after the frontmatter, comments, code ` +
665
- "blocks, tables and the reference section are stripped, no word is left.",
504
+ tex
505
+ ? `prose-lint: ${f} has no prose to measure — after the preamble, comments, floats and ` +
506
+ "the back matter are left out, no word is left."
507
+ : `prose-lint: ${f} has no prose to measure — no sentence is left before the references.`,
666
508
  );
667
509
  process.exit(2);
668
510
  }
669
511
  const r = analyse(text, sentences);
670
512
  if (flagsOnly) {
671
513
  const lines = [];
672
- // Citation density in a paragraph and a pile-up of jargon in a sentence moved on 2026-08-26 into
673
- // `eslint-rules/paper-craft.mjs` (`citation-density`, `unexplained-jargon`).
674
- for (const c of captions(f, fs, path)) {
514
+ // A paper's captions are in its own files — paper.tex and what it includes, wherever a figure
515
+ // is set; a rendered page has the `.tex` of `figures/` beside it.
516
+ for (const c of captions(tex ? tex.files : figureFiles(f, fs, path))) {
675
517
  if (c.longest > THRESHOLDS.captionSentenceWords.warn)
676
518
  lines.push(
677
- ` figures/${c.file}: caption sentence of ${c.longest} words (warn >${THRESHOLDS.captionSentenceWords.warn}) — "${c.longestSentence.slice(0, 110)}…"`,
519
+ ` ${c.file}: caption sentence of ${c.longest} words (warn >${THRESHOLDS.captionSentenceWords.warn}) — "${c.longestSentence.slice(0, 110)}…"`,
678
520
  );
679
521
  if (c.words > THRESHOLDS.captionWords.warn)
680
522
  lines.push(
681
- ` figures/${c.file}: caption is ${c.words} words (warn >${THRESHOLDS.captionWords.warn}) — a caption this long is a section under a picture`,
523
+ ` ${c.file}: caption is ${c.words} words (warn >${THRESHOLDS.captionWords.warn}) — a caption this long is a section under a picture`,
682
524
  );
683
525
  }
684
- // Sentences carrying more than two claims moved to the same place — the rule
685
- // `multi-claim-sentence`, together with the "five worst" slice (now the `maxReported` option).
686
- // ✅ Abstract length against the venue's median moved on 2026-09-07 into
687
- // `eslint-rules/paper-craft.mjs` — the rule `paper/abstract-length`. Parity was taken BEFORE the
688
- // deletion on a real paper: `compile-rules-2026/paper.md` — 306 words against a ceiling of 305
689
- // (median 203 × 1.5), one finding here and one there, the same four numbers; the rule
690
- // additionally carries the address `paper.md:126:1`, which this line never had.
691
- // Hedges, sentences about the text and coined terms are there too:
692
- // `hedge-density`, `discourse-subject`, `undefined-coinage`.
693
526
  if (lines.length) {
694
527
  flagged += lines.length;
695
528
  // The header STATES the count, so a caller need not count output lines — header and footer
@@ -699,7 +532,7 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
699
532
  );
700
533
  lines.forEach((l) => console.error(l));
701
534
  console.error(
702
- " run `node .claude/skills/grade-paper-writing/prose-lint.mjs <file>` for the sentences",
535
+ " run prose-lint.mjs on the file without --flags-only for the sentences",
703
536
  );
704
537
  }
705
538
  } else if (args.includes("--headings")) {
@@ -708,8 +541,14 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
708
541
  // exists to stop. What was missing is cheaper and was the real cause — nobody had ever seen the
709
542
  // headings AS A SET. Two that a reader called "VAGUE AF" and "why not mention the linter??"
710
543
  // survived six passes because every pass looked only at the section it was editing.
544
+ if (!tex) {
545
+ console.error(
546
+ `prose-lint: --headings reads a paper.tex — a rendered page (${f}) marks no heading`,
547
+ );
548
+ process.exit(2);
549
+ }
711
550
  console.log(`\n=== ${f.split("/").pop()} — every heading, in order ===`);
712
- structure(prep(raw)).headings.forEach((h, i) =>
551
+ tex.headings.forEach((h, i) =>
713
552
  console.log(`${String(i + 1).padStart(3)}. ${h}`),
714
553
  );
715
554
  console.log(