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
@@ -0,0 +1,1108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify-cites.mjs — deterministic, cacheable citation-existence gate.
4
+ *
5
+ * Provenance: design ported (reimplemented fresh — no source copied) from
6
+ * github.com/Imbad0202/academic-research-skills (CC BY-NC): the 4-DB lookup +
7
+ * narrowed-false reducer + DOI-misdirection. CVE / PoC-commit-SHA pinning design
8
+ * from github.com/euzun/security-paper-writing (MIT). Poach backlog for later
9
+ * rounds: papers/research/oss-skill-mining-imbad0202.md.
10
+ *
11
+ * Turns the LLM cite-check (SKILL.md, prose) into a reproducible artifact: for
12
+ * every citation it hits four real academic databases (Crossref, OpenAlex,
13
+ * Semantic Scholar, arXiv), DOI/arXiv-id first then a title search, and reduces
14
+ * the evidence to one of three verdicts:
15
+ *
16
+ * true — found, metadata matches (a resolver confirmed it)
17
+ * false — FABRICATION: a provided DOI/arXiv-id/CVE resolves to nothing,
18
+ * OR a DOI resolves to an UNRELATED real paper (DOI misdirection)
19
+ * unresolvable — could not find it by title, but no positive disproof.
20
+ * Explicitly NOT fabrication (a legit unindexed / regional /
21
+ * pre-digital paper looks exactly like this).
22
+ *
23
+ * The "narrowed-false" rule is the whole point: a title you simply can't find is
24
+ * NOT evidence of fabrication → unresolvable. Only a resolvable *identifier* that
25
+ * provably fails, or a DOI that lands on the wrong paper, is fabrication → false.
26
+ *
27
+ * Design ported (fresh original code, no source copied) from the OSS mining note
28
+ * papers/research/oss-skill-mining-imbad0202.md (STEAL #1), plus the
29
+ * CVE / PoC-SHA pinning discipline from oss-skill-mining-euzun.md (§4a).
30
+ *
31
+ * Architecture: the PURE reducer (reduceVerdict / classifyResolver / checkNvd /
32
+ * checkCommit + the string helpers) is separated from the live HTTP layer, so the
33
+ * verdict logic is unit-testable offline with injected fixtures — see
34
+ * verify-cites.test.mjs. The network layer only shapes raw API responses into the
35
+ * normalized evidence the pure functions consume.
36
+ *
37
+ * Usage:
38
+ * node verify-cites.mjs cites.json # JSON array of citation objects
39
+ * node verify-cites.mjs refs.bib # best-effort .bib extraction
40
+ * cat cites.json | node verify-cites.mjs - # from stdin
41
+ * node verify-cites.mjs cites.json --offline # no network (everything degrades)
42
+ *
43
+ * Citation object: { id, doi?, arxiv?, title?, authors?, year?, cve?, commit? }
44
+ * Output: JSON array of { id, verdict, reason, matched_db, matched_title?, flags? }
45
+ * + a summary line on stderr. Exit 1 iff any verdict is `false`.
46
+ */
47
+
48
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
49
+ import { fileURLToPath } from "node:url";
50
+ import { dirname, join, extname } from "node:path";
51
+ import { createHash } from "node:crypto";
52
+ import { consumerContactEmail } from "../../paper-pipeline/scripts/consumer.mjs";
53
+
54
+ const __dirname = dirname(fileURLToPath(import.meta.url));
55
+ const CACHE_PATH = join(__dirname, ".cite-cache.json");
56
+ // 🔴 THE ADDRESS IS THE CONSUMER'S, NOT OURS. Crossref's "polite pool" keys off this `mailto:`:
57
+ // it decides who gets the faster tier and, more to the point, WHOM THEY WARN before blocking.
58
+ // Hard-coded, it pointed at one person for every user of this package — so the warnings would
59
+ // reach someone who cannot act on them while the actual caller heard nothing. Undeclared, we
60
+ // send no `mailto:` and land in the public pool: slower, never wrong.
61
+ const CONTACT = consumerContactEmail();
62
+ const USER_AGENT = `verify-cites/1.0 (citation gate${CONTACT ? `; mailto:${CONTACT}` : ""})`;
63
+ const TIMEOUT_MS = 15000;
64
+ const TITLE_THRESHOLD = 0.7; // Levenshtein-normalized similarity
65
+ const YEAR_TOLERANCE = 1; // ±1
66
+
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+ // PURE LAYER — no I/O. Everything below is unit-testable with plain fixtures.
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+
71
+ /** Classic Levenshtein edit distance (own implementation, two-row DP). */
72
+ export function levenshtein(a, b) {
73
+ a = a ?? "";
74
+ b = b ?? "";
75
+ if (a === b) return 0;
76
+ if (a.length === 0) return b.length;
77
+ if (b.length === 0) return a.length;
78
+ let prev = new Array(b.length + 1);
79
+ let cur = new Array(b.length + 1);
80
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
81
+ for (let i = 1; i <= a.length; i++) {
82
+ cur[0] = i;
83
+ const ca = a.charCodeAt(i - 1);
84
+ for (let j = 1; j <= b.length; j++) {
85
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
86
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
87
+ }
88
+ [prev, cur] = [cur, prev];
89
+ }
90
+ return prev[b.length];
91
+ }
92
+
93
+ /**
94
+ * Normalize a title for comparison: lowercase, strip diacritics + punctuation,
95
+ * collapse whitespace. PRESERVES unicode letters/numbers (\p{L}\p{N}) so that
96
+ * Cyrillic / CJK / Greek titles keep their content — an ASCII-only normalizer
97
+ * collapses every non-Latin title to "" and then scores two DIFFERENT non-Latin
98
+ * titles as identical (S1: a fabricated non-Latin cite would pass as `true`).
99
+ * A title that has NO letters/digits at all (pure punctuation/emoji) still
100
+ * normalizes to "" — callers must treat "" as INCOMPARABLE, never as a match.
101
+ */
102
+ export function normalizeTitle(t) {
103
+ return (t ?? "")
104
+ .toLowerCase()
105
+ .normalize("NFKD")
106
+ .replace(/\p{M}+/gu, "") // strip combining marks (diacritics)
107
+ .replace(/[^\p{L}\p{N}\s]/gu, " ") // keep unicode letters/numbers, drop punct
108
+ .replace(/\s+/g, " ")
109
+ .trim();
110
+ }
111
+
112
+ /**
113
+ * Normalized similarity in [0,1]: 1 - dist/maxLen over normalized titles.
114
+ * If EITHER side normalizes to "" (incomparable) → 0, NEVER 1 (two empties are
115
+ * not "identical", they are un-judgeable — see normalizeTitle / S1).
116
+ */
117
+ export function titleSimilarity(a, b) {
118
+ const na = normalizeTitle(a);
119
+ const nb = normalizeTitle(b);
120
+ if (!na || !nb) return 0;
121
+ const maxLen = Math.max(na.length, nb.length);
122
+ return 1 - levenshtein(na, nb) / maxLen;
123
+ }
124
+
125
+ /** Two titles "match" iff normalized similarity ≥ threshold (default 0.70). */
126
+ export function titlesMatch(a, b, threshold = TITLE_THRESHOLD) {
127
+ return titleSimilarity(a, b) >= threshold;
128
+ }
129
+
130
+ /** Normalize a DOI: strip a `doi:` / resolver-URL prefix and any TRAILING sentence
131
+ * punctuation a .bib or prose extractor swallowed. A trailing `.` makes doi.org
132
+ * return responseCode 100, so an unnormalized real DOI reads as fabrication (the
133
+ * gate's own parser must never manufacture a false `false`). */
134
+ export function normalizeDoi(doi) {
135
+ if (!doi) return doi;
136
+ return String(doi)
137
+ .trim()
138
+ .replace(/^doi:\s*/i, "")
139
+ .replace(/^https?:\/\/(dx\.)?doi\.org\//i, "")
140
+ .replace(/[.,;:]+$/, "")
141
+ .trim();
142
+ }
143
+
144
+ /** Normalize an arXiv id: strip an `arXiv:` prefix + surrounding punctuation, so a
145
+ * bib-styled `arXiv:1706.03762` reaches the API as a bare id (else the arXiv API
146
+ * returns an entry-less feed → authoritative not-found → a wrong `false`). */
147
+ export function normalizeArxiv(arxiv) {
148
+ if (!arxiv) return arxiv;
149
+ return String(arxiv)
150
+ .trim()
151
+ .replace(/^arxiv:\s*/i, "")
152
+ .replace(/[.,;:]+$/, "")
153
+ .trim();
154
+ }
155
+
156
+ /** Shallow copy of a citation with doi/arxiv identifiers normalized. Applied at the
157
+ * verify boundary so BOTH JSON input and .bib-parsed input are clean before any
158
+ * network / authority lookup. */
159
+ export function normalizeIdentifiers(citation) {
160
+ const c = { ...citation };
161
+ if (c.doi) c.doi = normalizeDoi(c.doi);
162
+ if (c.arxiv) c.arxiv = normalizeArxiv(c.arxiv);
163
+ return c;
164
+ }
165
+
166
+ // Short function words that carry no discriminative signal for token-overlap.
167
+ const STOP_TOKENS = new Set([
168
+ "the",
169
+ "a",
170
+ "an",
171
+ "of",
172
+ "and",
173
+ "for",
174
+ "to",
175
+ "in",
176
+ "on",
177
+ "is",
178
+ "are",
179
+ "with",
180
+ "via",
181
+ "using",
182
+ "from",
183
+ "by",
184
+ "at",
185
+ "as",
186
+ "or",
187
+ "be",
188
+ "we",
189
+ "our",
190
+ ]);
191
+
192
+ /** Content tokens of a normalized title (drop stopwords + 1-char noise). */
193
+ function contentTokens(norm) {
194
+ return norm.split(" ").filter((w) => w.length >= 2 && !STOP_TOKENS.has(w));
195
+ }
196
+
197
+ /** Does `base` spell the initials of some run of consecutive `words`?
198
+ * ("cnn" ↔ ["convolutional","neural","networks"]). Handles acronym ↔ expansion. */
199
+ function isAcronymOf(base, words) {
200
+ if (base.length < 2) return false;
201
+ for (let i = 0; i < words.length; i++) {
202
+ let acr = "";
203
+ for (let j = i; j < words.length && acr.length < base.length; j++) {
204
+ acr += words[j][0] || "";
205
+ }
206
+ if (acr === base) return true;
207
+ }
208
+ return false;
209
+ }
210
+
211
+ /** Count tokens of the SMALLER set present in the LARGER set — directly, as a
212
+ * depluralized form, or as an acronym expansion — for a containment ratio. */
213
+ function tokenOverlap(small, large) {
214
+ const setL = new Set(large);
215
+ let count = 0;
216
+ for (const t of small) {
217
+ const base = t.replace(/s$/, "");
218
+ if (setL.has(t) || setL.has(base) || isAcronymOf(base, large)) count++;
219
+ }
220
+ return count;
221
+ }
222
+
223
+ /**
224
+ * titleRelation — the ROBUST title comparison for identifier verification.
225
+ * Returns 'match' | 'different' | 'incomparable'. Beyond raw Levenshtein it adds
226
+ * token-set containment so a split subtitle, an abbreviation/acronym, or a
227
+ * preprint→published drift does NOT read as an unrelated paper (M2/M3). If either
228
+ * title is empty after normalization (e.g. non-Latin vs Latin) it is INCOMPARABLE
229
+ * — we cannot assert the titles are unrelated, so we never say 'different'.
230
+ */
231
+ /** Which alphabets/scripts appear in a normalized string (digits are neutral). */
232
+ function scriptsOf(s) {
233
+ const set = new Set();
234
+ if (/[a-z]/.test(s)) set.add("latin");
235
+ if (/[Ѐ-ӿ]/.test(s)) set.add("cyrillic");
236
+ if (/[぀-ヿ㐀-鿿가-힯]/.test(s)) set.add("cjk");
237
+ if (/[Ͱ-Ͽ]/.test(s)) set.add("greek");
238
+ if (/[؀-ۿ]/.test(s)) set.add("arabic");
239
+ return set;
240
+ }
241
+
242
+ export function titleRelation(a, b) {
243
+ const na = normalizeTitle(a);
244
+ const nb = normalizeTitle(b);
245
+ if (!na || !nb) return "incomparable";
246
+ // Cross-script titles (e.g. Cyrillic claimed vs Latin record) are NOT char-
247
+ // comparable: a non-Latin work's DOI often records a translated/transliterated
248
+ // title, so a character mismatch is NOT evidence of an unrelated paper (M3). If
249
+ // the two share no script, we cannot assert 'different' → incomparable.
250
+ const sa = scriptsOf(na);
251
+ const sb = scriptsOf(nb);
252
+ if (sa.size && sb.size && ![...sa].some((x) => sb.has(x)))
253
+ return "incomparable";
254
+ const sim = 1 - levenshtein(na, nb) / Math.max(na.length, nb.length);
255
+ if (sim >= TITLE_THRESHOLD) return "match";
256
+ const A = contentTokens(na);
257
+ const B = contentTokens(nb);
258
+ if (A.length && B.length) {
259
+ const [small, large] = A.length <= B.length ? [A, B] : [B, A];
260
+ const contain = tokenOverlap(small, large) / small.length;
261
+ if (contain >= 0.6) return "match"; // subtitle split / abbrev / drift
262
+ }
263
+ return "different";
264
+ }
265
+
266
+ /** Candidate title strings for a resolved record — the plain title and, when the
267
+ * record splits a subtitle into its own field (Crossref), the concatenation. */
268
+ export function candidateTitles(record) {
269
+ const t = (record.title || "").trim();
270
+ const out = [t];
271
+ if (record.subtitle) {
272
+ const sub = String(record.subtitle).trim();
273
+ out.push(`${t} ${sub}`.trim(), `${t}: ${sub}`.trim());
274
+ }
275
+ return out.filter(Boolean);
276
+ }
277
+
278
+ /** Best relation between a claimed title and any candidate rendering of a record.
279
+ * 'match' if any candidate matches; 'incomparable' if EVERY comparison was
280
+ * incomparable (claimed title un-normalizable); otherwise 'different'. */
281
+ export function bestTitleRelation(claimed, candidates) {
282
+ let sawComparable = false;
283
+ for (const cand of candidates) {
284
+ const rel = titleRelation(claimed, cand);
285
+ if (rel === "match") return "match";
286
+ if (rel !== "incomparable") sawComparable = true;
287
+ }
288
+ return sawComparable ? "different" : "incomparable";
289
+ }
290
+
291
+ /** Year match: within ±tolerance. Missing on either side → not disqualifying. */
292
+ export function yearMatch(claimed, found, tolerance = YEAR_TOLERANCE) {
293
+ const c = toYear(claimed);
294
+ const f = toYear(found);
295
+ if (c == null || f == null) return true;
296
+ return Math.abs(c - f) <= tolerance;
297
+ }
298
+
299
+ function toYear(y) {
300
+ if (y == null) return null;
301
+ const n = typeof y === "number" ? y : parseInt(String(y).slice(0, 4), 10);
302
+ return Number.isFinite(n) ? n : null;
303
+ }
304
+
305
+ /**
306
+ * classifyResolver — PURE. Turn one resolver's normalized response into a
307
+ * per-resolver evidence status. This is the heart of the narrowed-false logic.
308
+ *
309
+ * response shape (produced by the network layer OR by a test fixture):
310
+ * {
311
+ * db: 'crossref' | 'openalex' | 'semantic_scholar' | 'arxiv',
312
+ * transport:'ok' | 'error', // 'error' = network/timeout/rate-limit
313
+ * query: 'doi' | 'arxiv' | 'title',
314
+ * record: { title, year } | null, // for id lookups: resolved record or null
315
+ * records: [{ title, year }] | null, // for title searches: hits (may be [])
316
+ * }
317
+ *
318
+ * returns one of:
319
+ * { db, status:'matched', matchedTitle, matchedYear }
320
+ * { db, status:'doi_mismatch', foundTitle } // DOI landed on unrelated paper
321
+ * { db, status:'id_unmatched', query } // provided id resolved to nothing
322
+ * { db, status:'title_miss' } // title search found no ≥0.70 hit
323
+ * { db, status:'unreachable' } // transport failure — NOT disproof
324
+ */
325
+ export function classifyResolver(citation, response) {
326
+ const db = response.db;
327
+ if (response.transport === "error") return { db, status: "unreachable" };
328
+
329
+ if (response.query === "doi" || response.query === "arxiv") {
330
+ // A provided identifier. If it resolves to nothing at THIS resolver → an
331
+ // id-miss. Whether that is fabrication is decided in reduceVerdict: a content
332
+ // registry (Crossref/OpenAlex/S2) is NOT the DOI authority (M1), so its DOI
333
+ // id-miss is `authoritative:false`; only the arXiv API is authoritative for an
334
+ // arXiv id, so an arXiv-db arXiv id-miss is `authoritative:true`.
335
+ if (response.record == null) {
336
+ return {
337
+ db,
338
+ status: "id_unmatched",
339
+ query: response.query,
340
+ authoritative: response.query === "arxiv" ? db === "arxiv" : false,
341
+ };
342
+ }
343
+ // It resolved to a real record. If we have a claimed title, it must not be a
344
+ // CONFIDENTLY-DIFFERENT paper. titleRelation returns 'different' ONLY for two
345
+ // comparable-yet-unrelated titles; a split subtitle / abbreviation / drift →
346
+ // 'match', and a non-Latin-vs-Latin (or otherwise un-normalizable) pair →
347
+ // 'incomparable' → we do NOT assert misdirection (M2/M3).
348
+ if (citation.title) {
349
+ const rel = bestTitleRelation(
350
+ citation.title,
351
+ candidateTitles(response.record),
352
+ );
353
+ if (rel === "different") {
354
+ return {
355
+ db,
356
+ status: "doi_mismatch",
357
+ foundTitle: response.record.title,
358
+ };
359
+ }
360
+ }
361
+ return {
362
+ db,
363
+ status: "matched",
364
+ matchedTitle: response.record.title,
365
+ matchedYear: response.record.year,
366
+ };
367
+ }
368
+
369
+ // Title search: accept iff a hit is a robust title MATCH (not merely ≥0.70 raw
370
+ // Levenshtein) AND year ±1. titleRelation treats an un-normalizable claimed
371
+ // title as 'incomparable' → no match (we can't confirm existence from a title
372
+ // we can't compare), never a false positive.
373
+ const hits = response.records || [];
374
+ for (const rec of hits) {
375
+ if (
376
+ titleRelation(citation.title, rec.title) === "match" &&
377
+ yearMatch(citation.year, rec.year)
378
+ ) {
379
+ return {
380
+ db,
381
+ status: "matched",
382
+ matchedTitle: rec.title,
383
+ matchedYear: rec.year,
384
+ };
385
+ }
386
+ }
387
+ // Nothing found by title. Coverage gap — NOT fabrication.
388
+ return { db, status: "title_miss" };
389
+ }
390
+
391
+ /**
392
+ * classifyDoiAuthority — PURE. The DOI *authority* (doi.org handle system) is the
393
+ * only source that can positively DISPROVE a DOI's existence. A content-registry
394
+ * 404 (Crossref/OpenAlex/S2 having no metadata) is NOT disproof — DataCite/Zenodo
395
+ * DOIs and freshly-minted 2026 DOIs resolve at doi.org while carrying no registry
396
+ * metadata (M1). Maps the doi.org `/api/handles/{doi}` response to evidence:
397
+ * response shape: { transport:'ok'|'error', responseCode?: number }
398
+ * responseCode 1 → the handle exists → authority_present
399
+ * responseCode 100 → the handle does NOT exist → authority_absent (disproof)
400
+ * anything else / transport error → unreachable (no disproof)
401
+ */
402
+ export function classifyDoiAuthority(response) {
403
+ const db = "doi_authority";
404
+ if (!response || response.transport === "error")
405
+ return { db, status: "unreachable" };
406
+ if (response.responseCode === 1) return { db, status: "authority_present" };
407
+ if (response.responseCode === 100) return { db, status: "authority_absent" };
408
+ return { db, status: "unreachable" }; // unknown code — cannot disprove
409
+ }
410
+
411
+ /**
412
+ * checkNvd — PURE. CVE existence via a normalized NVD response, folded into the
413
+ * same evidence shape as a resolver so the reducer treats it uniformly.
414
+ * nvd shape: { transport:'ok'|'error', found: boolean }
415
+ * found === false on a well-formed CVE id = the id resolves to nothing = false.
416
+ */
417
+ export function checkNvd(citation, nvd) {
418
+ const db = "nvd";
419
+ if (!citation.cve) return null;
420
+ if (!nvd || nvd.transport === "error") return { db, status: "unreachable" };
421
+ if (nvd.found) {
422
+ return { db, status: "matched", matchedTitle: citation.cve };
423
+ }
424
+ // NVD authoritatively has no such CVE → id-keyed miss → fabrication evidence.
425
+ return { db, status: "id_unmatched", query: "cve" };
426
+ }
427
+
428
+ /** Well-formed CVE id? CVE-YYYY-NNNN(+). */
429
+ export function isValidCveId(cve) {
430
+ return /^CVE-\d{4}-\d{4,}$/i.test((cve ?? "").trim());
431
+ }
432
+
433
+ /**
434
+ * checkCommit — PURE. PoC / exploit references must pin a FULL 40-char git SHA
435
+ * (branches and tags move; SHAs don't). Returns advisory flags — a short or
436
+ * malformed SHA is a hygiene flag, NOT a fabrication verdict.
437
+ */
438
+ export function checkCommit(citation) {
439
+ const flags = [];
440
+ if (citation.commit == null) return flags;
441
+ const sha = String(citation.commit).trim();
442
+ if (!/^[0-9a-f]+$/i.test(sha)) {
443
+ flags.push(`commit "${sha}" is not a hex git SHA — pin a full 40-char SHA`);
444
+ } else if (sha.length < 40) {
445
+ flags.push(
446
+ `commit "${sha}" is a short SHA (${sha.length}/40) — pin the full 40-char SHA (short hashes collide / are ambiguous)`,
447
+ );
448
+ } else if (sha.length > 40) {
449
+ // A hex string LONGER than 40 chars is not a valid SHA-1 git object id — do
450
+ // not wave it through just because it is hex (S3).
451
+ flags.push(
452
+ `commit "${sha}" is ${sha.length} hex chars (>40) — not a valid git SHA; pin the exact full 40-char SHA`,
453
+ );
454
+ }
455
+ return flags;
456
+ }
457
+
458
+ /**
459
+ * reduceVerdict — PURE. The narrowed-false reducer. Given a citation and the
460
+ * list of per-resolver/NVD evidence (from classifyResolver / checkNvd), plus the
461
+ * commit flags, produce the final verdict.
462
+ *
463
+ * true iff ANY evidence is `matched` (a match always WINS)
464
+ * false iff NOT matched AND ≥1 evidence is `id_unmatched` or `doi_mismatch`
465
+ * (a resolvable id that fails, or a DOI on the wrong paper)
466
+ * unresolvable otherwise (only `title_miss` and/or `unreachable` — no disproof)
467
+ */
468
+ export function reduceVerdict(citation, evidence, commitFlags = []) {
469
+ const ev = evidence.filter(Boolean);
470
+ const flags = [...commitFlags];
471
+ const out = (verdict, reason, matched_db = null, extra = {}) => ({
472
+ id: citation.id,
473
+ verdict,
474
+ reason,
475
+ matched_db,
476
+ ...extra,
477
+ ...(flags.length ? { flags } : {}),
478
+ });
479
+
480
+ // A match ALWAYS wins.
481
+ const matched = ev.find((e) => e.status === "matched");
482
+ if (matched) {
483
+ return out(
484
+ "true",
485
+ `confirmed by ${matched.db}${matched.matchedTitle ? ` — "${matched.matchedTitle}"` : ""}`,
486
+ matched.db,
487
+ { matched_title: matched.matchedTitle },
488
+ );
489
+ }
490
+
491
+ // A DOI/arXiv id that resolves to a CONFIDENTLY-DIFFERENT paper → misdirection.
492
+ const mismatch = ev.find((e) => e.status === "doi_mismatch");
493
+ if (mismatch) {
494
+ return out(
495
+ "false",
496
+ `DOI_MISMATCH: identifier resolves via ${mismatch.db} to an UNRELATED paper "${mismatch.foundTitle}" (claimed "${citation.title}") — DOI misdirection`,
497
+ );
498
+ }
499
+
500
+ // ── Authority-gated fabrication (M1) ──────────────────────────────────────
501
+ // A content-registry 404 is NOT positive disproof of a DOI. Fabrication may be
502
+ // asserted ONLY from an AUTHORITATIVE not-found:
503
+ // • DOI → doi.org handle system says responseCode 100 (authority_absent)
504
+ // • arXiv → the reachable arXiv API itself (not S2) definitively missed it
505
+ // • CVE → NVD authoritatively has no such id
506
+ const authorityAbsent = ev.find((e) => e.status === "authority_absent");
507
+ const authorityPresent = ev.find((e) => e.status === "authority_present");
508
+ const doiIdMiss = ev.find(
509
+ (e) => e.status === "id_unmatched" && e.query === "doi",
510
+ );
511
+ const arxivAuthMiss = ev.find(
512
+ (e) =>
513
+ e.status === "id_unmatched" && e.query === "arxiv" && e.authoritative,
514
+ );
515
+ const arxivAnyMiss = ev.find(
516
+ (e) => e.status === "id_unmatched" && e.query === "arxiv",
517
+ );
518
+ const cveMiss = ev.find(
519
+ (e) => e.status === "id_unmatched" && e.query === "cve",
520
+ );
521
+
522
+ if (authorityAbsent) {
523
+ return out(
524
+ "false",
525
+ `RESOLVED_TO_NOTHING: DOI ${citation.doi} does not resolve at the DOI authority (doi.org handle responseCode 100 = not found) — fabrication evidence`,
526
+ );
527
+ }
528
+ if (arxivAuthMiss) {
529
+ return out(
530
+ "false",
531
+ `RESOLVED_TO_NOTHING: arXiv id ${citation.arxiv} returns a definitive not-found from the reachable arXiv API — fabrication evidence`,
532
+ );
533
+ }
534
+ if (cveMiss) {
535
+ return out(
536
+ "false",
537
+ `RESOLVED_TO_NOTHING: the provided CVE ${citation.cve} resolves to no record (checked ${cveMiss.db}) — fabrication evidence`,
538
+ );
539
+ }
540
+
541
+ // ── No positive disproof → unresolvable, with the most informative reason ──
542
+ const reachable = ev.some((e) => e.status !== "unreachable");
543
+ let reason;
544
+ if (doiIdMiss && authorityPresent) {
545
+ reason = `DOI ${citation.doi} resolves at doi.org but no content registry (Crossref/OpenAlex/Semantic Scholar) has metadata for it — common for DataCite/Zenodo and freshly-minted DOIs; the work EXISTS, its metadata is just unindexed → unresolvable, NOT fabrication`;
546
+ } else if (doiIdMiss) {
547
+ reason = `DOI ${citation.doi} not found in any content registry, and the DOI authority (doi.org) was not reachable to confirm non-existence — cannot assert fabrication → unresolvable, never false`;
548
+ } else if (arxivAnyMiss) {
549
+ reason = `arXiv id ${citation.arxiv} missed only by non-authoritative sources (the reachable arXiv API did not definitively reject it) — cannot assert fabrication → unresolvable`;
550
+ } else if (reachable) {
551
+ reason =
552
+ "not found by title in any database (no resolvable id to disprove) — could be a legit unindexed/regional/pre-digital work, NOT fabrication";
553
+ } else {
554
+ reason =
555
+ "all databases unreachable (network/rate-limit) — degraded to unresolvable, never to false";
556
+ }
557
+ return out("unresolvable", reason);
558
+ }
559
+
560
+ // ─────────────────────────────────────────────────────────────────────────────
561
+ // .bib PARSER — small, best-effort. Extracts @article/@inproceedings/... entries
562
+ // and (loosely) \bibitem blocks. Not a full BibTeX grammar; enough to seed cites.
563
+ // ─────────────────────────────────────────────────────────────────────────────
564
+
565
+ /**
566
+ * field(body, name) — extract a BibTeX field value from an entry body.
567
+ * (a) anchors `name` so `title` does NOT also match `booktitle` (S4a);
568
+ * (b) reads BRACE-delimited values with balanced-brace scanning, so a protected
569
+ * acronym `{Evaluating {LLM}-based ...}` is captured whole, not truncated at
570
+ * the inner `}` (S4b);
571
+ * also handles "quoted" and bare (numeric) values.
572
+ */
573
+ function bibField(body, name) {
574
+ const re = new RegExp(`(?<![a-zA-Z])${name}\\s*=\\s*`, "i");
575
+ const fm = re.exec(body);
576
+ if (!fm) return undefined;
577
+ let i = fm.index + fm[0].length;
578
+ const ch = body[i];
579
+ if (ch === "{") {
580
+ let depth = 0;
581
+ let j = i;
582
+ for (; j < body.length; j++) {
583
+ if (body[j] === "{") depth++;
584
+ else if (body[j] === "}") {
585
+ depth--;
586
+ if (depth === 0) break;
587
+ }
588
+ }
589
+ return body
590
+ .slice(i + 1, j)
591
+ .replace(/\s+/g, " ")
592
+ .trim();
593
+ }
594
+ if (ch === '"') {
595
+ let j = i + 1;
596
+ while (j < body.length && body[j] !== '"') j++;
597
+ return body
598
+ .slice(i + 1, j)
599
+ .replace(/\s+/g, " ")
600
+ .trim();
601
+ }
602
+ // bare value (e.g. year = 2017,) — up to the next comma / newline / close brace
603
+ let j = i;
604
+ while (j < body.length && !/[,\n}]/.test(body[j])) j++;
605
+ const v = body.slice(i, j).replace(/\s+/g, " ").trim();
606
+ return v || undefined;
607
+ }
608
+
609
+ export function parseBib(text) {
610
+ const cites = [];
611
+ // @type{key, — then read the entry body with BALANCED-BRACE scanning so that
612
+ // one-line entries and indented / same-line closing braces (`\n }` or `...}`)
613
+ // are captured, not dropped by a `\n}`-at-column-0 requirement (S4c).
614
+ const headRe = /@(\w+)\s*\{\s*([^,\s}]+)\s*,/g;
615
+ let m;
616
+ while ((m = headRe.exec(text)) !== null) {
617
+ const type = m[1].toLowerCase();
618
+ if (type === "comment" || type === "string" || type === "preamble")
619
+ continue;
620
+ const key = m[2].trim();
621
+ // Balanced scan from just after the key's comma to the entry's closing brace.
622
+ let depth = 1; // the entry's opening `{` was already consumed by headRe
623
+ let j = headRe.lastIndex;
624
+ for (; j < text.length && depth > 0; j++) {
625
+ if (text[j] === "{") depth++;
626
+ else if (text[j] === "}") depth--;
627
+ }
628
+ const body = text.slice(headRe.lastIndex, j - 1);
629
+ headRe.lastIndex = j; // resume scanning AFTER this entry
630
+
631
+ const c = { id: key };
632
+ const doi = bibField(body, "doi");
633
+ const title = bibField(body, "title");
634
+ const year = bibField(body, "year");
635
+ const author = bibField(body, "author");
636
+ const eprint = bibField(body, "eprint");
637
+ const archive =
638
+ bibField(body, "archiveprefix") || bibField(body, "eprinttype");
639
+ if (doi) c.doi = normalizeDoi(doi);
640
+ if (title) c.title = title.replace(/[{}]/g, "");
641
+ if (year) c.year = year;
642
+ if (author) c.authors = author;
643
+ if (eprint && (!archive || /arxiv/i.test(archive)))
644
+ c.arxiv = normalizeArxiv(eprint);
645
+ cites.push(c);
646
+ }
647
+ if (cites.length > 0) return cites;
648
+
649
+ // Fallback: \bibitem — grab arXiv ids / DOIs out of each block, best-effort.
650
+ const itemRe =
651
+ /\\bibitem(?:\[[^\]]*\])?\{([^}]+)\}([\s\S]*?)(?=\\bibitem|\\end\{thebibliography\}|$)/g;
652
+ while ((m = itemRe.exec(text)) !== null) {
653
+ const key = m[1].trim();
654
+ const block = m[2];
655
+ const c = { id: key };
656
+ const doiM = block.match(/10\.\d{4,9}\/[-._;()/:A-Z0-9]+/i);
657
+ const arxivM = block.match(/arXiv:\s*(\d{4}\.\d{4,5})/i);
658
+ const yearM = block.match(/\b(19|20)\d{2}\b/);
659
+ if (doiM) c.doi = normalizeDoi(doiM[0]);
660
+ if (arxivM) c.arxiv = normalizeArxiv(arxivM[1]);
661
+ if (yearM) c.year = yearM[0];
662
+ // crude title: longest quoted or {\em ...} run
663
+ const titleM =
664
+ block.match(/``([^']{6,}?)''/) ||
665
+ block.match(/\\emph\{([^}]{6,}?)\}/) ||
666
+ block.match(/\{\\em\s+([^}]{6,}?)\}/);
667
+ if (titleM) c.title = titleM[1].replace(/\s+/g, " ").trim();
668
+ cites.push(c);
669
+ }
670
+ return cites;
671
+ }
672
+
673
+ // ─────────────────────────────────────────────────────────────────────────────
674
+ // NETWORK LAYER — impure. Fetches the 4 APIs + NVD, shapes raw → normalized
675
+ // evidence, then hands off to the pure functions above. Cached to disk.
676
+ // ─────────────────────────────────────────────────────────────────────────────
677
+
678
+ function loadCache() {
679
+ try {
680
+ if (existsSync(CACHE_PATH))
681
+ return JSON.parse(readFileSync(CACHE_PATH, "utf8"));
682
+ } catch {
683
+ /* corrupt cache → start fresh */
684
+ }
685
+ return {};
686
+ }
687
+ function saveCache(cache) {
688
+ try {
689
+ writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
690
+ } catch {
691
+ /* best-effort; a cache write failure must never fail the run */
692
+ }
693
+ }
694
+
695
+ function titleHash(t) {
696
+ return createHash("sha1")
697
+ .update(normalizeTitle(t))
698
+ .digest("hex")
699
+ .slice(0, 16);
700
+ }
701
+ function cacheKey(kind, val) {
702
+ return `${kind}:${val}`;
703
+ }
704
+
705
+ async function httpGet(url, { json = true } = {}) {
706
+ const ctrl = new AbortController();
707
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
708
+ try {
709
+ const res = await fetch(url, {
710
+ headers: {
711
+ "User-Agent": USER_AGENT,
712
+ Accept: json ? "application/json" : "*/*",
713
+ },
714
+ signal: ctrl.signal,
715
+ });
716
+ if (res.status === 429 || res.status >= 500) {
717
+ return { ok: false, reason: `http ${res.status}` }; // rate-limit / outage
718
+ }
719
+ if (res.status === 404) return { ok: true, notFound: true };
720
+ if (!res.ok) return { ok: false, reason: `http ${res.status}` };
721
+ const data = json ? await res.json() : await res.text();
722
+ return { ok: true, data };
723
+ } catch (e) {
724
+ return {
725
+ ok: false,
726
+ reason: e.name === "AbortError" ? "timeout" : String(e),
727
+ };
728
+ } finally {
729
+ clearTimeout(timer);
730
+ }
731
+ }
732
+
733
+ // Each *Resolver returns a normalized response (the shape classifyResolver eats).
734
+ // A transport failure → { transport:'error' }: degrade to unresolvable, NEVER false.
735
+
736
+ async function crossrefResolve(citation) {
737
+ const db = "crossref";
738
+ if (citation.doi) {
739
+ const r = await httpGet(
740
+ `https://api.crossref.org/works/${encodeURIComponent(citation.doi)}`,
741
+ );
742
+ if (!r.ok) return { db, transport: "error" };
743
+ if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
744
+ const w = r.data?.message;
745
+ return {
746
+ db,
747
+ transport: "ok",
748
+ query: "doi",
749
+ // Crossref splits a subtitle into its own field — carry it so the title
750
+ // comparison can try title AND title+subtitle (M2).
751
+ record: w
752
+ ? {
753
+ title: (w.title || [])[0] || "",
754
+ subtitle: (w.subtitle || [])[0] || "",
755
+ year: crYear(w),
756
+ }
757
+ : null,
758
+ };
759
+ }
760
+ if (citation.title) {
761
+ const url = `https://api.crossref.org/works?query.bibliographic=${encodeURIComponent(
762
+ citation.title,
763
+ )}&rows=5`;
764
+ const r = await httpGet(url);
765
+ if (!r.ok) return { db, transport: "error" };
766
+ const items = r.data?.message?.items || [];
767
+ return {
768
+ db,
769
+ transport: "ok",
770
+ query: "title",
771
+ records: items.map((w) => ({
772
+ title: (w.title || [])[0] || "",
773
+ year: crYear(w),
774
+ })),
775
+ };
776
+ }
777
+ return null;
778
+ }
779
+ function crYear(w) {
780
+ return (
781
+ w?.issued?.["date-parts"]?.[0]?.[0] ??
782
+ w?.published?.["date-parts"]?.[0]?.[0] ??
783
+ null
784
+ );
785
+ }
786
+
787
+ async function openalexResolve(citation) {
788
+ const db = "openalex";
789
+ if (citation.doi) {
790
+ const r = await httpGet(
791
+ `https://api.openalex.org/works/doi:${encodeURIComponent(citation.doi)}`,
792
+ );
793
+ if (!r.ok) return { db, transport: "error" };
794
+ if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
795
+ const w = r.data;
796
+ return {
797
+ db,
798
+ transport: "ok",
799
+ query: "doi",
800
+ record: w?.id
801
+ ? { title: w.display_name || "", year: w.publication_year }
802
+ : null,
803
+ };
804
+ }
805
+ if (citation.title) {
806
+ const r = await httpGet(
807
+ `https://api.openalex.org/works?search=${encodeURIComponent(citation.title)}&per-page=5`,
808
+ );
809
+ if (!r.ok) return { db, transport: "error" };
810
+ const items = r.data?.results || [];
811
+ return {
812
+ db,
813
+ transport: "ok",
814
+ query: "title",
815
+ records: items.map((w) => ({
816
+ title: w.display_name || "",
817
+ year: w.publication_year,
818
+ })),
819
+ };
820
+ }
821
+ return null;
822
+ }
823
+
824
+ async function semanticScholarResolve(citation) {
825
+ const db = "semantic_scholar";
826
+ const base = "https://api.semanticscholar.org/graph/v1/paper";
827
+ if (citation.doi || citation.arxiv) {
828
+ const id = citation.doi ? `DOI:${citation.doi}` : `arXiv:${citation.arxiv}`;
829
+ const r = await httpGet(
830
+ `${base}/${encodeURIComponent(id)}?fields=title,year`,
831
+ );
832
+ if (!r.ok) return { db, transport: "error" };
833
+ if (r.notFound)
834
+ return {
835
+ db,
836
+ transport: "ok",
837
+ query: citation.doi ? "doi" : "arxiv",
838
+ record: null,
839
+ };
840
+ const w = r.data;
841
+ return {
842
+ db,
843
+ transport: "ok",
844
+ query: citation.doi ? "doi" : "arxiv",
845
+ record: w?.title != null ? { title: w.title || "", year: w.year } : null,
846
+ };
847
+ }
848
+ if (citation.title) {
849
+ const r = await httpGet(
850
+ `${base}/search?query=${encodeURIComponent(citation.title)}&fields=title,year&limit=5`,
851
+ );
852
+ if (!r.ok) return { db, transport: "error" };
853
+ const items = r.data?.data || [];
854
+ return {
855
+ db,
856
+ transport: "ok",
857
+ query: "title",
858
+ records: items.map((w) => ({ title: w.title || "", year: w.year })),
859
+ };
860
+ }
861
+ return null;
862
+ }
863
+
864
+ async function arxivResolve(citation) {
865
+ const db = "arxiv";
866
+ // arXiv only makes sense for an arXiv id or a title search (no DOI endpoint).
867
+ if (citation.arxiv) {
868
+ const r = await httpGet(
869
+ `https://export.arxiv.org/api/query?id_list=${encodeURIComponent(citation.arxiv)}&max_results=1`,
870
+ { json: false },
871
+ );
872
+ if (!r.ok) return { db, transport: "error" };
873
+ const entry = parseArxivFeed(r.data);
874
+ return { db, transport: "ok", query: "arxiv", record: entry[0] ?? null };
875
+ }
876
+ if (citation.title && !citation.doi) {
877
+ const r = await httpGet(
878
+ `https://export.arxiv.org/api/query?search_query=ti:${encodeURIComponent(
879
+ '"' + citation.title + '"',
880
+ )}&max_results=5`,
881
+ { json: false },
882
+ );
883
+ if (!r.ok) return { db, transport: "error" };
884
+ return {
885
+ db,
886
+ transport: "ok",
887
+ query: "title",
888
+ records: parseArxivFeed(r.data),
889
+ };
890
+ }
891
+ return null;
892
+ }
893
+ export function parseArxivFeed(xml) {
894
+ const out = [];
895
+ if (!xml) return out;
896
+ const entries = xml.split(/<entry>/).slice(1);
897
+ for (const e of entries) {
898
+ const tM = e.match(/<title>([\s\S]*?)<\/title>/);
899
+ const pM = e.match(/<published>(\d{4})/);
900
+ const idM = e.match(/<id>([\s\S]*?)<\/id>/);
901
+ if (!tM) continue;
902
+ const title = tM[1].replace(/\s+/g, " ").trim();
903
+ // S2: arXiv answers a bad/unknown id with HTTP 200 and a SENTINEL entry titled
904
+ // "Error" whose <id> is the arxiv.org/api/errors URL. That is NOT a real
905
+ // "unrelated paper" — skip it so the id reads as not-found, not a mismatch.
906
+ const isErrorSentinel =
907
+ title === "Error" && /arxiv\.org\/api\/errors/i.test(idM ? idM[1] : "");
908
+ if (isErrorSentinel) continue;
909
+ out.push({ title, year: pM ? +pM[1] : null });
910
+ }
911
+ return out;
912
+ }
913
+
914
+ /**
915
+ * doiAuthorityCheck — hit the DOI *authority* (doi.org handle system). This is the
916
+ * only source that can positively disprove a DOI (M1). A content-registry 404 just
917
+ * means "no metadata here"; doi.org responseCode 100 means "this DOI does not
918
+ * exist". Returns { transport, responseCode } for classifyDoiAuthority.
919
+ */
920
+ async function doiAuthorityCheck(citation) {
921
+ if (!citation.doi) return null;
922
+ // Keep the DOI's own slashes as path separators; encode the rest.
923
+ const path = encodeURIComponent(citation.doi.trim()).replace(/%2F/gi, "/");
924
+ const r = await httpGet(`https://doi.org/api/handles/${path}`);
925
+ if (r.notFound) return { transport: "ok", responseCode: 100 }; // 404 = not found
926
+ if (!r.ok) return { transport: "error" };
927
+ const code = r.data?.responseCode;
928
+ return {
929
+ transport: "ok",
930
+ responseCode: typeof code === "number" ? code : undefined,
931
+ };
932
+ }
933
+
934
+ async function nvdCheck(citation) {
935
+ if (!citation.cve) return null;
936
+ if (!isValidCveId(citation.cve)) {
937
+ // Malformed CVE id — treat as a provided-id-that-cannot-resolve (fabrication).
938
+ return { transport: "ok", found: false };
939
+ }
940
+ const r = await httpGet(
941
+ `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(
942
+ citation.cve.toUpperCase(),
943
+ )}`,
944
+ );
945
+ if (!r.ok) return { transport: "error" };
946
+ if (r.notFound) return { transport: "ok", found: false };
947
+ const total = r.data?.totalResults ?? (r.data?.vulnerabilities || []).length;
948
+ return { transport: "ok", found: total > 0 };
949
+ }
950
+
951
+ /**
952
+ * Verify ONE citation live (with caching). Returns the reduceVerdict result.
953
+ *
954
+ * 🔴 CONTRACT: CALL SEQUENTIALLY. The shared `cache` is read BEFORE the `await` and written AFTER
955
+ * it (three places below), so two concurrent calls sharing the same `cache` will both miss the
956
+ * write and both fall through to the network. This doesn't corrupt the data — the resolvers are
957
+ * deterministic, and the second call writes an equivalent value — but it's extra requests to
958
+ * CrossRef / OpenAlex / Semantic Scholar / arXiv / NVD, i.e. a direct route to a 429, which is
959
+ * exactly why the neighboring `bib-authors.mjs` has 900ms pauses.
960
+ *
961
+ * Today the contract IS HONORED: the only caller is `main()` in this same file (a sequential
962
+ * `for … of` with `await`), so the interleaving `require-atomic-updates` warns about does not
963
+ * exist. The rule is deliberately left at `warn` for that reason: it judges the SHAPE, not the
964
+ * fact of it. But the function IS EXPORTED and takes a shared cache — exactly what gets
965
+ * parallelized via `Promise.all` — so the warning is left visible rather than suppressed. The real
966
+ * fix would be caching UNRESOLVED promises in the cache (in-flight deduplication) rather than
967
+ * values; that's incompatible with the current JSON-based `saveCache()` and so hasn't been done
968
+ * (measured 2026-08-28).
969
+ */
970
+ export async function verifyCitationLive(
971
+ citation,
972
+ { cache = {}, offline = false } = {},
973
+ ) {
974
+ citation = normalizeIdentifiers(citation); // clean doi:/arXiv: prefixes + trailing punct first
975
+ const commitFlags = checkCommit(citation);
976
+
977
+ if (offline) {
978
+ // No network: everything degrades to unresolvable (matches narrowed-false).
979
+ return reduceVerdict(citation, [], commitFlags);
980
+ }
981
+
982
+ const evidence = [];
983
+
984
+ // Paper resolvers (skip when the citation is CVE/commit-only).
985
+ if (citation.doi || citation.arxiv || citation.title) {
986
+ const resolvers = [
987
+ { name: "crossref", fn: crossrefResolve },
988
+ { name: "openalex", fn: openalexResolve },
989
+ { name: "semantic_scholar", fn: semanticScholarResolve },
990
+ { name: "arxiv", fn: arxivResolve },
991
+ ];
992
+ for (const { name, fn } of resolvers) {
993
+ const ck = cacheKey(
994
+ name,
995
+ citation.doi
996
+ ? `doi:${citation.doi}`
997
+ : citation.arxiv
998
+ ? `arxiv:${citation.arxiv}`
999
+ : `title:${titleHash(citation.title)}`,
1000
+ );
1001
+ let resp;
1002
+ if (cache[ck]) {
1003
+ resp = cache[ck];
1004
+ } else {
1005
+ resp = await fn(citation);
1006
+ if (resp && resp.transport === "ok") cache[ck] = resp; // only cache successes
1007
+ }
1008
+ if (resp) evidence.push(classifyResolver(citation, resp));
1009
+ }
1010
+
1011
+ // DOI authority (doi.org) — the ONLY source that can disprove a DOI (M1).
1012
+ // Always consulted when a DOI is present so a registry-404 never becomes
1013
+ // `false` without doi.org confirming responseCode 100.
1014
+ if (citation.doi) {
1015
+ const ck = cacheKey("doi_authority", citation.doi);
1016
+ let auth = cache[ck];
1017
+ if (!auth) {
1018
+ auth = await doiAuthorityCheck(citation);
1019
+ // Cache only POSITIVE existence; never cache authority-absent (100), so a
1020
+ // freshly-minted DOI checked pre-propagation isn't pinned to `false` on re-run.
1021
+ if (auth && auth.transport === "ok" && auth.responseCode !== 100)
1022
+ cache[ck] = auth;
1023
+ }
1024
+ if (auth) evidence.push(classifyDoiAuthority(auth));
1025
+ }
1026
+ }
1027
+
1028
+ // CVE via NVD.
1029
+ if (citation.cve) {
1030
+ const ck = cacheKey("nvd", citation.cve.toUpperCase());
1031
+ let nvd = cache[ck];
1032
+ if (!nvd) {
1033
+ nvd = await nvdCheck(citation);
1034
+ if (nvd && nvd.transport === "ok") cache[ck] = nvd;
1035
+ }
1036
+ const e = checkNvd(citation, nvd);
1037
+ if (e) evidence.push(e);
1038
+ }
1039
+
1040
+ return reduceVerdict(citation, evidence, commitFlags);
1041
+ }
1042
+
1043
+ // ─────────────────────────────────────────────────────────────────────────────
1044
+ // CLI
1045
+ // ─────────────────────────────────────────────────────────────────────────────
1046
+
1047
+ function loadInput(argv) {
1048
+ const path = argv.find((a) => !a.startsWith("-"));
1049
+ const raw =
1050
+ path && path !== "-" ? readFileSync(path, "utf8") : readFileSync(0, "utf8"); // stdin
1051
+ if (path && extname(path).toLowerCase() === ".bib") return parseBib(raw);
1052
+ const parsed = JSON.parse(raw);
1053
+ return Array.isArray(parsed) ? parsed : [parsed];
1054
+ }
1055
+
1056
+ async function main() {
1057
+ const argv = process.argv.slice(2);
1058
+ if (argv.includes("--help") || argv.includes("-h")) {
1059
+ process.stderr.write(
1060
+ "usage: verify-cites.mjs <cites.json | refs.bib | -> [--offline]\n",
1061
+ );
1062
+ process.exit(0);
1063
+ }
1064
+ const offline = argv.includes("--offline");
1065
+
1066
+ let cites;
1067
+ try {
1068
+ cites = loadInput(argv);
1069
+ } catch (e) {
1070
+ process.stderr.write(`error: could not read input — ${e.message}\n`);
1071
+ process.exit(2);
1072
+ }
1073
+
1074
+ const cache = loadCache();
1075
+ const results = [];
1076
+ for (const c of cites) {
1077
+ if (!c || !c.id) {
1078
+ results.push({
1079
+ id: c?.id ?? null,
1080
+ verdict: "unresolvable",
1081
+ reason: "citation has no id",
1082
+ matched_db: null,
1083
+ });
1084
+ continue;
1085
+ }
1086
+ results.push(await verifyCitationLive(c, { cache, offline }));
1087
+ }
1088
+ saveCache(cache);
1089
+
1090
+ process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1091
+
1092
+ const n = results.length;
1093
+ const f = results.filter((r) => r.verdict === "false").length;
1094
+ const u = results.filter((r) => r.verdict === "unresolvable").length;
1095
+ const t = results.filter((r) => r.verdict === "true").length;
1096
+ const flagged = results.filter((r) => r.flags && r.flags.length).length;
1097
+ process.stderr.write(
1098
+ `\nverify-cites: ${n} citation(s) — ${t} true, ${f} false (fabrication), ${u} unresolvable` +
1099
+ (flagged ? `, ${flagged} with hygiene flag(s)` : "") +
1100
+ `\n${f > 0 ? "FAIL — fabrication(s) found (exit 1)" : "PASS — no fabrication (unresolvable is advisory)"}\n`,
1101
+ );
1102
+ process.exit(f > 0 ? 1 : 0);
1103
+ }
1104
+
1105
+ // Run as CLI only when invoked directly (not when imported by the test).
1106
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1107
+ main();
1108
+ }