paperlint 3.0.0 → 3.1.1

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 (319) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/README.md +179 -180
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. package/src/structure.mutations.mjs +0 -83
@@ -278,7 +278,8 @@ export function surnames(authorField) {
278
278
  .filter((s) => s && s !== "others");
279
279
  }
280
280
 
281
- const truncated = (authorField) => /\band\s+others\b/i.test(authorField || "");
281
+ // Only called for an entry that has an author (checkAuthors filters on it first).
282
+ const truncated = (authorField) => /\band\s+others\b/i.test(authorField);
282
283
 
283
284
  /** Is the entry claiming a PUBLISHED venue (as opposed to a preprint)? */
284
285
  export function claimsPublished(e) {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * verify-cites.mjs as a process — the way the verify-citations skill runs it, which is THROUGH A
3
+ * SYMLINK (`.claude/skills/verify-citations -> …/skills/verify-citations`, made by `paperlint
4
+ * init`). Only `--offline` runs here: the online resolvers are driven in-process with a fake
5
+ * `fetch` (verify-cites.net.test.mjs), because a test must not depend on four live APIs.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { test } from "vitest";
12
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const root = useTempDir("verify-cites-cli-");
16
+ // The skill's own address for the script: a link to the skill directory, as `paperlint init` makes.
17
+ symlinkSync(join(HERE, ".."), join(root, "verify-citations"));
18
+ const LINKED = join(root, "verify-citations", "scripts", "verify-cites.mjs");
19
+
20
+ test("run through the skill's symlink, the CLI runs at all (--help prints the usage)", () => {
21
+ assert.deepEqual(runNode(LINKED, ["--help"]), {
22
+ status: 0,
23
+ stdout: "",
24
+ stderr: "usage: verify-cites.mjs <cites.json | refs.bib | -> [--offline]\n",
25
+ });
26
+ });
27
+
28
+ const SCRIPT = join(HERE, "verify-cites.mjs");
29
+ const CACHE = join(root, "cache.json");
30
+ const env = { VERIFY_CITES_CACHE: CACHE };
31
+ writeTree(root, {
32
+ "cites.json": JSON.stringify([
33
+ { id: "a", doi: "10.1/x", title: "A Title Of Some Length" },
34
+ { title: "no id at all" },
35
+ { id: "h", title: "Hygiene", commit: "abc123" },
36
+ ]),
37
+ "one.json": JSON.stringify({ id: "solo", title: "Just One Citation" }),
38
+ "refs.bib": "@article{k, title={A Bib Title}, doi={10.2/y}, year={2020}}\n",
39
+ "bad.json": "{ not json",
40
+ // Loaded with --import before the script: every registry answers 404, so a DOI the authority
41
+ // does not know is FALSE — the one verdict that fails the run.
42
+ "fetch-404.mjs":
43
+ "globalThis.fetch = async () => new Response(null, { status: 404 });\n",
44
+ // Prints the User-Agent of every request, then answers 404.
45
+ "fetch-ua.mjs":
46
+ "globalThis.fetch = async (url, init) => {\n" +
47
+ " process.stderr.write(`UA ${init.headers['User-Agent']}\\n`);\n" +
48
+ " return new Response(null, { status: 404 });\n};\n",
49
+ "consumer/paperlint.json": JSON.stringify({ contactEmail: "me@example.org" }),
50
+ });
51
+ const offline = (args, opts = {}) =>
52
+ runNode(SCRIPT, [...args, "--offline"], { env, ...opts });
53
+ const parsed = (r) => ({ ...r, stdout: JSON.parse(r.stdout) });
54
+ const SUMMARY_PASS = (n, t, u, flagged) =>
55
+ `\nverify-cites: ${n} citation(s) — ${t} true, 0 false (fabrication), ${u} unresolvable` +
56
+ (flagged ? `, ${flagged} with hygiene flag(s)` : "") +
57
+ "\nPASS — no fabrication (unresolvable is advisory)\n";
58
+
59
+ test("-h is --help", () => {
60
+ assert.equal(
61
+ runNode(SCRIPT, ["-h"]).stderr,
62
+ runNode(SCRIPT, ["--help"]).stderr,
63
+ );
64
+ });
65
+
66
+ test("an input that cannot be read: exit 2, named", () => {
67
+ const r = offline([join(root, "bad.json")]);
68
+ assert.deepEqual(
69
+ { status: r.status, stdout: r.stdout, stderr: r.stderr.split(" — ")[0] },
70
+ { status: 2, stdout: "", stderr: "error: could not read input" },
71
+ );
72
+ });
73
+
74
+ test("offline over a JSON list: a citation with no id is unresolvable by name, a short SHA is flagged", () => {
75
+ const r = parsed(offline([join(root, "cites.json")]));
76
+ assert.deepEqual(
77
+ {
78
+ status: r.status,
79
+ ids: r.stdout.map((x) => [x.id, x.verdict, x.flags?.length ?? 0]),
80
+ stderr: r.stderr,
81
+ },
82
+ {
83
+ status: 0,
84
+ ids: [
85
+ ["a", "unresolvable", 0],
86
+ [null, "unresolvable", 0],
87
+ ["h", "unresolvable", 1],
88
+ ],
89
+ stderr: SUMMARY_PASS(3, 0, 3, 1),
90
+ },
91
+ );
92
+ });
93
+
94
+ test("one JSON object on stdin, with `-` and with no path at all", () => {
95
+ const input = readFileSync(join(root, "one.json"), "utf8");
96
+ const dash = parsed(offline(["-"], { input }));
97
+ const bare = parsed(offline([], { input }));
98
+ assert.deepEqual(
99
+ [dash.stdout.map((x) => x.id), bare.stdout.map((x) => x.id), dash.status],
100
+ [["solo"], ["solo"], 0],
101
+ );
102
+ });
103
+
104
+ test("a .bib file is parsed into citations", () => {
105
+ const r = parsed(offline([join(root, "refs.bib")]));
106
+ assert.deepEqual(
107
+ r.stdout.map((x) => x.id),
108
+ ["k"],
109
+ );
110
+ });
111
+
112
+ test("online, a DOI the authority does not know is FALSE: FAIL, exit 1, and the cache is written", () => {
113
+ rmSync(CACHE, { force: true });
114
+ const r = runNode(SCRIPT, [join(root, "refs.bib")], {
115
+ env,
116
+ nodeArgs: ["--import", join(root, "fetch-404.mjs")],
117
+ });
118
+ assert.deepEqual(
119
+ {
120
+ status: r.status,
121
+ verdicts: JSON.parse(r.stdout).map((x) => x.verdict),
122
+ tail: r.stderr.trim().split("\n").pop(),
123
+ cached: Object.keys(JSON.parse(readFileSync(CACHE, "utf8"))).sort(),
124
+ },
125
+ {
126
+ status: 1,
127
+ verdicts: ["false"],
128
+ tail: "FAIL — fabrication(s) found (exit 1)",
129
+ // 404s are successful transports, so they are cached; the authority's 100 is not.
130
+ cached: [
131
+ "crossref:doi:10.2/y",
132
+ "openalex:doi:10.2/y",
133
+ "semantic_scholar:doi:10.2/y",
134
+ ],
135
+ },
136
+ );
137
+ });
138
+
139
+ test("a corrupt cache is ignored, and a cache that cannot be written does not fail the run", () => {
140
+ writeFileSync(CACHE, "{ corrupt");
141
+ const corrupt = offline([join(root, "one.json")]);
142
+ const unwritable = runNode(SCRIPT, [join(root, "one.json"), "--offline"], {
143
+ env: { VERIFY_CITES_CACHE: root }, // a directory: reading and writing both fail
144
+ });
145
+ assert.deepEqual([corrupt.status, unwritable.status], [0, 0]);
146
+ });
147
+
148
+ test("the consumer's declared contact reaches every request's User-Agent (Crossref's polite pool)", () => {
149
+ const r = runNode(SCRIPT, [join(root, "refs.bib")], {
150
+ env: { ...env, CLAUDE_PROJECT_DIR: join(root, "consumer") },
151
+ nodeArgs: ["--import", join(root, "fetch-ua.mjs")],
152
+ });
153
+ const agents = new Set(
154
+ r.stderr.split("\n").filter((l) => l.startsWith("UA ")),
155
+ );
156
+ assert.deepEqual(
157
+ [...agents],
158
+ ["UA verify-cites/1.0 (citation gate; mailto:me@example.org)"],
159
+ );
160
+ });
@@ -49,10 +49,16 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
49
49
  import { fileURLToPath } from "node:url";
50
50
  import { dirname, join, extname } from "node:path";
51
51
  import { createHash } from "node:crypto";
52
- import { consumerContactEmail } from "../../paper-pipeline/scripts/consumer.mjs";
52
+ import {
53
+ consumerContactEmail,
54
+ isMain,
55
+ } from "../../paper-pipeline/scripts/consumer.mjs";
53
56
 
54
57
  const __dirname = dirname(fileURLToPath(import.meta.url));
55
- const CACHE_PATH = join(__dirname, ".cite-cache.json");
58
+ // Beside this script by default (the skill says so, and `.gitignore` covers it). VERIFY_CITES_CACHE
59
+ // points it elsewhere — how a test keeps the run from writing into the package it is testing.
60
+ const CACHE_PATH =
61
+ process.env.VERIFY_CITES_CACHE || join(__dirname, ".cite-cache.json");
56
62
  // 🔴 THE ADDRESS IS THE CONSUMER'S, NOT OURS. Crossref's "polite pool" keys off this `mailto:`:
57
63
  // it decides who gets the faster tier and, more to the point, WHOM THEY WARN before blocking.
58
64
  // Hard-coded, it pointed at one person for every user of this package — so the warnings would
@@ -122,11 +128,6 @@ export function titleSimilarity(a, b) {
122
128
  return 1 - levenshtein(na, nb) / maxLen;
123
129
  }
124
130
 
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
131
  /** Normalize a DOI: strip a `doi:` / resolver-URL prefix and any TRAILING sentence
131
132
  * punctuation a .bib or prose extractor swallowed. A trailing `.` makes doi.org
132
133
  * return responseCode 100, so an unnormalized real DOI reads as fabrication (the
@@ -201,7 +202,7 @@ function isAcronymOf(base, words) {
201
202
  for (let i = 0; i < words.length; i++) {
202
203
  let acr = "";
203
204
  for (let j = i; j < words.length && acr.length < base.length; j++) {
204
- acr += words[j][0] || "";
205
+ acr += words[j].charAt(0); // "" for an empty token, like the first letter of nothing
205
206
  }
206
207
  if (acr === base) return true;
207
208
  }
@@ -251,8 +252,7 @@ export function titleRelation(a, b) {
251
252
  const sb = scriptsOf(nb);
252
253
  if (sa.size && sb.size && ![...sa].some((x) => sb.has(x)))
253
254
  return "incomparable";
254
- const sim = 1 - levenshtein(na, nb) / Math.max(na.length, nb.length);
255
- if (sim >= TITLE_THRESHOLD) return "match";
255
+ if (titleSimilarity(a, b) >= TITLE_THRESHOLD) return "match";
256
256
  const A = contentTokens(na);
257
257
  const B = contentTokens(nb);
258
258
  if (A.length && B.length) {
@@ -702,32 +702,33 @@ function cacheKey(kind, val) {
702
702
  return `${kind}:${val}`;
703
703
  }
704
704
 
705
+ /** One request, classified: rate-limit/outage and other failures → `ok:false`, 404 → `notFound`. */
706
+ async function request(url, json, signal) {
707
+ const res = await fetch(url, {
708
+ headers: {
709
+ "User-Agent": USER_AGENT,
710
+ Accept: json ? "application/json" : "*/*",
711
+ },
712
+ signal,
713
+ });
714
+ if (res.status === 429 || res.status >= 500) {
715
+ return { ok: false, reason: `http ${res.status}` }; // rate-limit / outage
716
+ }
717
+ if (res.status === 404) return { ok: true, notFound: true };
718
+ if (!res.ok) return { ok: false, reason: `http ${res.status}` };
719
+ const data = json ? await res.json() : await res.text();
720
+ return { ok: true, data };
721
+ }
722
+
705
723
  async function httpGet(url, { json = true } = {}) {
706
724
  const ctrl = new AbortController();
707
725
  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
- }
726
+ const result = await request(url, json, ctrl.signal).catch((e) => ({
727
+ ok: false,
728
+ reason: e.name === "AbortError" ? "timeout" : String(e),
729
+ }));
730
+ clearTimeout(timer);
731
+ return result;
731
732
  }
732
733
 
733
734
  // Each *Resolver returns a normalized response (the shape classifyResolver eats).
@@ -821,44 +822,44 @@ async function openalexResolve(citation) {
821
822
  return null;
822
823
  }
823
824
 
824
- async function semanticScholarResolve(citation) {
825
+ const S2 = "https://api.semanticscholar.org/graph/v1/paper";
826
+
827
+ // By its id when the citation has one, else by the title — the resolvers run only for a citation
828
+ // with at least one of the three, so the title search is given a title.
829
+ const semanticScholarResolve = (citation) =>
830
+ citation.doi || citation.arxiv
831
+ ? semanticScholarById(citation)
832
+ : semanticScholarByTitle(citation.title);
833
+
834
+ async function semanticScholarById(citation) {
825
835
  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;
836
+ const query = citation.doi ? "doi" : "arxiv";
837
+ const id = citation.doi ? `DOI:${citation.doi}` : `arXiv:${citation.arxiv}`;
838
+ const r = await httpGet(`${S2}/${encodeURIComponent(id)}?fields=title,year`);
839
+ if (!r.ok) return { db, transport: "error" };
840
+ if (r.notFound) return { db, transport: "ok", query, record: null };
841
+ const w = r.data;
842
+ return {
843
+ db,
844
+ transport: "ok",
845
+ query,
846
+ record: w?.title != null ? { title: w.title || "", year: w.year } : null,
847
+ };
848
+ }
849
+
850
+ async function semanticScholarByTitle(title) {
851
+ const db = "semantic_scholar";
852
+ const r = await httpGet(
853
+ `${S2}/search?query=${encodeURIComponent(title)}&fields=title,year&limit=5`,
854
+ );
855
+ if (!r.ok) return { db, transport: "error" };
856
+ const items = r.data?.data || [];
857
+ return {
858
+ db,
859
+ transport: "ok",
860
+ query: "title",
861
+ records: items.map((w) => ({ title: w.title || "", year: w.year })),
862
+ };
862
863
  }
863
864
 
864
865
  async function arxivResolve(citation) {
@@ -917,10 +918,9 @@ export function parseArxivFeed(xml) {
917
918
  * means "no metadata here"; doi.org responseCode 100 means "this DOI does not
918
919
  * exist". Returns { transport, responseCode } for classifyDoiAuthority.
919
920
  */
920
- async function doiAuthorityCheck(citation) {
921
- if (!citation.doi) return null;
921
+ async function doiAuthorityCheck(doi) {
922
922
  // Keep the DOI's own slashes as path separators; encode the rest.
923
- const path = encodeURIComponent(citation.doi.trim()).replace(/%2F/gi, "/");
923
+ const path = encodeURIComponent(doi.trim()).replace(/%2F/gi, "/");
924
924
  const r = await httpGet(`https://doi.org/api/handles/${path}`);
925
925
  if (r.notFound) return { transport: "ok", responseCode: 100 }; // 404 = not found
926
926
  if (!r.ok) return { transport: "error" };
@@ -931,15 +931,14 @@ async function doiAuthorityCheck(citation) {
931
931
  };
932
932
  }
933
933
 
934
- async function nvdCheck(citation) {
935
- if (!citation.cve) return null;
936
- if (!isValidCveId(citation.cve)) {
934
+ async function nvdCheck(cve) {
935
+ if (!isValidCveId(cve)) {
937
936
  // Malformed CVE id — treat as a provided-id-that-cannot-resolve (fabrication).
938
937
  return { transport: "ok", found: false };
939
938
  }
940
939
  const r = await httpGet(
941
940
  `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(
942
- citation.cve.toUpperCase(),
941
+ cve.toUpperCase(),
943
942
  )}`,
944
943
  );
945
944
  if (!r.ok) return { transport: "error" };
@@ -951,21 +950,18 @@ async function nvdCheck(citation) {
951
950
  /**
952
951
  * Verify ONE citation live (with caching). Returns the reduceVerdict result.
953
952
  *
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.
953
+ * ⚠️ CONCURRENT CALLS SHARING ONE `cache` MAY ASK TWICE. The cache is read BEFORE the `await` and
954
+ * written AFTER it (three places below), so two concurrent calls for the SAME identifier both miss
955
+ * and both go to the network. The data does not suffer — the resolvers are deterministic and the
956
+ * second write is equivalent — the cost is an extra request per duplicate identifier.
960
957
  *
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).
958
+ * Callers: `main()` here calls it one citation at a time; `paperlint build`'s references step
959
+ * (`src/adapters/references/index.ts`) calls it six at a time (#107 — serially, 27 references took
960
+ * 217 s). Six is the bound on requests in flight to any one service, since a citation's own
961
+ * requests go to the four resolvers one after another. `require-atomic-updates` stays at `warn`
962
+ * because the interleaving it describes now exists, and is accepted for the reason above. In-flight
963
+ * deduplication (caching the promise, not the value) would remove the duplicate request; it does
964
+ * not fit `saveCache()`'s JSON file and has not been done.
969
965
  */
970
966
  export async function verifyCitationLive(
971
967
  citation,
@@ -1015,13 +1011,13 @@ export async function verifyCitationLive(
1015
1011
  const ck = cacheKey("doi_authority", citation.doi);
1016
1012
  let auth = cache[ck];
1017
1013
  if (!auth) {
1018
- auth = await doiAuthorityCheck(citation);
1014
+ auth = await doiAuthorityCheck(citation.doi);
1019
1015
  // Cache only POSITIVE existence; never cache authority-absent (100), so a
1020
1016
  // freshly-minted DOI checked pre-propagation isn't pinned to `false` on re-run.
1021
1017
  if (auth && auth.transport === "ok" && auth.responseCode !== 100)
1022
1018
  cache[ck] = auth;
1023
1019
  }
1024
- if (auth) evidence.push(classifyDoiAuthority(auth));
1020
+ evidence.push(classifyDoiAuthority(auth));
1025
1021
  }
1026
1022
  }
1027
1023
 
@@ -1030,8 +1026,8 @@ export async function verifyCitationLive(
1030
1026
  const ck = cacheKey("nvd", citation.cve.toUpperCase());
1031
1027
  let nvd = cache[ck];
1032
1028
  if (!nvd) {
1033
- nvd = await nvdCheck(citation);
1034
- if (nvd && nvd.transport === "ok") cache[ck] = nvd;
1029
+ nvd = await nvdCheck(citation.cve);
1030
+ if (nvd.transport === "ok") cache[ck] = nvd;
1035
1031
  }
1036
1032
  const e = checkNvd(citation, nvd);
1037
1033
  if (e) evidence.push(e);
@@ -1103,6 +1099,10 @@ async function main() {
1103
1099
  }
1104
1100
 
1105
1101
  // 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]) {
1102
+ // 🔴 `isMain`, not `fileURLToPath(import.meta.url) === process.argv[1]`. The skill runs this file
1103
+ // through `.claude/skills/verify-citations`, a symlink; Node reports `import.meta.url` as the
1104
+ // realpath and `argv[1]` as the link, so the old comparison was false and the CLI exited 0 having
1105
+ // done nothing — a silent PASS for any bibliography. (consumer.mjs `isMain` documents the class.)
1106
+ if (isMain(import.meta.url)) {
1107
1107
  main();
1108
1108
  }