paperlint 2.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 (762) hide show
  1. package/.github/dependabot.yml +72 -0
  2. package/.github/workflows/ci.yml +297 -0
  3. package/.github/workflows/dependabot-automerge.yml +70 -0
  4. package/.github/workflows/pr-title.yml +59 -0
  5. package/.github/workflows/release.yml +54 -0
  6. package/CLAUDE.md +598 -0
  7. package/CONTRIBUTING.md +159 -0
  8. package/LICENSE +21 -0
  9. package/README.md +240 -0
  10. package/action.harness.mjs +287 -0
  11. package/action.mutations.mjs +162 -0
  12. package/action.yml +138 -0
  13. package/bin/rpp.mjs +43 -0
  14. package/dist/action-ref.d.ts +12 -0
  15. package/dist/action-ref.d.ts.map +1 -0
  16. package/dist/action-ref.js +16 -0
  17. package/dist/action-ref.js.map +1 -0
  18. package/dist/adapters/banal/failure.d.ts +73 -0
  19. package/dist/adapters/banal/failure.d.ts.map +1 -0
  20. package/dist/adapters/banal/failure.js +58 -0
  21. package/dist/adapters/banal/failure.js.map +1 -0
  22. package/dist/adapters/banal/index.d.ts +17 -0
  23. package/dist/adapters/banal/index.d.ts.map +1 -0
  24. package/dist/adapters/banal/index.js +56 -0
  25. package/dist/adapters/banal/index.js.map +1 -0
  26. package/dist/adapters/banal/install.d.ts +26 -0
  27. package/dist/adapters/banal/install.d.ts.map +1 -0
  28. package/dist/adapters/banal/install.js +15 -0
  29. package/dist/adapters/banal/install.js.map +1 -0
  30. package/dist/adapters/banal/invocation.d.ts +48 -0
  31. package/dist/adapters/banal/invocation.d.ts.map +1 -0
  32. package/dist/adapters/banal/invocation.js +43 -0
  33. package/dist/adapters/banal/invocation.js.map +1 -0
  34. package/dist/adapters/banal/locate.d.ts +50 -0
  35. package/dist/adapters/banal/locate.d.ts.map +1 -0
  36. package/dist/adapters/banal/locate.js +34 -0
  37. package/dist/adapters/banal/locate.js.map +1 -0
  38. package/dist/adapters/banal/output.d.ts +27 -0
  39. package/dist/adapters/banal/output.d.ts.map +1 -0
  40. package/dist/adapters/banal/output.js +112 -0
  41. package/dist/adapters/banal/output.js.map +1 -0
  42. package/dist/adapters/banal/pin.d.ts +19 -0
  43. package/dist/adapters/banal/pin.d.ts.map +1 -0
  44. package/dist/adapters/banal/pin.js +15 -0
  45. package/dist/adapters/banal/pin.js.map +1 -0
  46. package/dist/adapters/banal/probe.d.ts +12 -0
  47. package/dist/adapters/banal/probe.d.ts.map +1 -0
  48. package/dist/adapters/banal/probe.js +27 -0
  49. package/dist/adapters/banal/probe.js.map +1 -0
  50. package/dist/adapters/banal/run.d.ts +89 -0
  51. package/dist/adapters/banal/run.d.ts.map +1 -0
  52. package/dist/adapters/banal/run.js +104 -0
  53. package/dist/adapters/banal/run.js.map +1 -0
  54. package/dist/adapters/banal/settings.d.ts +18 -0
  55. package/dist/adapters/banal/settings.d.ts.map +1 -0
  56. package/dist/adapters/banal/settings.js +29 -0
  57. package/dist/adapters/banal/settings.js.map +1 -0
  58. package/dist/adapters/banal/xml.d.ts +48 -0
  59. package/dist/adapters/banal/xml.d.ts.map +1 -0
  60. package/dist/adapters/banal/xml.js +67 -0
  61. package/dist/adapters/banal/xml.js.map +1 -0
  62. package/dist/adapters/curl/download.io.d.ts +14 -0
  63. package/dist/adapters/curl/download.io.d.ts.map +1 -0
  64. package/dist/adapters/curl/download.io.js +69 -0
  65. package/dist/adapters/curl/download.io.js.map +1 -0
  66. package/dist/adapters/curl/index.d.ts +6 -0
  67. package/dist/adapters/curl/index.d.ts.map +1 -0
  68. package/dist/adapters/curl/index.js +6 -0
  69. package/dist/adapters/curl/index.js.map +1 -0
  70. package/dist/adapters/memory/index.d.ts +43 -0
  71. package/dist/adapters/memory/index.d.ts.map +1 -0
  72. package/dist/adapters/memory/index.js +79 -0
  73. package/dist/adapters/memory/index.js.map +1 -0
  74. package/dist/adapters/node/files.io.d.ts +3 -0
  75. package/dist/adapters/node/files.io.d.ts.map +1 -0
  76. package/dist/adapters/node/files.io.js +31 -0
  77. package/dist/adapters/node/files.io.js.map +1 -0
  78. package/dist/adapters/node/host.io.d.ts +3 -0
  79. package/dist/adapters/node/host.io.d.ts.map +1 -0
  80. package/dist/adapters/node/host.io.js +14 -0
  81. package/dist/adapters/node/host.io.js.map +1 -0
  82. package/dist/adapters/node/index.d.ts +25 -0
  83. package/dist/adapters/node/index.d.ts.map +1 -0
  84. package/dist/adapters/node/index.js +14 -0
  85. package/dist/adapters/node/index.js.map +1 -0
  86. package/dist/adapters/node/process.io.d.ts +14 -0
  87. package/dist/adapters/node/process.io.d.ts.map +1 -0
  88. package/dist/adapters/node/process.io.js +41 -0
  89. package/dist/adapters/node/process.io.js.map +1 -0
  90. package/dist/adapters/node/workspace.io.d.ts +4 -0
  91. package/dist/adapters/node/workspace.io.d.ts.map +1 -0
  92. package/dist/adapters/node/workspace.io.js +33 -0
  93. package/dist/adapters/node/workspace.io.js.map +1 -0
  94. package/dist/adapters/pdfjs/fill.d.ts +42 -0
  95. package/dist/adapters/pdfjs/fill.d.ts.map +1 -0
  96. package/dist/adapters/pdfjs/fill.js +91 -0
  97. package/dist/adapters/pdfjs/fill.js.map +1 -0
  98. package/dist/build-engine.d.ts +48 -0
  99. package/dist/build-engine.d.ts.map +1 -0
  100. package/dist/build-engine.js +148 -0
  101. package/dist/build-engine.js.map +1 -0
  102. package/dist/build.d.ts +163 -0
  103. package/dist/build.d.ts.map +1 -0
  104. package/dist/build.js +575 -0
  105. package/dist/build.js.map +1 -0
  106. package/dist/cli.d.ts +151 -0
  107. package/dist/cli.d.ts.map +1 -0
  108. package/dist/cli.js +951 -0
  109. package/dist/cli.js.map +1 -0
  110. package/dist/doctor.d.ts +42 -0
  111. package/dist/doctor.d.ts.map +1 -0
  112. package/dist/doctor.js +280 -0
  113. package/dist/doctor.js.map +1 -0
  114. package/dist/domain/geometry.d.ts +71 -0
  115. package/dist/domain/geometry.d.ts.map +1 -0
  116. package/dist/domain/geometry.js +35 -0
  117. package/dist/domain/geometry.js.map +1 -0
  118. package/dist/domain/host.d.ts +16 -0
  119. package/dist/domain/host.d.ts.map +1 -0
  120. package/dist/domain/host.js +8 -0
  121. package/dist/domain/host.js.map +1 -0
  122. package/dist/domain/page-layout.d.ts +34 -0
  123. package/dist/domain/page-layout.d.ts.map +1 -0
  124. package/dist/domain/page-layout.js +8 -0
  125. package/dist/domain/page-layout.js.map +1 -0
  126. package/dist/domain/paths.d.ts +5 -0
  127. package/dist/domain/paths.d.ts.map +1 -0
  128. package/dist/domain/paths.js +2 -0
  129. package/dist/domain/paths.js.map +1 -0
  130. package/dist/domain/result.d.ts +23 -0
  131. package/dist/domain/result.d.ts.map +1 -0
  132. package/dist/domain/result.js +10 -0
  133. package/dist/domain/result.js.map +1 -0
  134. package/dist/domain/sha256.d.ts +7 -0
  135. package/dist/domain/sha256.d.ts.map +1 -0
  136. package/dist/domain/sha256.js +14 -0
  137. package/dist/domain/sha256.js.map +1 -0
  138. package/dist/domain/text.d.ts +6 -0
  139. package/dist/domain/text.d.ts.map +1 -0
  140. package/dist/domain/text.js +7 -0
  141. package/dist/domain/text.js.map +1 -0
  142. package/dist/engine.d.ts +93 -0
  143. package/dist/engine.d.ts.map +1 -0
  144. package/dist/engine.js +119 -0
  145. package/dist/engine.js.map +1 -0
  146. package/dist/exit-code.d.ts +22 -0
  147. package/dist/exit-code.d.ts.map +1 -0
  148. package/dist/exit-code.js +10 -0
  149. package/dist/exit-code.js.map +1 -0
  150. package/dist/facts-file.d.ts +96 -0
  151. package/dist/facts-file.d.ts.map +1 -0
  152. package/dist/facts-file.js +134 -0
  153. package/dist/facts-file.js.map +1 -0
  154. package/dist/hooks-settings.d.ts +141 -0
  155. package/dist/hooks-settings.d.ts.map +1 -0
  156. package/dist/hooks-settings.js +306 -0
  157. package/dist/hooks-settings.js.map +1 -0
  158. package/dist/init.d.ts +201 -0
  159. package/dist/init.d.ts.map +1 -0
  160. package/dist/init.js +579 -0
  161. package/dist/init.js.map +1 -0
  162. package/dist/latex-log.d.ts +80 -0
  163. package/dist/latex-log.d.ts.map +1 -0
  164. package/dist/latex-log.js +187 -0
  165. package/dist/latex-log.js.map +1 -0
  166. package/dist/latex-loop.d.ts +129 -0
  167. package/dist/latex-loop.d.ts.map +1 -0
  168. package/dist/latex-loop.js +113 -0
  169. package/dist/latex-loop.js.map +1 -0
  170. package/dist/link-skills.d.ts +51 -0
  171. package/dist/link-skills.d.ts.map +1 -0
  172. package/dist/link-skills.js +199 -0
  173. package/dist/link-skills.js.map +1 -0
  174. package/dist/new-paper.d.ts +48 -0
  175. package/dist/new-paper.d.ts.map +1 -0
  176. package/dist/new-paper.js +110 -0
  177. package/dist/new-paper.js.map +1 -0
  178. package/dist/pdf-facts.d.ts +44 -0
  179. package/dist/pdf-facts.d.ts.map +1 -0
  180. package/dist/pdf-facts.js +239 -0
  181. package/dist/pdf-facts.js.map +1 -0
  182. package/dist/pdf-geometry.d.ts +170 -0
  183. package/dist/pdf-geometry.d.ts.map +1 -0
  184. package/dist/pdf-geometry.js +158 -0
  185. package/dist/pdf-geometry.js.map +1 -0
  186. package/dist/ports/download.d.ts +9 -0
  187. package/dist/ports/download.d.ts.map +1 -0
  188. package/dist/ports/download.js +2 -0
  189. package/dist/ports/download.js.map +1 -0
  190. package/dist/ports/files.d.ts +11 -0
  191. package/dist/ports/files.d.ts.map +1 -0
  192. package/dist/ports/files.js +2 -0
  193. package/dist/ports/files.js.map +1 -0
  194. package/dist/ports/measure-geometry.d.ts +8 -0
  195. package/dist/ports/measure-geometry.d.ts.map +1 -0
  196. package/dist/ports/measure-geometry.js +2 -0
  197. package/dist/ports/measure-geometry.js.map +1 -0
  198. package/dist/ports/process.d.ts +45 -0
  199. package/dist/ports/process.d.ts.map +1 -0
  200. package/dist/ports/process.js +2 -0
  201. package/dist/ports/process.js.map +1 -0
  202. package/dist/ports/tool-installer.d.ts +29 -0
  203. package/dist/ports/tool-installer.d.ts.map +1 -0
  204. package/dist/ports/tool-installer.js +2 -0
  205. package/dist/ports/tool-installer.js.map +1 -0
  206. package/dist/ports/workspace.d.ts +18 -0
  207. package/dist/ports/workspace.d.ts.map +1 -0
  208. package/dist/ports/workspace.js +2 -0
  209. package/dist/ports/workspace.js.map +1 -0
  210. package/dist/rules-config.d.ts +34 -0
  211. package/dist/rules-config.d.ts.map +1 -0
  212. package/dist/rules-config.js +132 -0
  213. package/dist/rules-config.js.map +1 -0
  214. package/dist/structure.d.ts +34 -0
  215. package/dist/structure.d.ts.map +1 -0
  216. package/dist/structure.js +149 -0
  217. package/dist/structure.js.map +1 -0
  218. package/dist/tex-requirements.d.ts +43 -0
  219. package/dist/tex-requirements.d.ts.map +1 -0
  220. package/dist/tex-requirements.js +127 -0
  221. package/dist/tex-requirements.js.map +1 -0
  222. package/dist/toolchain.d.ts +159 -0
  223. package/dist/toolchain.d.ts.map +1 -0
  224. package/dist/toolchain.js +542 -0
  225. package/dist/toolchain.js.map +1 -0
  226. package/dist/types.d.ts +110 -0
  227. package/dist/types.d.ts.map +1 -0
  228. package/dist/types.js +2 -0
  229. package/dist/types.js.map +1 -0
  230. package/docs/configuration.md +235 -0
  231. package/docs/e2e.md +152 -0
  232. package/docs/incidents.md +59 -0
  233. package/docs/install.md +170 -0
  234. package/docs/optional-rules.md +107 -0
  235. package/docs/package-shape-options.md +262 -0
  236. package/docs/prior-art/README.md +76 -0
  237. package/docs/prior-art/blocking-vs-advisory.md +83 -0
  238. package/docs/prior-art/content-delivery.md +124 -0
  239. package/docs/prior-art/multi-mode-tools.md +106 -0
  240. package/docs/prior-art/nondeterministic-checks.md +99 -0
  241. package/docs/prior-art/package-location.md +422 -0
  242. package/docs/prior-art/paper-folder-scaffolding.md +538 -0
  243. package/docs/prior-art/readme-structure.md +69 -0
  244. package/docs/prior-art/repro/README.md +92 -0
  245. package/docs/prior-art/repro/claim1-allowedtools.mjs +66 -0
  246. package/docs/prior-art/repro/claim1-at2.mjs +40 -0
  247. package/docs/prior-art/repro/claim1-crosschannel.mjs +54 -0
  248. package/docs/prior-art/repro/claim1-frontmatter.mjs +76 -0
  249. package/docs/prior-art/repro/claim1-hook-payload-reporter.mjs +10 -0
  250. package/docs/prior-art/repro/claim1-plugin-frontmatter.mjs +27 -0
  251. package/docs/prior-art/repro/claim1-plugin-skill.mjs +52 -0
  252. package/docs/prior-art/repro/claim1-project-skill.mjs +81 -0
  253. package/docs/prior-art/repro/claim2-marketplace-flat-asclaimed.json +1 -0
  254. package/docs/prior-art/repro/claim2-marketplace-negative-control.json +1 -0
  255. package/docs/prior-art/repro/claim2-marketplace-nested-exact.json +9 -0
  256. package/docs/prior-art/repro/claim2-marketplace-nested-noversion.json +9 -0
  257. package/docs/prior-art/repro/claim2-marketplace-nested-range.json +1 -0
  258. package/docs/prior-art/repro/claim3-imports.mjs +50 -0
  259. package/docs/prior-art/repro/claim4-find-package-json.mjs +8 -0
  260. package/docs/prior-art/repro/claim4-package-dir.mjs +39 -0
  261. package/docs/prior-art/repro/claim4-parent-arg.mjs +17 -0
  262. package/docs/prior-art/repro/claim4-resolve-apis.mjs +21 -0
  263. package/docs/prior-art/repro/claim4-setup-consumers.mjs +45 -0
  264. package/docs/prior-art/repro/claim4-yarn-pnp.mjs +70 -0
  265. package/docs/prior-art/repro/claim5-bin-launch.mjs +39 -0
  266. package/docs/prior-art/repro/claim5-exports-mutation.mjs +57 -0
  267. package/docs/prior-art/repro/claim5-resolved-location-and-bin.mjs +33 -0
  268. package/docs/prior-art/repro/claim6-candidate-ambiguity.mjs +17 -0
  269. package/docs/prior-art/repro/claim6-doc-path-candidates.mjs +27 -0
  270. package/docs/prior-art/test-tooling.md +131 -0
  271. package/docs/rules.md +58 -0
  272. package/docs/texlive-install-decision.md +230 -0
  273. package/docs/toolchain.md +152 -0
  274. package/eslint-rules/doc-fields.harness.mjs +336 -0
  275. package/eslint-rules/doc-fields.mjs +186 -0
  276. package/eslint-rules/doc-fields.mutations.mjs +96 -0
  277. package/eslint-rules/install-path-literals.harness.mjs +121 -0
  278. package/eslint-rules/install-path-literals.mjs +108 -0
  279. package/eslint-rules/install-path-literals.mutations.mjs +62 -0
  280. package/eslint-rules/latex-language.harness.mjs +599 -0
  281. package/eslint-rules/latex-language.mjs +591 -0
  282. package/eslint-rules/latex-language.mutations.mjs +196 -0
  283. package/eslint-rules/paper-research-question.harness.mjs +146 -0
  284. package/eslint-rules/paper-research-question.mjs +180 -0
  285. package/eslint-rules/paper-research-question.mutations.mjs +127 -0
  286. package/eslint-rules/paper-stages.harness.mjs +356 -0
  287. package/eslint-rules/paper-stages.mjs +455 -0
  288. package/eslint-rules/paper-stages.mutations.mjs +157 -0
  289. package/eslint-rules/paper-typography.harness.mjs +291 -0
  290. package/eslint-rules/paper-typography.mjs +313 -0
  291. package/eslint-rules/paper-typography.mutations.mjs +131 -0
  292. package/eslint-rules/papers.harness.mjs +259 -0
  293. package/eslint-rules/papers.mjs +166 -0
  294. package/eslint-rules/papers.mutations.mjs +186 -0
  295. package/eslint-rules/pdf-last-page-balance.harness.mjs +206 -0
  296. package/eslint-rules/pdf-last-page-balance.mjs +208 -0
  297. package/eslint-rules/review-findings-cause.harness.mjs +228 -0
  298. package/eslint-rules/review-findings-cause.mjs +135 -0
  299. package/eslint-rules/review-findings-cause.mutations.mjs +72 -0
  300. package/eslint-rules/temp-root-realpath.harness.mjs +176 -0
  301. package/eslint-rules/temp-root-realpath.mjs +129 -0
  302. package/eslint-rules/temp-root-realpath.mutations.mjs +99 -0
  303. package/eslint-rules/tex-build.harness.mjs +753 -0
  304. package/eslint-rules/tex-build.mjs +322 -0
  305. package/eslint-rules/tex-build.mutations.mjs +258 -0
  306. package/eslint.config.mjs +521 -0
  307. package/fixtures/build-e2e/acmart/PIPELINE-STATUS.md +3 -0
  308. package/fixtures/build-e2e/acmart/paper.tex +11 -0
  309. package/fixtures/build-e2e/acmart/venue.json +1 -0
  310. package/fixtures/build-e2e/broken/PIPELINE-STATUS.md +3 -0
  311. package/fixtures/build-e2e/broken/paper.tex +7 -0
  312. package/fixtures/build-e2e/cite/PIPELINE-STATUS.md +3 -0
  313. package/fixtures/build-e2e/cite/build.sh +5 -0
  314. package/fixtures/build-e2e/cite/paper.tex +10 -0
  315. package/fixtures/build-e2e/cite/refs.bib +9 -0
  316. package/fixtures/build-e2e/empty/PIPELINE-STATUS.md +3 -0
  317. package/fixtures/build-e2e/empty/paper.tex +6 -0
  318. package/fixtures/build-e2e/fallback/PIPELINE-STATUS.md +3 -0
  319. package/fixtures/build-e2e/fallback/paper.tex +11 -0
  320. package/fixtures/build-e2e/guards/PIPELINE-STATUS.md +3 -0
  321. package/fixtures/build-e2e/guards/paper.tex +10 -0
  322. package/fixtures/build-e2e/no-source/PIPELINE-STATUS.md +3 -0
  323. package/fixtures/build-e2e/unbalanced/PIPELINE-STATUS.md +3 -0
  324. package/fixtures/build-e2e/unbalanced/paper.tex +28 -0
  325. package/fixtures/build-e2e/unbalanced/refs.bib +269 -0
  326. package/fixtures/install-path-literals/clean.fixture.mjs +3 -0
  327. package/fixtures/install-path-literals/clean.md +15 -0
  328. package/fixtures/install-path-literals/defect.fixture.mjs +3 -0
  329. package/fixtures/install-path-literals/defect.md +14 -0
  330. package/fixtures/latex-language/clean.tex +50 -0
  331. package/fixtures/latex-language/defect.tex +52 -0
  332. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +9 -0
  333. package/fixtures/paper-research-question/comment-only/paper.tex +7 -0
  334. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +10 -0
  335. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +6 -0
  336. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +6 -0
  337. package/fixtures/paper-research-question/draft/paper.tex +2 -0
  338. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +9 -0
  339. package/fixtures/paper-research-question/markdown-no-rq/paper.md +4 -0
  340. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +12 -0
  341. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +3 -0
  342. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +10 -0
  343. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +2 -0
  344. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +16 -0
  345. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +17 -0
  346. package/fixtures/paper-stages/nofile/PIPELINE-STATUS.md +8 -0
  347. package/fixtures/paper-stages/noheader/PIPELINE-STATUS.md +1 -0
  348. package/fixtures/paper-stages/noheader/versions/2026-07-22-submitted.pdf +0 -0
  349. package/fixtures/paper-stages/nothing/PIPELINE-STATUS.md +3 -0
  350. package/fixtures/paper-stages/ok/PIPELINE-STATUS.md +9 -0
  351. package/fixtures/paper-stages/ok/versions/2026-07-22-submitted.pdf +0 -0
  352. package/fixtures/paper-stages/stale/PIPELINE-STATUS.md +1 -0
  353. package/fixtures/paper-stages/stale/versions/2026-07-22-submitted.STALE-WRONG-FILE.pdf +0 -0
  354. package/fixtures/paper-stages/twice/PIPELINE-STATUS.md +14 -0
  355. package/fixtures/paper-stages/twice/versions/2026-08-06-submitted.pdf +0 -0
  356. package/fixtures/paper-stages/twice/versions/2026-10-24-submitted.pdf +0 -0
  357. package/fixtures/paper-stages/undeclared/PIPELINE-STATUS.md +8 -0
  358. package/fixtures/paper-stages/undeclared/versions/2026-07-22-submitted.pdf +0 -0
  359. package/fixtures/paper-stages/undeclared/versions/2026-08-29-camera-ready.pdf +0 -0
  360. package/fixtures/paper-stages/wrongsize/PIPELINE-STATUS.md +8 -0
  361. package/fixtures/paper-stages/wrongsize/versions/2026-07-22-submitted.pdf +0 -0
  362. package/fixtures/paper-typography/clean-paper/paper.tex +29 -0
  363. package/fixtures/paper-typography/messy-paper/paper.tex +27 -0
  364. package/fixtures/pdf-facts/README.md +22 -0
  365. package/fixtures/pdf-facts/corrupt-font.pdf +0 -0
  366. package/fixtures/pdf-facts/encrypted.pdf +0 -0
  367. package/fixtures/pdf-facts/hidden-text.pdf +0 -0
  368. package/fixtures/pdf-facts/hidden-text.tex +28 -0
  369. package/fixtures/pdf-facts/t3-all.pdf +0 -0
  370. package/fixtures/pdf-facts/t3-all.tex +8 -0
  371. package/fixtures/pdf-facts/t3-mixed.pdf +0 -0
  372. package/fixtures/pdf-facts/t3-mixed.tex +9 -0
  373. package/fixtures/pdf-facts/ttf.pdf +2240 -1
  374. package/fixtures/pdf-facts/ttf.tex +6 -0
  375. package/fixtures/real-markdown-paper/baseline.json +24 -0
  376. package/fixtures/real-markdown-paper/baseline.mjs +48 -0
  377. package/fixtures/render-paper/build-clean.sh +25 -0
  378. package/fixtures/render-paper/build-defect.sh +15 -0
  379. package/fixtures/review-findings-cause/clean.md +17 -0
  380. package/fixtures/review-findings-cause/defect.md +14 -0
  381. package/fixtures/review-findings-cause/old-debt.md +14 -0
  382. package/fixtures/review-findings-cause/quiet-in-fence.md +16 -0
  383. package/fixtures/tex-build/clean.tex +21 -0
  384. package/fixtures/tex-build/defect.tex +24 -0
  385. package/fixtures/tex-build/frontmatter-clean.tex +25 -0
  386. package/fixtures/tex-build/frontmatter-defect.tex +23 -0
  387. package/fixtures/toolchain-mirror/catalog.txt +5 -0
  388. package/fixtures/toolchain-mirror/install-tl +27 -0
  389. package/fixtures/toolchain-mirror/release-texlive.txt +3 -0
  390. package/fixtures/toolchain-mirror/release-year +1 -0
  391. package/fixtures/toolchain-mirror/stub-kpsewhich +8 -0
  392. package/fixtures/toolchain-mirror/stub-pdflatex +3 -0
  393. package/fixtures/toolchain-mirror/stub-tlmgr +44 -0
  394. package/hooks/hooks.harness.mjs +713 -0
  395. package/hooks/hooks.mutations.mjs +337 -0
  396. package/hooks/paper-edit-guard.hook.d.mts +13 -0
  397. package/hooks/paper-edit-guard.hook.mjs +457 -0
  398. package/hooks/paper-skills-nudge.hook.mjs +136 -0
  399. package/hooks/paper-status-gates.hook.mjs +156 -0
  400. package/hooks/paper-status-gates.sh +91 -0
  401. package/lib/agent-cli-version.harness.mjs +165 -0
  402. package/lib/agent-cli-version.mjs +106 -0
  403. package/lib/agent-cli-version.mutations.mjs +109 -0
  404. package/lib/markdown.mjs +386 -0
  405. package/lib/mutation-driver.harness.mjs +227 -0
  406. package/lib/mutation-driver.mjs +397 -0
  407. package/lib/mutation-driver.mutations.mjs +68 -0
  408. package/lib/paper-config.d.mts +34 -0
  409. package/lib/paper-config.harness.mjs +286 -0
  410. package/lib/paper-config.mjs +142 -0
  411. package/lib/paper-config.mutations.mjs +143 -0
  412. package/lib/skill-checks.mjs +701 -0
  413. package/lib/skill-corpus.mjs +403 -0
  414. package/lib/skill-eval-fixture.mjs +63 -0
  415. package/lib/skill-eval-kit.mjs +257 -0
  416. package/lib/skill-trigger-cases.harness.mjs +170 -0
  417. package/lib/skill-trigger-cases.mjs +446 -0
  418. package/lib/skill-trigger-cases.mutations.mjs +65 -0
  419. package/lib/trigger-ledger.mjs +215 -0
  420. package/package.json +97 -0
  421. package/plugin/.claude-plugin/plugin.json +8 -0
  422. package/plugin/hooks/hooks.json +30 -0
  423. package/scripts/check.harness.mjs +177 -0
  424. package/scripts/check.mjs +239 -0
  425. package/scripts/check.mutations.mjs +110 -0
  426. package/scripts/eslint-report-guard.mjs +82 -0
  427. package/scripts/exclusive.mjs +138 -0
  428. package/scripts/harness-api.frozen.json +76 -0
  429. package/scripts/harness-api.test.ts +175 -0
  430. package/scripts/layer-legacy-frozen.d.mts +28 -0
  431. package/scripts/layer-legacy-frozen.mjs +152 -0
  432. package/scripts/layer-legacy-frozen.test.ts +115 -0
  433. package/scripts/layer-legacy.frozen.json +50 -0
  434. package/scripts/mutation-batteries-frozen.harness.mjs +204 -0
  435. package/scripts/mutation-batteries-frozen.mjs +238 -0
  436. package/scripts/mutation-batteries.frozen.json +117 -0
  437. package/scripts/release-config.test.ts +90 -0
  438. package/scripts/rules-are-content-only.harness.mjs +113 -0
  439. package/scripts/rules-are-content-only.mjs +138 -0
  440. package/scripts/rules-are-content-only.mutations.mjs +81 -0
  441. package/scripts/rules-see-files.harness.mjs +115 -0
  442. package/scripts/rules-see-files.mjs +99 -0
  443. package/scripts/rules-see-files.mutations.mjs +131 -0
  444. package/scripts/run-mutations.mjs +100 -0
  445. package/scripts/semantic-release-plugins.d.ts +16 -0
  446. package/skills/README.md +15 -0
  447. package/skills/analyze-sibling-paper/SKILL.md +170 -0
  448. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +186 -0
  449. package/skills/analyze-sibling-paper/analyze-sibling-paper.eval.mjs +19 -0
  450. package/skills/analyze-sibling-paper/analyze-sibling-paper.harness.mjs +23 -0
  451. package/skills/argument-arc/SKILL.md +177 -0
  452. package/skills/argument-arc/SKILL.md.spec.ts +192 -0
  453. package/skills/argument-arc/argument-arc.eval.mjs +19 -0
  454. package/skills/argument-arc/argument-arc.harness.mjs +23 -0
  455. package/skills/build-benchmark/SKILL.md +213 -0
  456. package/skills/build-benchmark/SKILL.md.spec.ts +220 -0
  457. package/skills/build-benchmark/build-benchmark.eval.mjs +19 -0
  458. package/skills/build-benchmark/build-benchmark.harness.mjs +23 -0
  459. package/skills/build-benchmark/references/adversarial-cold-repro.md +68 -0
  460. package/skills/camera-ready/SKILL.md +148 -0
  461. package/skills/camera-ready/SKILL.md.spec.ts +164 -0
  462. package/skills/camera-ready/camera-ready.eval.mjs +19 -0
  463. package/skills/camera-ready/camera-ready.harness.mjs +23 -0
  464. package/skills/cold-read-diff/SKILL.md +160 -0
  465. package/skills/cold-read-diff/SKILL.md.spec.ts +166 -0
  466. package/skills/cold-read-diff/cold-read-diff.eval.mjs +19 -0
  467. package/skills/cold-read-diff/cold-read-diff.harness.mjs +23 -0
  468. package/skills/draft-paper/SKILL.md +152 -0
  469. package/skills/draft-paper/SKILL.md.spec.ts +169 -0
  470. package/skills/draft-paper/draft-paper.eval.mjs +19 -0
  471. package/skills/draft-paper/draft-paper.harness.mjs +23 -0
  472. package/skills/extend-paper/SKILL.md +99 -0
  473. package/skills/extend-paper/SKILL.md.spec.ts +116 -0
  474. package/skills/extend-paper/extend-paper.eval.mjs +19 -0
  475. package/skills/extend-paper/extend-paper.harness.mjs +23 -0
  476. package/skills/find-venue/SKILL.md +128 -0
  477. package/skills/find-venue/SKILL.md.spec.ts +145 -0
  478. package/skills/find-venue/find-venue.eval.mjs +19 -0
  479. package/skills/find-venue/find-venue.harness.mjs +23 -0
  480. package/skills/grade-paper-writing/SKILL.md +436 -0
  481. package/skills/grade-paper-writing/SKILL.md.spec.ts +453 -0
  482. package/skills/grade-paper-writing/fixtures/control_gopen.txt +1 -0
  483. package/skills/grade-paper-writing/fixtures/control_human_paper.txt +1 -0
  484. package/skills/grade-paper-writing/fixtures/rewrite.txt +1 -0
  485. package/skills/grade-paper-writing/fixtures/specimen.txt +1 -0
  486. package/skills/grade-paper-writing/fixtures/structure-checks.md +22 -0
  487. package/skills/grade-paper-writing/grade-paper-writing.eval.mjs +19 -0
  488. package/skills/grade-paper-writing/grade-paper-writing.harness.mjs +23 -0
  489. package/skills/grade-paper-writing/prose-lint.mjs +713 -0
  490. package/skills/harden-paper/SKILL.md +318 -0
  491. package/skills/harden-paper/SKILL.md.spec.ts +336 -0
  492. package/skills/harden-paper/check-numbers.sh +33 -0
  493. package/skills/harden-paper/check-release-claims.sh +35 -0
  494. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +43 -0
  495. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +77 -0
  496. package/skills/harden-paper/harden-paper.eval.mjs +19 -0
  497. package/skills/harden-paper/harden-paper.harness.mjs +23 -0
  498. package/skills/map-prior-work/SKILL.md +211 -0
  499. package/skills/map-prior-work/SKILL.md.spec.ts +227 -0
  500. package/skills/map-prior-work/map-prior-work.eval.mjs +19 -0
  501. package/skills/map-prior-work/map-prior-work.harness.mjs +23 -0
  502. package/skills/osf-artifact-upload/SKILL.md +52 -0
  503. package/skills/osf-artifact-upload/SKILL.md.spec.ts +59 -0
  504. package/skills/osf-artifact-upload/osf-artifact-upload.eval.mjs +22 -0
  505. package/skills/osf-artifact-upload/osf-artifact-upload.harness.mjs +103 -0
  506. package/skills/paper-adversarial-review/SKILL.md +126 -0
  507. package/skills/paper-adversarial-review/SKILL.md.spec.ts +142 -0
  508. package/skills/paper-adversarial-review/paper-adversarial-review.eval.mjs +19 -0
  509. package/skills/paper-adversarial-review/paper-adversarial-review.harness.mjs +23 -0
  510. package/skills/paper-pipeline/PIPELINE-MAP.md +371 -0
  511. package/skills/paper-pipeline/SKILL.md +499 -0
  512. package/skills/paper-pipeline/SKILL.md.spec.ts +517 -0
  513. package/skills/paper-pipeline/description-language.eval.mjs +347 -0
  514. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +891 -0
  515. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +1254 -0
  516. package/skills/paper-pipeline/paper-pipeline.eval.mjs +22 -0
  517. package/skills/paper-pipeline/paper-pipeline.harness.mjs +143 -0
  518. package/skills/paper-pipeline/pipeline-firing.baseline.json +270 -0
  519. package/skills/paper-pipeline/pipeline-firing.eval.mjs +664 -0
  520. package/skills/paper-pipeline/pipeline-language.eval.mjs +672 -0
  521. package/skills/paper-pipeline/references/acceptance-gate.md +329 -0
  522. package/skills/paper-pipeline/references/acl-venue-rules.md +142 -0
  523. package/skills/paper-pipeline/references/anonymization.md +68 -0
  524. package/skills/paper-pipeline/references/artifact-checklist.md +93 -0
  525. package/skills/paper-pipeline/references/body-vs-appendix.md +97 -0
  526. package/skills/paper-pipeline/references/credit-criteria.md +69 -0
  527. package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/README.md +35 -0
  528. package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/run_retext.mjs +24 -0
  529. package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/sentences.txt +11 -0
  530. package/skills/paper-pipeline/references/occupancy-2026-08-06-probe/test_sentences.py +25 -0
  531. package/skills/paper-pipeline/references/occupancy-2026-08-06-prose-checkers.md +538 -0
  532. package/skills/paper-pipeline/references/occupancy-2026-08-06-reproducible-tooling.md +431 -0
  533. package/skills/paper-pipeline/references/occupancy-2026-08-06-staleness-and-orchestration.md +592 -0
  534. package/skills/paper-pipeline/references/pipeline-status-template.md +162 -0
  535. package/skills/paper-pipeline/references/review-ratchet.md +36 -0
  536. package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +585 -0
  537. package/skills/paper-pipeline/references/writing-craft.md +448 -0
  538. package/skills/paper-pipeline/repro/2026-08-07-description-language-control.log +63 -0
  539. package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +52 -0
  540. package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +33 -0
  541. package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +33 -0
  542. package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +166 -0
  543. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.json +338 -0
  544. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +118 -0
  545. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-raw.log +245 -0
  546. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary.json +776 -0
  547. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +53 -0
  548. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-raw.log +89 -0
  549. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.json +450 -0
  550. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +136 -0
  551. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-raw.log +242 -0
  552. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-setupdiff.log +59 -0
  553. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation.json +1032 -0
  554. package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.json +139 -0
  555. package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +98 -0
  556. package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +92 -0
  557. package/skills/paper-pipeline/repro/README.md +129 -0
  558. package/skills/paper-pipeline/repro/analyze-language-eval.py +116 -0
  559. package/skills/paper-pipeline/scripts/README.md +344 -0
  560. package/skills/paper-pipeline/scripts/announce.mjs +67 -0
  561. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +496 -0
  562. package/skills/paper-pipeline/scripts/artifact-coverage.mjs +397 -0
  563. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +218 -0
  564. package/skills/paper-pipeline/scripts/check-provenance.mjs +184 -0
  565. package/skills/paper-pipeline/scripts/consumer.d.mts +32 -0
  566. package/skills/paper-pipeline/scripts/consumer.harness.mjs +562 -0
  567. package/skills/paper-pipeline/scripts/consumer.mjs +535 -0
  568. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +190 -0
  569. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +457 -0
  570. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +656 -0
  571. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +54 -0
  572. package/skills/paper-pipeline/scripts/fixtures/clean/PIPELINE-STATUS.md +51 -0
  573. package/skills/paper-pipeline/scripts/fixtures/dirty/PIPELINE-STATUS.md +52 -0
  574. package/skills/paper-pipeline/scripts/fixtures/dirty/paper.md +6 -0
  575. package/skills/paper-pipeline/scripts/fixtures/real-bib/refs.bib +153 -0
  576. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +466 -0
  577. package/skills/paper-pipeline/scripts/generated-code.mjs +338 -0
  578. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +254 -0
  579. package/skills/paper-pipeline/scripts/ledger.mjs +623 -0
  580. package/skills/paper-pipeline/scripts/ledger.selftest.mjs +286 -0
  581. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +389 -0
  582. package/skills/paper-pipeline/scripts/pipeline-check.mjs +737 -0
  583. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +54 -0
  584. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +169 -0
  585. package/skills/paper-pipeline/scripts/population-map.harness.mjs +178 -0
  586. package/skills/paper-pipeline/scripts/population-map.mjs +181 -0
  587. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +65 -0
  588. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +122 -0
  589. package/skills/paper-pipeline/scripts/provenance.harness.mjs +240 -0
  590. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +59 -0
  591. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +881 -0
  592. package/skills/paper-pipeline/scripts/round-diff.mjs +576 -0
  593. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +276 -0
  594. package/skills/paper-pipeline/scripts/run-mechanical.mjs +633 -0
  595. package/skills/paper-pipeline/scripts/status.mjs +295 -0
  596. package/skills/paper-status/SKILL.md +183 -0
  597. package/skills/paper-status/SKILL.md.spec.ts +190 -0
  598. package/skills/paper-status/paper-status.eval.mjs +22 -0
  599. package/skills/paper-status/paper-status.harness.mjs +25 -0
  600. package/skills/pc-panel-review/SKILL.md +263 -0
  601. package/skills/pc-panel-review/SKILL.md.spec.ts +280 -0
  602. package/skills/pc-panel-review/pc-panel-review.eval.mjs +19 -0
  603. package/skills/pc-panel-review/pc-panel-review.harness.mjs +23 -0
  604. package/skills/plan-paper-timeline/SKILL.md +182 -0
  605. package/skills/plan-paper-timeline/SKILL.md.spec.ts +200 -0
  606. package/skills/plan-paper-timeline/fixtures/fake-google-calendar.mjs +239 -0
  607. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +431 -0
  608. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +65 -0
  609. package/skills/plan-paper-timeline/plan-paper-timeline.eval.mjs +19 -0
  610. package/skills/plan-paper-timeline/plan-paper-timeline.harness.mjs +23 -0
  611. package/skills/render-paper/SKILL.md +159 -0
  612. package/skills/render-paper/SKILL.md.spec.ts +166 -0
  613. package/skills/render-paper/check-render.sh +419 -0
  614. package/skills/render-paper/checkers-requirements.txt +55 -0
  615. package/skills/render-paper/ensure-checkers.sh +69 -0
  616. package/skills/render-paper/extract-pdf-facts.harness.mjs +166 -0
  617. package/skills/render-paper/extract-pdf-facts.mjs +144 -0
  618. package/skills/render-paper/render-paper.eval.mjs +19 -0
  619. package/skills/render-paper/render-paper.harness.mjs +339 -0
  620. package/skills/research-ideate/SKILL.md +136 -0
  621. package/skills/research-ideate/SKILL.md.spec.ts +152 -0
  622. package/skills/research-ideate/research-ideate.eval.mjs +19 -0
  623. package/skills/research-ideate/research-ideate.harness.mjs +23 -0
  624. package/skills/skill-contract.mutations.mjs +179 -0
  625. package/skills/study-accepted-papers/SKILL.md +206 -0
  626. package/skills/study-accepted-papers/SKILL.md.spec.ts +223 -0
  627. package/skills/study-accepted-papers/study-accepted-papers.eval.mjs +19 -0
  628. package/skills/study-accepted-papers/study-accepted-papers.harness.mjs +23 -0
  629. package/skills/submit-paper/SKILL.md +182 -0
  630. package/skills/submit-paper/SKILL.md.spec.ts +199 -0
  631. package/skills/submit-paper/check-deanon.sh +149 -0
  632. package/skills/submit-paper/references/publishers/acm.md +92 -0
  633. package/skills/submit-paper/references/venues/agenticdev.jsonc +108 -0
  634. package/skills/submit-paper/references/venues/agenticdev.md +139 -0
  635. package/skills/submit-paper/references/venues/agenticdev.tex +19 -0
  636. package/skills/submit-paper/references/venues/aisec.jsonc +101 -0
  637. package/skills/submit-paper/references/venues/aisec.md +105 -0
  638. package/skills/submit-paper/references/venues/paper-guards.tex +41 -0
  639. package/skills/submit-paper/references/venues/realm.jsonc +81 -0
  640. package/skills/submit-paper/references/venues/realm.md +155 -0
  641. package/skills/submit-paper/references/venues/tex-base.jsonc +50 -0
  642. package/skills/submit-paper/references/venues/venue-profile.schema.json +74 -0
  643. package/skills/submit-paper/submit-paper.eval.mjs +19 -0
  644. package/skills/submit-paper/submit-paper.harness.mjs +23 -0
  645. package/skills/sweep-design-space/SKILL.md +269 -0
  646. package/skills/sweep-design-space/SKILL.md.spec.ts +285 -0
  647. package/skills/sweep-design-space/sweep-design-space.eval.mjs +19 -0
  648. package/skills/sweep-design-space/sweep-design-space.harness.mjs +23 -0
  649. package/skills/tighten-paper/SKILL.md +368 -0
  650. package/skills/tighten-paper/SKILL.md.spec.ts +384 -0
  651. package/skills/tighten-paper/structure.mjs +371 -0
  652. package/skills/tighten-paper/tighten-paper.eval.mjs +19 -0
  653. package/skills/tighten-paper/tighten-paper.harness.mjs +23 -0
  654. package/skills/verify-citations/SKILL.md +328 -0
  655. package/skills/verify-citations/SKILL.md.spec.ts +345 -0
  656. package/skills/verify-citations/scripts/bib-authors.mjs +479 -0
  657. package/skills/verify-citations/scripts/bib-authors.test.mjs +175 -0
  658. package/skills/verify-citations/scripts/verify-cites.mjs +1108 -0
  659. package/skills/verify-citations/scripts/verify-cites.test.mjs +735 -0
  660. package/skills/verify-citations/verify-citations.eval.mjs +19 -0
  661. package/skills/verify-citations/verify-citations.harness.mjs +23 -0
  662. package/src/CLAUDE.md +51 -0
  663. package/src/action-ref.test.ts +26 -0
  664. package/src/action-ref.ts +15 -0
  665. package/src/adapters/banal/failure.test.ts +63 -0
  666. package/src/adapters/banal/failure.ts +118 -0
  667. package/src/adapters/banal/index.test.ts +119 -0
  668. package/src/adapters/banal/index.ts +100 -0
  669. package/src/adapters/banal/install.test.ts +20 -0
  670. package/src/adapters/banal/install.ts +41 -0
  671. package/src/adapters/banal/invocation.test.ts +74 -0
  672. package/src/adapters/banal/invocation.ts +95 -0
  673. package/src/adapters/banal/locate.test.ts +52 -0
  674. package/src/adapters/banal/locate.ts +84 -0
  675. package/src/adapters/banal/output.test.ts +140 -0
  676. package/src/adapters/banal/output.ts +141 -0
  677. package/src/adapters/banal/pin.ts +30 -0
  678. package/src/adapters/banal/probe.ts +35 -0
  679. package/src/adapters/banal/run.test.ts +191 -0
  680. package/src/adapters/banal/run.ts +244 -0
  681. package/src/adapters/banal/settings.test.ts +31 -0
  682. package/src/adapters/banal/settings.ts +55 -0
  683. package/src/adapters/banal/xml.test.ts +111 -0
  684. package/src/adapters/banal/xml.ts +112 -0
  685. package/src/adapters/curl/download.io.ts +73 -0
  686. package/src/adapters/curl/download.test.ts +55 -0
  687. package/src/adapters/curl/index.ts +5 -0
  688. package/src/adapters/memory/index.ts +131 -0
  689. package/src/adapters/node/files.io.ts +39 -0
  690. package/src/adapters/node/files.test.ts +28 -0
  691. package/src/adapters/node/host.io.ts +15 -0
  692. package/src/adapters/node/index.ts +36 -0
  693. package/src/adapters/node/process.io.ts +49 -0
  694. package/src/adapters/node/process.test.ts +46 -0
  695. package/src/adapters/node/workspace.io.ts +40 -0
  696. package/src/adapters/node/workspace.test.ts +58 -0
  697. package/src/adapters/pdfjs/fill.test.ts +111 -0
  698. package/src/adapters/pdfjs/fill.ts +141 -0
  699. package/src/build-engine.harness.mjs +314 -0
  700. package/src/build-engine.ts +219 -0
  701. package/src/build.harness.mjs +631 -0
  702. package/src/build.mutations.mjs +195 -0
  703. package/src/build.ts +793 -0
  704. package/src/cli.harness.mjs +2007 -0
  705. package/src/cli.mutations.mjs +448 -0
  706. package/src/cli.ts +1189 -0
  707. package/src/doctor.harness.mjs +396 -0
  708. package/src/doctor.mutations.mjs +175 -0
  709. package/src/doctor.ts +356 -0
  710. package/src/domain/geometry.ts +108 -0
  711. package/src/domain/host.ts +23 -0
  712. package/src/domain/page-layout.ts +32 -0
  713. package/src/domain/paths.ts +5 -0
  714. package/src/domain/result.test.ts +26 -0
  715. package/src/domain/result.ts +29 -0
  716. package/src/domain/sha256.test.ts +12 -0
  717. package/src/domain/sha256.ts +21 -0
  718. package/src/domain/text.ts +11 -0
  719. package/src/engine.harness.mjs +252 -0
  720. package/src/engine.ts +176 -0
  721. package/src/exit-code.test.ts +21 -0
  722. package/src/exit-code.ts +38 -0
  723. package/src/facts-file.test.ts +240 -0
  724. package/src/facts-file.ts +241 -0
  725. package/src/hooks-settings.harness.mjs +386 -0
  726. package/src/hooks-settings.mutations.mjs +116 -0
  727. package/src/hooks-settings.ts +434 -0
  728. package/src/init.ts +900 -0
  729. package/src/latex-log.harness.mjs +226 -0
  730. package/src/latex-log.ts +234 -0
  731. package/src/latex-loop.harness.mjs +449 -0
  732. package/src/latex-loop.ts +211 -0
  733. package/src/link-skills.harness.mjs +273 -0
  734. package/src/link-skills.mutations.mjs +136 -0
  735. package/src/link-skills.ts +258 -0
  736. package/src/new-paper.harness.mjs +216 -0
  737. package/src/new-paper.mutations.mjs +79 -0
  738. package/src/new-paper.ts +158 -0
  739. package/src/pdf-facts.harness.mjs +188 -0
  740. package/src/pdf-facts.ts +327 -0
  741. package/src/pdf-geometry.harness.mjs +254 -0
  742. package/src/pdf-geometry.ts +300 -0
  743. package/src/ports/download.ts +10 -0
  744. package/src/ports/files.ts +11 -0
  745. package/src/ports/measure-geometry.ts +8 -0
  746. package/src/ports/process.ts +46 -0
  747. package/src/ports/tool-installer.ts +33 -0
  748. package/src/ports/workspace.ts +20 -0
  749. package/src/rules-config.harness.mjs +114 -0
  750. package/src/rules-config.ts +178 -0
  751. package/src/structure.harness.mjs +179 -0
  752. package/src/structure.mutations.mjs +83 -0
  753. package/src/structure.ts +166 -0
  754. package/src/tex-requirements.harness.mjs +238 -0
  755. package/src/tex-requirements.ts +181 -0
  756. package/src/toolchain.harness.mjs +651 -0
  757. package/src/toolchain.ts +755 -0
  758. package/src/types.ts +106 -0
  759. package/templates/paper/PIPELINE-STATUS.md +72 -0
  760. package/templates/paper/paper.md +4 -0
  761. package/templates/paper/paper.tex +8 -0
  762. package/tsconfig.json +23 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `paperlint lint [paths…]` — run every rule over the corpus of papers.
4
+ *
5
+ * 🔴 WHY THIS UTILITY EXISTS. Before it, "installation" meant: install the package AND WRITE BY
6
+ * HAND sixty lines of ESLint flat config, listing ten rules, three languages and four `files`
7
+ * blocks. That is, the tool dumped its own implementation onto the user: to count the bytes of a
8
+ * pdf you first had to learn what `language: "tex/latex"` is. The rules still run under ESLint —
9
+ * but that is INTERNAL machinery, and you no longer need to know it in order to run them.
10
+ *
11
+ * Two entry points into the tool, and both are whole now:
12
+ * npx paperlint lint ← here
13
+ * uses: zernie/research-paper-pipeline@<sha> ← action.yml
14
+ *
15
+ * ⚠️ THE BOUNDARY THIS UTILITY HAS NO RIGHT TO ERASE: the consumer's data stays with the consumer.
16
+ * The typography debt, the marker of the author-list check run, the field dictionary — all of that
17
+ * is about ONE corpus, and wiring it into the package would repeat the defect that put the path
18
+ * `.claude/skills/verify-citations/...` into a rule's message. So they live in the consumer's
19
+ * `rpp.json`.
20
+ *
21
+ * 🔴 WHY THE COMMAND IS CALLED `lint` AND NOT `check`. It does exactly what everyone else calls by
22
+ * that word: reads files, changes nothing, prints findings, exits non-zero. `check` is taken in the
23
+ * ecosystem by another meaning — `cargo check`, `tsc --noEmit`, `npm run check` — it means "build,
24
+ * but do not emit the artifact", that is, half of a BUILD. A package whose paper build comes first
25
+ * has no right to occupy that word with a linter. `check` stays as an alias and prints what
26
+ * replaced it: silently breaking someone else's workflow is worse than asking them to fix a line.
27
+ */
28
+ import { ESLint, type Linter } from "eslint";
29
+ import { readFileSync, existsSync } from "node:fs";
30
+ import { createRequire } from "node:module";
31
+ import { spawnSync } from "node:child_process";
32
+ import { fileURLToPath } from "node:url";
33
+ import { join, dirname, resolve, relative, basename, sep } from "node:path";
34
+ import markdown from "@eslint/markdown";
35
+ // Types come from consumer.d.mts beside it, the same arrangement as lib/paper-config.d.mts.
36
+ import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
37
+ export { isMain };
38
+ import type { Args, RppConfig, ConfigRead } from "./types.ts";
39
+ import {
40
+ checkStructure,
41
+ formatStructure,
42
+ asEslintResults,
43
+ } from "./structure.ts";
44
+ import {
45
+ buildPapers,
46
+ papersIn,
47
+ anyFailed,
48
+ remedyFor,
49
+ readFacts,
50
+ MAIN,
51
+ } from "./build.ts";
52
+ import { prepareEngine } from "./build-engine.ts";
53
+ import { runToolchain } from "./toolchain.ts";
54
+ import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
55
+ import { curlDownload } from "./adapters/curl/index.ts";
56
+ import { hostDirs, nodeAdapters } from "./adapters/node/index.ts";
57
+ import type { ToolInstaller } from "./ports/tool-installer.ts";
58
+ import {
59
+ mergeRequirements,
60
+ requirementsFor,
61
+ NO_REQUIREMENTS,
62
+ type TexRequirements,
63
+ } from "./tex-requirements.ts";
64
+ import { doctor } from "./doctor.ts";
65
+ import { init, processInteractivity, askOnTerminal } from "./init.ts";
66
+ import {
67
+ DEFAULT_FORMAT,
68
+ FORMATS,
69
+ isFormat,
70
+ newPaper,
71
+ reportNewPaper,
72
+ type PaperFormat,
73
+ } from "./new-paper.ts";
74
+ // The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
75
+ // rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
76
+ import {
77
+ CONFIG_KEY,
78
+ LEGACY_CONFIG_KEY,
79
+ LEGACY_KEY_MESSAGE,
80
+ PAPERS_DIR_FIELD,
81
+ SETTINGS_KEYS,
82
+ declaredSettings,
83
+ renamedFieldMessage,
84
+ } from "../lib/paper-config.mjs";
85
+ import {
86
+ parseRuleBlocks,
87
+ shippedRuleIds,
88
+ unknownKeys,
89
+ type Parsed,
90
+ } from "./rules-config.ts";
91
+ export { init };
92
+ export { nextSteps } from "./init.ts";
93
+
94
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
95
+ import paperStages from "../eslint-rules/paper-stages.mjs";
96
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
97
+ import researchQuestion from "../eslint-rules/paper-research-question.mjs";
98
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
99
+ import typography from "../eslint-rules/paper-typography.mjs";
100
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
101
+ import texBuild from "../eslint-rules/tex-build.mjs";
102
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
103
+ import docFields from "../eslint-rules/doc-fields.mjs";
104
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
105
+ import findingsCause from "../eslint-rules/review-findings-cause.mjs";
106
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
107
+ import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
108
+
109
+ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
110
+
111
+ npx paperlint init [dir] set the project up: detect the papers directory, declare it
112
+ in package.json, link the skills, wire the hooks into
113
+ .claude/settings.json, offer the CI step, report what is missing
114
+ npx paperlint new <name> [--format tex|md]
115
+ create <papers>/<name>/ from the template; never overwrites,
116
+ on an existing folder adds only the missing files, then lints it
117
+ npx paperlint lint [paths…] run every rule over your papers
118
+ npx paperlint build <paper> | --all
119
+ compile paper.tex to paper.pdf: pdflatex and bibtex, rerun until
120
+ the references settle. Prints the plan first; a build.sh in the
121
+ paper directory is ignored (--dry-run: print the plan only).
122
+ Compiles with paperlint's TeX Live, else one on PATH that has every
123
+ package the venue declares; on a terminal it offers to install
124
+ one, without a terminal it stops and names \`npx paperlint toolchain\`
125
+ npx paperlint toolchain [--check] install TeX Live with every package the venue profiles declare
126
+ into ~/.cache/rpp/texlive (RPP_TEXLIVE_DIR overrides); a second
127
+ run does nothing. --check: report what is missing, change nothing
128
+ npx paperlint doctor say what is actually wired — and what only LOOKS wired
129
+ npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
130
+ npx paperlint --help
131
+
132
+ init:
133
+ --yes, -y ask nothing, take every default. Without a terminal on stdin AND stdout,
134
+ or with CI set, nothing is asked either
135
+ --no-hooks do not wire the hooks (the default without a human is to wire them)
136
+ --paper <name> create this paper too (without a human, the only way init creates one)
137
+ --format tex|md the new paper's source format; default tex
138
+
139
+ lint:
140
+ npx paperlint lint [paths…] [--config <file.json>] [--json]
141
+
142
+ <paths…> where your papers live, e.g. papers. Optional ONLY because the declaration
143
+ names it — one of the two must name the scope. There is no default
144
+ of ".": linting whatever happens to be in the checkout is how a green
145
+ report over a scope nobody chose gets produced.
146
+ --config <file> read the settings from this file instead of the discovered one
147
+ --json machine-readable findings on stdout, nothing else on it
148
+ --max-warnings <n> fail when warnings exceed n. Default -1: warnings never fail, because
149
+ most findings here are advisory and a gate that fails on advice gets muted
150
+
151
+ settings — the \`paperlint\` key of your package.json, found by walking up from the
152
+ current directory, the way every other tool in the stack finds its config. \`rpp.json\` is still
153
+ read as a deprecated fallback and the run says so. \`papersDir\` is required; the rest is optional:
154
+
155
+ "paperlint": {
156
+ "papersDir": "papers",
157
+ "authorListCommand": "node scripts/bib-authors.mjs",
158
+ "typographyDebt": { "papers/my-paper": { "sectionSign": 12 } },
159
+ "docFields": { "read": { "values": ["full", "abstract", "none"] } },
160
+ "reviewSince": "2026-08-23",
161
+ "minFindings": 3,
162
+ "causeMarker": "Cause:",
163
+ "rules": [ { "files": ["papers/my-paper/**"],
164
+ "rules": { "pdf/last-page-balance": "error" } } ]
165
+ }
166
+
167
+ "rules" takes ESLint flat-config blocks (files, ignores, rules), appended after rpp's own, with
168
+ files relative to the file holding the settings. Optional rules (off unless turned on there):
169
+ pdf/last-page-balance. An unknown key, anywhere in the settings, is an error.
170
+ `;
171
+
172
+ /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
173
+ export function buildConfig(
174
+ opts: RppConfig = {},
175
+ texLanguage: unknown,
176
+ ): unknown[] {
177
+ const paperRules = { ...researchQuestion.rules, ...typography.rules };
178
+ const typographyOpt = ["warn", { debt: opts.typographyDebt ?? {} }];
179
+ const md = {
180
+ language: "markdown/gfm",
181
+ languageOptions: { frontmatter: "yaml" },
182
+ };
183
+
184
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
185
+ const cfg: any[] = [
186
+ // 🔴 THE PROJECT'S PAPER TEMPLATE IS NOT A PAPER. `paperlint new` reads `<papers>/.template/`, and
187
+ // its files carry every marker a paper does. Flat config does NOT ignore dot-directories by
188
+ // default (only `node_modules/` and `.git/`), so without this block `paperlint lint` would lint the
189
+ // template as a paper — and a richer template with placeholder stages would fail the run.
190
+ { ignores: ["**/.template/"] },
191
+ // The `pdf` plugin is registered for EVERY file, and its rule is on for none. A consumer's
192
+ // block (`rules`, appended below) turns it on for a glob that also matches markdown files;
193
+ // with the plugin defined only beside `paper.tex`, ESLint would refuse those files with
194
+ // "could not find plugin". The rule itself acts on `paper.tex` only.
195
+ { plugins: { pdf: pdfRules } },
196
+ {
197
+ files: ["**/PIPELINE-STATUS.md"],
198
+ plugins: { markdown, paper: paperStages },
199
+ ...md,
200
+ rules: {
201
+ "paper/stages": "error",
202
+ "paper/source": "error",
203
+ "paper/author-list": [
204
+ "warn",
205
+ opts.authorListCommand ? { command: opts.authorListCommand } : {},
206
+ ],
207
+ },
208
+ },
209
+ {
210
+ files: ["**/paper.md", "**/draft.md"],
211
+ plugins: { markdown, paper: { rules: paperRules } },
212
+ ...md,
213
+ rules: {
214
+ "paper/research-question": "warn",
215
+ "paper/typography": typographyOpt,
216
+ },
217
+ },
218
+ {
219
+ files: ["**/reviews/*.md"],
220
+ plugins: {
221
+ markdown,
222
+ review: { rules: { ...findingsCause.rules } },
223
+ doc: docFields,
224
+ },
225
+ ...md,
226
+ rules: {
227
+ /*
228
+ * 🔴 THE DEFAULT IS ENGLISH SINCE 2026-09-17. It used to be the Russian word for "Cause:" —
229
+ * a Russian word in a package whose interface is English. An `error`-level rule demanded
230
+ * that a person put Cyrillic into their own file, and there was nothing to change the
231
+ * marker with: `causeMarker` was not threaded through the CLI at all. The only way out was
232
+ * to abandon the command and assemble the ESLint config by hand — that is, the defect
233
+ * pushed you onto exactly the path the utility frees you from.
234
+ * The Russian marker stays EXPRESSIBLE, but now as a value, not as the default.
235
+ */
236
+ "review/findings-cause": [
237
+ "error",
238
+ {
239
+ minFindings: opts.minFindings ?? 3,
240
+ ...(opts.causeMarker ? { causeMarker: opts.causeMarker } : {}),
241
+ ...(opts.reviewSince ? { sinceCreated: opts.reviewSince } : {}),
242
+ },
243
+ ],
244
+ ...(opts.docFields
245
+ ? { "doc/fields": ["warn", { fields: opts.docFields }] }
246
+ : {}),
247
+ },
248
+ },
249
+ ];
250
+
251
+ // `.tex` only if the language loaded: it pulls in the LaTeX parser, and dying because of it on a
252
+ // corpus without a single `.tex` would be refusing to work where work is possible.
253
+ if (texLanguage)
254
+ cfg.push({
255
+ files: ["**/paper.tex"],
256
+ plugins: {
257
+ tex: { languages: { latex: texLanguage }, rules: texBuild },
258
+ paper: { rules: paperRules },
259
+ },
260
+ language: "tex/latex",
261
+ rules: {
262
+ "paper/research-question": "warn",
263
+ "paper/typography": typographyOpt,
264
+ "tex/future-promise": "warn",
265
+ "tex/acm-frontmatter-override": "error",
266
+ },
267
+ });
268
+ // The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
269
+ // `readConfig`; each carries the settings file's directory as its `basePath`.
270
+ cfg.push(...(opts.rules ?? []));
271
+ return cfg;
272
+ }
273
+
274
+ /**
275
+ * The rule ids a consumer may name in `rules`: every rule rpp's own config defines, read off that
276
+ * config rather than listed again. `@eslint/markdown` is a dependency's plugin, not rpp's.
277
+ */
278
+ export const SHIPPED_RULES: ReadonlySet<string> = shippedRuleIds(
279
+ buildConfig({}, { sentinel: "tex language" }),
280
+ [markdown],
281
+ );
282
+
283
+ /** Whether a rule entry (`"error"`, `2`, `["warn", {…}]`) turns the rule on. */
284
+ const isOn = (entry: unknown): boolean => {
285
+ const sev = Array.isArray(entry) ? entry[0] : entry;
286
+ return sev !== undefined && sev !== "off" && sev !== 0;
287
+ };
288
+
289
+ /**
290
+ * Rules paperlint ships and turns on for no file itself — the ones a consumer opts into with `rules`.
291
+ * Derived: every shipped rule that no block of rpp's own config names.
292
+ */
293
+ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
294
+ [...SHIPPED_RULES].filter(
295
+ (id) =>
296
+ !buildConfig({}, { sentinel: "tex language" }).some((b) =>
297
+ isOn((b as { rules?: Record<string, unknown> }).rules?.[id]),
298
+ ),
299
+ ),
300
+ );
301
+
302
+ /**
303
+ * 🔴 AN OPTIONAL RULE THAT IS ON AND REACHES NO PAPER IS A GREEN ZERO. A `files` glob that matches
304
+ * nothing — a typo, a path relative to the wrong directory — leaves the rule never invoked, and a
305
+ * rule that never runs reports exactly like a rule that passed. So for every optional rule the
306
+ * consumer turned on, some linted `paper.tex` must actually have it enabled; the ones none has are
307
+ * returned, for the caller to refuse.
308
+ */
309
+ export async function silentOptionalRules(
310
+ eslint: ESLint,
311
+ lintedFiles: readonly string[],
312
+ opts: RppConfig,
313
+ ): Promise<string[]> {
314
+ const turnedOn = new Set(
315
+ (opts.rules ?? []).flatMap((b) =>
316
+ Object.entries(b.rules)
317
+ .filter(([id, e]) => OPTIONAL_RULES.has(id) && isOn(e))
318
+ .map(([id]) => id),
319
+ ),
320
+ );
321
+ const reached = new Set<string>();
322
+ for (const f of lintedFiles.filter((p) => basename(p) === MAIN)) {
323
+ const cfg = (await eslint.calculateConfigForFile(f)) as {
324
+ rules?: Record<string, unknown>;
325
+ };
326
+ for (const id of turnedOn) if (isOn(cfg.rules?.[id])) reached.add(id);
327
+ }
328
+ return [...turnedOn].filter((id) => !reached.has(id));
329
+ }
330
+
331
+ /**
332
+ * The settings after the boundary: an unknown key is refused by name, and `rules` becomes parsed
333
+ * config blocks. Nothing after this sees the raw object.
334
+ */
335
+ export function parseSettings(
336
+ opts: RppConfig,
337
+ where: string,
338
+ baseDir: string,
339
+ ): Parsed<RppConfig> {
340
+ const raw = opts as Record<string, unknown>;
341
+ const unknown = unknownKeys(raw);
342
+ if (unknown.length > 0)
343
+ return {
344
+ ok: false,
345
+ error:
346
+ `${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
347
+ `a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
348
+ };
349
+ const rules = parseRuleBlocks(raw["rules"], where, SHIPPED_RULES, baseDir);
350
+ if (!rules.ok) return rules;
351
+ return { ok: true, value: { ...opts, rules: rules.value } };
352
+ }
353
+
354
+ /**
355
+ * The directory ESLint runs from. ESLint ignores every file outside it (#48), and
356
+ * `paper/typography` reads its debt keys relative to it — keys the config writes from its own
357
+ * directory. So: the deepest directory holding the config's directory and every path. With the
358
+ * papers inside the config's directory, that is the config's directory itself.
359
+ */
360
+ const lintRoot = (home: string, paths: readonly string[]): string =>
361
+ commonDir([home, ...paths]);
362
+
363
+ /** The longest shared leading run of path segments. */
364
+ const commonDir = (paths: readonly string[]): string => {
365
+ const [first = [], ...rest] = paths.map((p) => p.split(sep));
366
+ const end = first.findIndex((part, i) =>
367
+ rest.some((other) => other[i] !== part),
368
+ );
369
+ return first.slice(0, end === -1 ? undefined : end).join(sep) || sep;
370
+ };
371
+
372
+ export function parseArgs(argv: readonly string[]): Args {
373
+ // `--help` is parsed BEFORE argv[0] becomes the command: otherwise `paperlint --help` answers
374
+ // "unknown command `--help`" — caught by the very first run of the utility.
375
+ const out: Args = {
376
+ cmd: null,
377
+ paths: [],
378
+ config: null,
379
+ json: false,
380
+ all: false,
381
+ dryRun: false,
382
+ check: false,
383
+ yes: false,
384
+ noHooks: false,
385
+ paper: null,
386
+ format: null,
387
+ hooksMode: null,
388
+ // -1 = warnings NEVER fail the run. In this set most findings are advisory by design, and a
389
+ // gate that fails on advice gets muted entirely.
390
+ maxWarnings: -1,
391
+ };
392
+ const rest = [...argv];
393
+ if (rest[0] && !rest[0].startsWith("-")) out.cmd = rest.shift() ?? null;
394
+
395
+ // 🔴 A FLAG WHOSE VALUE WAS TAKEN AWAY IS A REFUSAL, NOT A DEFAULT. The compiler found this
396
+ // during the move to TypeScript: `rest[++i]` past the last argument gives `undefined`, and
397
+ // `paperlint lint --config` (the value forgotten, or eaten by a substitution in CI) silently turned
398
+ // into "no config given" — that is, it went to auto-discovery and linted against SOMEONE ELSE'S
399
+ // file, saying nothing. The failure is one-sided and toward silence, so it is cured by
400
+ // behaviour, not by a type cast.
401
+ const valueFor = (flag: string, i: number): string | undefined => {
402
+ const v = rest[i];
403
+ if (v === undefined) out.missingValue = flag;
404
+ return v;
405
+ };
406
+
407
+ for (let i = 0; i < rest.length; i++) {
408
+ const a = rest[i];
409
+ if (a === undefined) continue;
410
+ if (a === "--json") out.json = true;
411
+ else if (a === "--all") out.all = true;
412
+ else if (a === "--dry-run") out.dryRun = true;
413
+ else if (a === "--check") out.check = true;
414
+ else if (a === "--yes" || a === "-y") out.yes = true;
415
+ else if (a === "--no-hooks") out.noHooks = true;
416
+ else if (a.startsWith("--hooks="))
417
+ out.hooksMode = a.slice("--hooks=".length);
418
+ else if (a === "--paper") out.paper = valueFor(a, ++i) ?? null;
419
+ else if (a === "--format") out.format = valueFor(a, ++i) ?? null;
420
+ // `--options` was the first spelling and is kept working. It named the wrong thing — every
421
+ // other tool in the stack calls this file its config — but a flag in someone's CI is not
422
+ // ours to break.
423
+ else if (a === "--config" || a === "--options")
424
+ out.config = valueFor(a, ++i) ?? null;
425
+ else if (a === "--max-warnings") {
426
+ const v = valueFor(a, ++i);
427
+ if (v !== undefined) out.maxWarnings = Number(v);
428
+ } else if (a === "--help" || a === "-h") out.help = true;
429
+ else out.paths.push(a);
430
+ }
431
+ return out;
432
+ }
433
+
434
+ export const CONFIG_NAME = "rpp.json";
435
+ export const PKG_NAME = "package.json";
436
+
437
+ /**
438
+ * This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
439
+ * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
440
+ * the placeholder instead of guessing.
441
+ */
442
+ function ownVersion(): string | undefined {
443
+ try {
444
+ const v = JSON.parse(
445
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
446
+ )?.version;
447
+ return typeof v === "string" ? v : undefined;
448
+ } catch {
449
+ return undefined;
450
+ }
451
+ }
452
+
453
+ /** Where the consumer's settings were found, and in which of the two carriers. */
454
+ export interface Declaration {
455
+ readonly path: string;
456
+ readonly kind: "package.json" | "rpp.json";
457
+ }
458
+
459
+ /**
460
+ * 🔴 THE CLI HAD TO LEARN TO READ `package.json`, AND THAT IS NOT A SIDE ERRAND. `paperlint init` now
461
+ * writes ONE declaration, into the `package.json` key that the three hooks and `eslint-rules`
462
+ * already read. Without this walker the install it produces would not work at all: `paperlint lint`
463
+ * would find no `rpp.json`, report "nothing to lint", and the consumer would be back to
464
+ * declaring the same directory twice — the defect the single declaration removes (issue #33,
465
+ * `docs/install.md`).
466
+ *
467
+ * `rpp.json` stays readable as a DEPRECATED fallback, and the read says so out loud. Silently
468
+ * dropping a file this command used to write would break working setups on upgrade.
469
+ *
470
+ * The walk goes up to the filesystem root, the way eslint, prettier and tsc find theirs, so a run
471
+ * from inside one paper sees the same settings as a run from the repository root. At each level
472
+ * `package.json` wins over `rpp.json`: it is the carrier every other reader uses, so preferring
473
+ * it is what keeps "one declaration" true rather than merely intended.
474
+ */
475
+ export function findDeclaration(startDir: string): Declaration | null {
476
+ let dir = resolve(startDir);
477
+ for (;;) {
478
+ const pkg = join(dir, PKG_NAME);
479
+ if (existsSync(pkg) && declaresSettings(pkg))
480
+ return { path: pkg, kind: "package.json" };
481
+ const rpp = join(dir, CONFIG_NAME);
482
+ if (existsSync(rpp)) return { path: rpp, kind: "rpp.json" };
483
+ const up = dirname(dir);
484
+ if (up === dir) return null;
485
+ dir = up;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * A `package.json` WITHOUT the key is not a declaration and must not stop the walk — every
491
+ * project on the way up has one, so stopping there would make the search find nothing, always.
492
+ * An unparsable one is treated the same way here; `paperlint doctor` is the command that reports it.
493
+ */
494
+ const declaresSettings = (pkgPath: string): boolean => {
495
+ try {
496
+ const d = declaredSettings(JSON.parse(readFileSync(pkgPath, "utf8")));
497
+ return d.settings !== undefined || d.conflict !== null;
498
+ } catch {
499
+ return false;
500
+ }
501
+ };
502
+
503
+ /** Kept as the one-line question "which file holds the settings" — callers that only need a path. */
504
+ export function findConfig(startDir: string): string | null {
505
+ return findDeclaration(startDir)?.path ?? null;
506
+ }
507
+
508
+ /**
509
+ * Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
510
+ * command needed the same config (`build`): two copies of this block would have drifted apart on
511
+ * the very first edit — exactly the class that already cost us the empty-set guard in two places.
512
+ *
513
+ * @returns `{ opts, configPath }` on success, or `{ code }` — and then the caller exits with it.
514
+ */
515
+ export function readConfig(
516
+ a: Args,
517
+ {
518
+ log = console.log,
519
+ err = console.error,
520
+ cwd = process.cwd(),
521
+ }: {
522
+ log?: typeof console.log;
523
+ err?: typeof console.error;
524
+ cwd?: string;
525
+ } = {},
526
+ ): ConfigRead {
527
+ // 🔴 THE CONFIG FINDS ITSELF. An explicit `--config` beats the discovered one — it was named out
528
+ // loud, and a substitution is never silent. For an explicit path the FILE NAME decides the
529
+ // carrier: the path here is a value, not a text to make guesses about, and `package.json` holds
530
+ // the settings under a key.
531
+ const decl: Declaration | null = a.config
532
+ ? {
533
+ path: a.config,
534
+ kind: basename(a.config) === PKG_NAME ? "package.json" : "rpp.json",
535
+ }
536
+ : findDeclaration(cwd);
537
+ const configPath = decl?.path ?? null;
538
+ if (a.config && !existsSync(a.config)) {
539
+ err(`config file not found: ${a.config}`);
540
+ return { code: 2 };
541
+ }
542
+
543
+ let opts: RppConfig = {};
544
+ let legacyKey = false;
545
+ if (decl && configPath) {
546
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
547
+ let parsed: any;
548
+ try {
549
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
550
+ } catch (e) {
551
+ err(`${configPath} is not valid JSON: ${(e as Error).message}`);
552
+ return { code: 2 };
553
+ }
554
+ if (decl.kind === "package.json") {
555
+ const d = declaredSettings(parsed);
556
+ if (d.conflict !== null) {
557
+ err(d.conflict);
558
+ return { code: 2 };
559
+ }
560
+ opts = (d.settings ?? {}) as RppConfig;
561
+ legacyKey = d.legacy;
562
+ } else opts = parsed;
563
+ // The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
564
+ // up someone else's file and does not say so — and a typography-debt mismatch looks like a finding.
565
+ //
566
+ // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
567
+ // the array breaks any `| jq`, and it breaks it for the consumer, not for us. Caught not by a
568
+ // test but by an attempt to wire our own action to this output; in the harness I first WORKED
569
+ // AROUND this line (stripped the first line before JSON.parse) — that is, the workaround hid
570
+ // the defect exactly where it should have been shouting.
571
+ (a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_NAME}`);
572
+ // 🔴 THE DEPRECATED CARRIER IS NAMED OUT LOUD, IT DOES NOT STOP BEING READ. The hooks read ONLY
573
+ // package.json, so a consumer whose settings stayed in rpp.json lints one directory and guards
574
+ // another — and both states look equally green.
575
+ if (decl.kind === "rpp.json")
576
+ (a.json ? err : log)(
577
+ ` ⚠ ${CONFIG_NAME} is deprecated — move these keys under "${CONFIG_KEY}" in ${PKG_NAME}; ` +
578
+ `the hooks read only that file. \`npx paperlint init\` does it for you.`,
579
+ );
580
+ if (legacyKey) (a.json ? err : log)(` ⚠ ${LEGACY_KEY_MESSAGE}`);
581
+ }
582
+
583
+ // The old field name is refused before anything else is read from the settings: falling back
584
+ // to it would keep it working forever, and this package has no released users to migrate.
585
+ const where =
586
+ decl?.kind === "package.json"
587
+ ? `${PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`
588
+ : (decl?.path ?? CONFIG_NAME);
589
+ const renamed = decl ? renamedFieldMessage(opts, where) : null;
590
+ if (renamed) {
591
+ err(renamed);
592
+ return { code: 2 };
593
+ }
594
+ if (decl && configPath) {
595
+ const parsed = parseSettings(
596
+ opts,
597
+ where,
598
+ dirname(resolve(cwd, configPath)),
599
+ );
600
+ if (!parsed.ok) {
601
+ err(parsed.error);
602
+ return { code: 2 };
603
+ }
604
+ opts = parsed.value;
605
+ }
606
+
607
+ // 🔴 THE PAPERS DIRECTORY IS A REQUIRED FIELD. The papers directory is the one thing without which the tool
608
+ // does not know what it works on, and the one thing that cannot be guessed: a default of "." runs
609
+ // the rules over the whole checkout and exits green over a scope nobody chose.
610
+ if (decl && !hasPapers(opts)) {
611
+ err(
612
+ decl.kind === "package.json"
613
+ ? `${decl.path} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
614
+ ` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
615
+ `It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`
616
+ : `${decl.path} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
617
+ ` { "${PAPERS_DIR_FIELD}": "papers" }\n` +
618
+ `It is the one thing this tool cannot guess.`,
619
+ );
620
+ return { code: 2 };
621
+ }
622
+ return { opts, configPath };
623
+ }
624
+
625
+ /** The papers directory field of the settings, read by its one declared name. */
626
+ export function papersDirOf(opts: RppConfig): unknown {
627
+ return (opts as Record<string, unknown>)[PAPERS_DIR_FIELD];
628
+ }
629
+
630
+ /** The papers directory may be one directory or several; both spellings normalise to a list. */
631
+ export function toPaths(papers: unknown): string[] {
632
+ if (typeof papers === "string") return papers.trim() ? [papers.trim()] : [];
633
+ if (Array.isArray(papers))
634
+ return papers.filter((x) => typeof x === "string" && x.trim());
635
+ return [];
636
+ }
637
+
638
+ const hasPapers = (opts: RppConfig): boolean =>
639
+ toPaths(papersDirOf(opts)).length > 0;
640
+
641
+ /**
642
+ * `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
643
+ * address the runtime from the project root.
644
+ *
645
+ * 🔴 WHAT IT WAS AND WHY IT BROKE. `hooks.json` called
646
+ * node "${CLAUDE_PROJECT_DIR}/node_modules/vigiles/dist/cli.js" hook-runtime run-program …
647
+ * While `vigiles` was a PEER dependency this path was GUARANTEED: a peer is installed by the
648
+ * consumer itself, into its own root. After the move to ordinary dependencies the guarantee was
649
+ * gone, and a measurement showed it — one tarball, two managers:
650
+ * npm: node_modules/vigiles/dist/cli.js PRESENT
651
+ * pnpm: node_modules/vigiles/dist/cli.js ABSENT (only paperlint in the root)
652
+ * The cost of the failure is asymmetric: `|| exit 2` stood on PreToolUse(Bash), that is, ANY
653
+ * command was denied, including the one you fix it with.
654
+ *
655
+ * WHAT IT IS NOW. The wiring calls ITS OWN bin — `paperlint` is a direct dependency,
656
+ * so it lies in the root under any manager — and the runtime is resolved FROM THE POSITION OF THIS
657
+ * FILE via `createRequire`. Wherever the manager laid the tree out, the resolver finds the same
658
+ * thing an `import` from inside the package would.
659
+ *
660
+ * 🔴 AND `|| exit 2` IS REMOVED FROM THE SHELL. The decision to stop is a decision, and it is taken
661
+ * here, in code. In the shell it meant "any mishap = block everything": the runtime was not found —
662
+ * work stopped. Now a runtime that is not found complains LOUDLY and returns 0, while the hook's
663
+ * real verdict (2 included) passes through. Silent degradation is worse than explicit degradation,
664
+ * but blocking everything is worse than both.
665
+ */
666
+ export function runHook(
667
+ name: string | undefined,
668
+ {
669
+ err = console.error,
670
+ run = spawnSync,
671
+ // The resolver is injected so that "the runtime was not found" is checked by an assert and not
672
+ // by deleting node_modules: a failure must be reproducible, not staged.
673
+ // 🔴 WE RESOLVE THE PACKAGE, NOT A FILE INSIDE IT. `require.resolve("vigiles/dist/cli.js")`
674
+ // DOES NOT WORK: the package's `exports` map hands out only "." and nine named subpaths, and
675
+ // `./dist/cli.js` — even `./package.json` — is not among them:
676
+ // Package subpath './dist/cli.js' is not defined by "exports"
677
+ // This is neither our oversight nor their bug: a closed export map is normal practice. So we
678
+ // resolve the root entry ("." → dist/test.js), take its directory and put `cli.js` next to it —
679
+ // the very file the package itself declares as its `bin`.
680
+ resolve = (spec: string): string =>
681
+ createRequire(import.meta.url).resolve(spec),
682
+ }: {
683
+ err?: typeof console.error;
684
+ run?: typeof spawnSync;
685
+ resolve?: (spec: string) => string;
686
+ } = {},
687
+ ): number {
688
+ if (!name) {
689
+ err(`\`hook\` needs a name, e.g. \`paperlint hook paper-edit-guard\``);
690
+ return 2;
691
+ }
692
+ const program = fileURLToPath(
693
+ new URL(`../hooks/${name}.hook.mjs`, import.meta.url),
694
+ );
695
+ if (!existsSync(program)) {
696
+ err(`unknown hook \`${name}\` — no such program at ${program}`);
697
+ return 2;
698
+ }
699
+ let runtime;
700
+ try {
701
+ runtime = join(dirname(resolve("vigiles")), "cli.js");
702
+ if (!existsSync(runtime))
703
+ throw new Error(`resolved vigiles, but no cli.js beside it: ${runtime}`);
704
+ } catch {
705
+ err(
706
+ `rpp: the hook runtime (vigiles) is not resolvable from ${fileURLToPath(new URL(".", import.meta.url))}.\n` +
707
+ `The \`${name}\` hook is NOT running. Everything else — \`paperlint lint\`, CI — is unaffected.\n` +
708
+ `Reinstall this package so its dependencies are present.`,
709
+ );
710
+ return 0;
711
+ }
712
+ const r = run(
713
+ process.execPath,
714
+ [runtime, "hook-runtime", "run-program", program],
715
+ {
716
+ stdio: "inherit",
717
+ },
718
+ );
719
+ return r.status ?? 0;
720
+ }
721
+
722
+ /**
723
+ * Create one paper and lint it — shared by `paperlint new` and `paperlint init --paper`, so the two cannot
724
+ * become two implementations. The lint runs on THAT folder, so the first thing printed after the
725
+ * file list is its verdict rather than the old "missing PIPELINE-STATUS.md".
726
+ */
727
+ export async function createPaperAt(
728
+ papersRoot: string,
729
+ name: string,
730
+ format: PaperFormat,
731
+ {
732
+ log,
733
+ err,
734
+ cwd,
735
+ }: { log: typeof console.log; err: typeof console.error; cwd: string },
736
+ ): Promise<number> {
737
+ const result = newPaper(papersRoot, name, format);
738
+ const here = (p: string): string => relative(cwd, p) || p;
739
+ for (const line of reportNewPaper(result, here)) log(line);
740
+ if (!result.ok) return 2;
741
+ log(``);
742
+ return run(["lint", result.dir], { log, err, cwd });
743
+ }
744
+
745
+ /**
746
+ * `paperlint new <name>` — the papers directory comes from the same declaration every other command
747
+ * reads; there is no second way to name it. See `new-paper.ts` for what it writes and why.
748
+ */
749
+ async function runNew(
750
+ a: Args,
751
+ {
752
+ log,
753
+ err,
754
+ cwd,
755
+ ask = askOnTerminal,
756
+ }: {
757
+ log: typeof console.log;
758
+ err: typeof console.error;
759
+ cwd: string;
760
+ ask?: (q: string) => Promise<string>;
761
+ },
762
+ ): Promise<number> {
763
+ const [name, ...extra] = a.paths;
764
+ if (!name || extra.length > 0) {
765
+ err(
766
+ `\`new\` takes exactly one paper name: \`paperlint new my-paper [--format tex|md]\``,
767
+ );
768
+ return 2;
769
+ }
770
+ let format: PaperFormat = DEFAULT_FORMAT;
771
+ if (a.format !== null) {
772
+ if (!isFormat(a.format)) {
773
+ err(
774
+ `--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``,
775
+ );
776
+ return 2;
777
+ }
778
+ format = a.format;
779
+ } else if (processInteractivity(a.yes).interactive) {
780
+ const f = await ask(`format: tex / md [${DEFAULT_FORMAT}] `).catch(
781
+ () => "",
782
+ );
783
+ if (isFormat(f.trim())) format = f.trim() as PaperFormat;
784
+ }
785
+ const cfg = readConfig({ ...a, json: false }, { log: () => {}, err, cwd });
786
+ if (cfg.code !== undefined) return cfg.code;
787
+ const roots = toPaths(papersDirOf(cfg.opts)).map((rel) =>
788
+ resolve(dirname(cfg.configPath ?? cwd), rel),
789
+ );
790
+ const papersRoot = roots[0];
791
+ if (!cfg.configPath || papersRoot === undefined) {
792
+ err(
793
+ `no papers directory is declared, so there is nowhere to put \`${name}\`.\n` +
794
+ `Run \`npx paperlint init\` first — it declares the directory in package.json.`,
795
+ );
796
+ return 2;
797
+ }
798
+ if (roots.length > 1)
799
+ log(
800
+ `several papers directories are declared — using the first: ${relative(cwd, papersRoot) || papersRoot}`,
801
+ );
802
+ return createPaperAt(papersRoot, name, format, { log, err, cwd });
803
+ }
804
+
805
+ /**
806
+ * 🔴 THE TARGET IS NAMED, "EVERYTHING" IS AN OPTION. That is how it is for everyone whose build is
807
+ * expensive and has side effects: `make <target>`, `docker build <context>`, `latexmk paper.tex`;
808
+ * with cargo, "the whole workspace" is turned on by a separate flag. A default of "build
809
+ * everything" on a corpus of five papers is twenty pdflatex runs instead of one, and almost never
810
+ * what was wanted.
811
+ */
812
+ async function runBuild(
813
+ a: Args,
814
+ {
815
+ log,
816
+ err,
817
+ cwd,
818
+ }: { log: typeof console.log; err: typeof console.error; cwd: string },
819
+ ): Promise<number> {
820
+ const cfg = readConfig(a, { log, err, cwd });
821
+ if (cfg.code !== undefined) return cfg.code;
822
+ const { opts, configPath } = cfg;
823
+ // The key that used to name the scripts to run. It is read by nothing now; saying so beats a
824
+ // setting that silently stopped doing anything.
825
+ if (opts.buildScripts !== undefined)
826
+ log(
827
+ `note: "buildScripts" in ${relative(cwd, configPath ?? "") || "the settings"} is ignored — paperlint builds the paper itself`,
828
+ );
829
+ const roots = toPaths(papersDirOf(opts)).map((rel) =>
830
+ resolve(configPath ? dirname(configPath) : cwd, rel),
831
+ );
832
+
833
+ let targets;
834
+ if (a.all) {
835
+ targets = roots.flatMap((r) => papersIn(r));
836
+ if (targets.length === 0) {
837
+ err(
838
+ `--all: no papers found under ${roots.join(", ") || "(nothing declared)"}`,
839
+ );
840
+ return 1;
841
+ }
842
+ } else if (a.paths.length > 0) {
843
+ targets = a.paths.map((p) => resolve(cwd, p));
844
+ } else {
845
+ err(
846
+ `\`build\` needs a target: \`paperlint build papers/my-paper\` or \`paperlint build --all\`.\n` +
847
+ `There is deliberately no "build everything" default: a build is expensive and has side\n` +
848
+ `effects, so the target is named — as with make, docker and latexmk.`,
849
+ );
850
+ return 2;
851
+ }
852
+
853
+ // The engine is resolved INSIDE buildPapers, after it has removed the stale PDFs: a run that
854
+ // stops for want of a TeX Live must not leave an old paper.pdf looking current either.
855
+ const out = await buildPapers(targets, {
856
+ cwd,
857
+ dryRun: a.dryRun,
858
+ log,
859
+ engine: () => engineEnv(targets, a, { log, err }),
860
+ });
861
+ if (out.kind === "no-engine") return 1;
862
+ const remedy = remedyFor(out.results);
863
+ if (remedy) err(remedy);
864
+ return anyFailed(out.results) ? 1 : 0;
865
+ }
866
+
867
+ /** What one paper needs from TeX Live; a venue.json that does not parse is the build's to report. */
868
+ function paperRequirements(dir: string): TexRequirements {
869
+ let venue: string | null = null;
870
+ try {
871
+ venue = readFacts(dir).venue;
872
+ } catch {
873
+ venue = null;
874
+ }
875
+ return requirementsFor(venue).tex;
876
+ }
877
+
878
+ /**
879
+ * The environment the builds run in — PATH led by a TeX Live that has every package the targeted
880
+ * papers' venues declare — or null when there is none and none was installed (the reason is
881
+ * already printed). Papers without `paper.tex` need no engine: they are refused by the build.
882
+ */
883
+ async function engineEnv(
884
+ targets: readonly string[],
885
+ a: Args,
886
+ { log, err }: { log: typeof console.log; err: typeof console.error },
887
+ ): Promise<NodeJS.ProcessEnv | null> {
888
+ const latex = targets.filter((t) => existsSync(join(t, MAIN)));
889
+ if (latex.length === 0) return process.env;
890
+ const tex = latex
891
+ .map(paperRequirements)
892
+ .reduce(mergeRequirements, NO_REQUIREMENTS);
893
+ const out = await prepareEngine({
894
+ tex,
895
+ dryRun: a.dryRun,
896
+ interactive: processInteractivity(false).interactive,
897
+ ask: askOnTerminal,
898
+ log,
899
+ err,
900
+ });
901
+ return out.ok ? out.env : null;
902
+ }
903
+
904
+ /** Commands that take the parsed arguments and the output streams, and nothing else. */
905
+ const SIMPLE: Readonly<
906
+ Record<
907
+ string,
908
+ (
909
+ a: Args,
910
+ io: { log: typeof console.log; err: typeof console.error; cwd: string },
911
+ ) => number | Promise<number>
912
+ >
913
+ > = {
914
+ hook: (a, { err }) => runHook(a.paths[0], { err }),
915
+ new: (a, io) => runNew(a, io),
916
+ build: (a, io) => runBuild(a, io),
917
+ toolchain: (a, { log, err }) =>
918
+ runToolchain({ check: a.check, log, err, banal: hostBanalInstaller() }),
919
+ };
920
+
921
+ /** banal's installer, wired from this process's environment: the composition root's work. */
922
+ function hostBanalInstaller(): ToolInstaller {
923
+ const s = parseBanalSettings(process.env, hostDirs());
924
+ const ports = nodeAdapters({ tmpDir: s.tmpDir });
925
+ const download = curlDownload({
926
+ run: ports.run,
927
+ env: s.processEnv,
928
+ tmpDir: s.tmpDir,
929
+ });
930
+ return banalInstaller({ ...ports, download }, s);
931
+ }
932
+
933
+ export async function run(
934
+ argv: readonly string[],
935
+ {
936
+ log = console.log,
937
+ err = console.error,
938
+ cwd = process.cwd(),
939
+ }: {
940
+ log?: typeof console.log;
941
+ err?: typeof console.error;
942
+ cwd?: string;
943
+ } = {},
944
+ ): Promise<number> {
945
+ const a = parseArgs(argv);
946
+ // The refusal must come FIRST: behind a flag without a value there is usually a typo, or a
947
+ // substitution in CI that collapsed into nothing, and any continuation works on something other
948
+ // than what was asked for.
949
+ if (a.missingValue) {
950
+ err(
951
+ `${a.missingValue} needs a value — it was given none.\n` +
952
+ `Without it the run would silently fall back to whatever config it discovers, which is ` +
953
+ `not what the command line said.`,
954
+ );
955
+ return 2;
956
+ }
957
+ if (a.help || !a.cmd) {
958
+ log(USAGE);
959
+ return a.help ? 0 : 2;
960
+ }
961
+ // `init` asks the CLI's OWN reader what it would lint, so the two sides `doctor` compares are
962
+ // not two implementations of the same question. A second resolver here is the defect the
963
+ // comparison exists to catch.
964
+ if (a.cmd === "init") {
965
+ // Deferred, not implemented: named and refused, rather than read as the directory argument.
966
+ if (a.hooksMode !== null) {
967
+ err(
968
+ `--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
969
+ `(shared, committed) or, with --no-hooks, nowhere.`,
970
+ );
971
+ return 2;
972
+ }
973
+ if (a.format !== null && !isFormat(a.format)) {
974
+ err(
975
+ `--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``,
976
+ );
977
+ return 2;
978
+ }
979
+ return await init(a.paths[0] ?? ".", {
980
+ log,
981
+ err,
982
+ cwd,
983
+ version: ownVersion(),
984
+ yes: a.yes,
985
+ hooks: !a.noHooks,
986
+ paper: a.paper,
987
+ format: isFormat(a.format) ? a.format : null,
988
+ createPaper: (papersRoot, name, format) =>
989
+ createPaperAt(papersRoot, name, format, { log, err, cwd }),
990
+ resolveCliPapers: (root: string): string | null => {
991
+ const read = readConfig(
992
+ { ...a, config: null },
993
+ { log: () => {}, err: () => {}, cwd: root },
994
+ );
995
+ return read.code === undefined
996
+ ? (toPaths(papersDirOf(read.opts))[0] ?? null)
997
+ : null;
998
+ },
999
+ });
1000
+ }
1001
+ // `doctor` reads the config but must NOT die on a broken one — reporting that the config is
1002
+ // broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
1003
+ // what it prints, rather than an early exit that tells the reader nothing about the hooks.
1004
+ if (a.cmd === "doctor") {
1005
+ const read = readConfig(a, { log: () => {}, err: () => {}, cwd });
1006
+ const papers =
1007
+ read.code === undefined
1008
+ ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1009
+ : null;
1010
+ return doctor({
1011
+ log,
1012
+ cwd,
1013
+ projectDir: process.env["CLAUDE_PROJECT_DIR"] ?? cwd,
1014
+ cliPapers: papers,
1015
+ });
1016
+ }
1017
+ const simple = SIMPLE[a.cmd];
1018
+ if (simple) return await simple(a, { log, err, cwd });
1019
+ if (a.cmd === "check")
1020
+ err(
1021
+ `\`check\` is now \`lint\` — running it anyway. Update the call to \`paperlint lint\`.`,
1022
+ );
1023
+ if (a.cmd !== "lint" && a.cmd !== "check") {
1024
+ err(`unknown command \`${a.cmd}\`\n\n${USAGE}`);
1025
+ return 2;
1026
+ }
1027
+
1028
+ const cfg = readConfig(a, { log, err, cwd });
1029
+ if (cfg.code !== undefined) return cfg.code;
1030
+ const { opts, configPath } = cfg;
1031
+
1032
+ // A command-line argument OVERRIDES the config: one paper out of the corpus gets linted without
1033
+ // editing a file.
1034
+ //
1035
+ // 🔴 A path FROM THE CONFIG is resolved relative to the CONFIG'S DIRECTORY, not the current one.
1036
+ // Otherwise walking up is pointless: from `papers/aisec-2026` the file would be found, but
1037
+ // `"papersDir": "papers"` would point at `papers/aisec-2026/papers`, which does not exist — and the
1038
+ // run would fail with "nothing found" where everything is in place. A command-line argument stays
1039
+ // relative to the current directory: it was typed here and now.
1040
+ //
1041
+ // Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
1042
+ // relative argument against the wrong directory.
1043
+ const paths =
1044
+ a.paths.length > 0
1045
+ ? a.paths.map((p) => resolve(cwd, p))
1046
+ : toPaths(papersDirOf(opts)).map((rel) =>
1047
+ resolve(dirname(configPath ?? cwd), rel),
1048
+ );
1049
+ if (paths.length === 0) {
1050
+ err(
1051
+ `nothing to lint: no path was given and no ${CONFIG_NAME} was found.\n` +
1052
+ `Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`,
1053
+ );
1054
+ return 2;
1055
+ }
1056
+
1057
+ // 🔴 STRUCTURE IS CHECKED BEFORE ESLint AND SEPARATELY FROM IT. A rule is invoked for the file
1058
+ // handed to it; a missing file is never handed over, so no rule at all can report the absence —
1059
+ // a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
1060
+ // analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
1061
+ const structure = checkStructure(paths, opts.structure, { cwd });
1062
+
1063
+ let texLanguage: unknown = null;
1064
+ try {
1065
+ // @ts-expect-error — the module is .mjs and has no types; a missing LaTeX parser is a normal
1066
+ // case here, it is caught by the catch below.
1067
+ ({ texLanguage } = await import("../eslint-rules/latex-language.mjs"));
1068
+ } catch {
1069
+ /* without a LaTeX parser we work over markdown */
1070
+ }
1071
+
1072
+ const eslint = new ESLint({
1073
+ cwd: lintRoot(configPath ? dirname(resolve(cwd, configPath)) : cwd, paths),
1074
+ overrideConfigFile: true,
1075
+ overrideConfig: buildConfig(opts, texLanguage) as Linter.Config[],
1076
+ });
1077
+
1078
+ // 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
1079
+ // reached, which is what the very first run over an empty directory showed: instead of a clear
1080
+ // message a stack from the depths of eslint-helpers.js flew out. A failure stays a failure, but
1081
+ // an explicable one.
1082
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
1083
+ let results: any[];
1084
+ try {
1085
+ results = await eslint.lintFiles(paths);
1086
+ } catch (e) {
1087
+ const fail = e as { messageTemplate?: string; message?: string } | null;
1088
+ if (
1089
+ fail?.messageTemplate === "file-not-found" ||
1090
+ /No files matching/i.test(fail?.message ?? "")
1091
+ )
1092
+ results = [];
1093
+ else throw e;
1094
+ }
1095
+
1096
+ // 🔴 THE GUARD AGAINST A GREEN ZERO, the same one as in action.yml and for the same reason:
1097
+ // ESLint exits zero when there are no findings, and "no findings" is byte-for-byte
1098
+ // indistinguishable from "not a single rule got a single file". A rule whose glob did not match
1099
+ // is not invoked — and, not being invoked, it physically cannot report that.
1100
+ if (results.length === 0) {
1101
+ err(
1102
+ `nothing was linted under ${paths.map((x) => relative(cwd, x) || x).join(", ")} — no PIPELINE-STATUS.md, paper.md/tex or reviews/ found there. A clean report over zero files is not a clean report.`,
1103
+ );
1104
+ return 1;
1105
+ }
1106
+
1107
+ return await reportLint(eslint, results, structure, {
1108
+ a,
1109
+ log,
1110
+ err,
1111
+ where: relative(cwd, dirname(resolve(cwd, configPath ?? "."))) || ".",
1112
+ opts,
1113
+ });
1114
+ }
1115
+
1116
+ /**
1117
+ * The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
1118
+ * decide the exit code. Pulled out of `run` so each question has its own function.
1119
+ */
1120
+ async function reportLint(
1121
+ eslint: ESLint,
1122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
1123
+ results: any[],
1124
+ structure: ReturnType<typeof checkStructure>,
1125
+ {
1126
+ a,
1127
+ log,
1128
+ err,
1129
+ where,
1130
+ opts,
1131
+ }: {
1132
+ a: Args;
1133
+ log: typeof console.log;
1134
+ err: typeof console.error;
1135
+ where: string;
1136
+ opts: RppConfig;
1137
+ },
1138
+ ): Promise<number> {
1139
+ const silent = await silentOptionalRules(
1140
+ eslint,
1141
+ results.map((r) => r.filePath),
1142
+ opts,
1143
+ );
1144
+ if (silent.length > 0) {
1145
+ err(
1146
+ `${silent.join(", ")} is turned on in "rules", but no linted paper.tex gets it — check the block's ` +
1147
+ `"files" (relative to ${where}). A rule that never runs reports exactly like a rule that passed.`,
1148
+ );
1149
+ return 1;
1150
+ }
1151
+
1152
+ if (a.json)
1153
+ log(JSON.stringify([...asEslintResults(structure), ...results], null, 1));
1154
+ else {
1155
+ // Missing things are printed FIRST: they explain why the report below may be suspiciously
1156
+ // short. The reverse order would read as "all clean — oh, and also this".
1157
+ if (structure.length > 0) log(formatStructure(structure));
1158
+ const out = await (await eslint.loadFormatter("stylish")).format(results);
1159
+ log(
1160
+ out.trim() ||
1161
+ (structure.length > 0
1162
+ ? ""
1163
+ : `✓ ${results.length} file(s) checked, no findings`),
1164
+ );
1165
+ }
1166
+ if (structure.length > 0 || results.some((r) => r.errorCount > 0)) return 1;
1167
+ // Warnings fail the run only when the threshold is named EXPLICITLY. A negative threshold means
1168
+ // "do not count them at all", and that is the default.
1169
+ if (a.maxWarnings >= 0) {
1170
+ const warnings = results.reduce((n, r) => n + r.warningCount, 0);
1171
+ if (warnings > a.maxWarnings) {
1172
+ err(
1173
+ `${warnings} warning(s) exceed the --max-warnings limit of ${a.maxWarnings}`,
1174
+ );
1175
+ return 1;
1176
+ }
1177
+ }
1178
+ return 0;
1179
+ }
1180
+
1181
+ // 🔴 `isMain`, NOT A STRING COMPARISON. The first version wrote
1182
+ // if (import.meta.url === `file://${process.argv[1]}`)
1183
+ // and the utility, launched via `node_modules/.bin/rpp`, SILENTLY EXITED WITH ZERO: npm puts a
1184
+ // SYMLINK there, `process.argv[1]` stays the symlink's path while `import.meta.url` is the real
1185
+ // path, and the condition is false. That is, the only way a real consumer launches the utility did
1186
+ // not work at all — and it looked like a clean run.
1187
+ // The helper WAS ALREADY in the package, and its docstring describes exactly this failure
1188
+ // verbatim: "turns a CLI into a no-op that exits 0". I wrote by hand what was lying there ready.
1189
+ if (isMain(import.meta.url)) process.exit(await run(process.argv.slice(2)));