paperlint 3.1.0 → 3.1.2

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 (311) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +21 -105
  4. package/CONTRIBUTING.md +23 -25
  5. package/README.md +11 -7
  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/clock.io.d.ts +5 -0
  16. package/dist/adapters/references/clock.io.d.ts.map +1 -0
  17. package/dist/adapters/references/clock.io.js +7 -0
  18. package/dist/adapters/references/clock.io.js.map +1 -0
  19. package/dist/adapters/references/index.d.ts +24 -2
  20. package/dist/adapters/references/index.d.ts.map +1 -1
  21. package/dist/adapters/references/index.js +131 -12
  22. package/dist/adapters/references/index.js.map +1 -1
  23. package/dist/adapters/references/pool.d.ts +8 -0
  24. package/dist/adapters/references/pool.d.ts.map +1 -0
  25. package/dist/adapters/references/pool.js +19 -0
  26. package/dist/adapters/references/pool.js.map +1 -0
  27. package/dist/build.d.ts +14 -2
  28. package/dist/build.d.ts.map +1 -1
  29. package/dist/build.js +17 -11
  30. package/dist/build.js.map +1 -1
  31. package/dist/cli.d.ts +27 -1
  32. package/dist/cli.d.ts.map +1 -1
  33. package/dist/cli.js +71 -55
  34. package/dist/cli.js.map +1 -1
  35. package/dist/doctor.d.ts +27 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +4 -3
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/domain/lookup-cache.d.ts +75 -0
  40. package/dist/domain/lookup-cache.d.ts.map +1 -0
  41. package/dist/domain/lookup-cache.js +137 -0
  42. package/dist/domain/lookup-cache.js.map +1 -0
  43. package/dist/domain/result.d.ts +0 -2
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +0 -4
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/hooks-settings.d.ts +8 -5
  48. package/dist/hooks-settings.d.ts.map +1 -1
  49. package/dist/hooks-settings.js +32 -32
  50. package/dist/hooks-settings.js.map +1 -1
  51. package/dist/init.d.ts +8 -0
  52. package/dist/init.d.ts.map +1 -1
  53. package/dist/init.js +3 -3
  54. package/dist/init.js.map +1 -1
  55. package/dist/latex-log.d.ts +0 -2
  56. package/dist/latex-log.d.ts.map +1 -1
  57. package/dist/latex-log.js +7 -13
  58. package/dist/latex-log.js.map +1 -1
  59. package/dist/link-skills.d.ts +15 -2
  60. package/dist/link-skills.d.ts.map +1 -1
  61. package/dist/link-skills.js +36 -20
  62. package/dist/link-skills.js.map +1 -1
  63. package/dist/paper-files.d.ts.map +1 -1
  64. package/dist/paper-files.js +6 -4
  65. package/dist/paper-files.js.map +1 -1
  66. package/dist/pdf-facts.d.ts +11 -0
  67. package/dist/pdf-facts.d.ts.map +1 -1
  68. package/dist/pdf-facts.js +16 -18
  69. package/dist/pdf-facts.js.map +1 -1
  70. package/dist/pdf-geometry.js +1 -1
  71. package/dist/pdf-geometry.js.map +1 -1
  72. package/dist/ports/check-references.d.ts +12 -2
  73. package/dist/ports/check-references.d.ts.map +1 -1
  74. package/dist/ports/check-references.js +0 -5
  75. package/dist/ports/check-references.js.map +1 -1
  76. package/dist/presets.d.ts +2 -0
  77. package/dist/presets.d.ts.map +1 -1
  78. package/dist/presets.js +3 -1
  79. package/dist/presets.js.map +1 -1
  80. package/dist/references.d.ts +11 -3
  81. package/dist/references.d.ts.map +1 -1
  82. package/dist/references.js +63 -15
  83. package/dist/references.js.map +1 -1
  84. package/dist/tex-requirements.d.ts +8 -0
  85. package/dist/tex-requirements.d.ts.map +1 -1
  86. package/dist/tex-requirements.js +6 -5
  87. package/dist/tex-requirements.js.map +1 -1
  88. package/dist/toolchain.d.ts +3 -2
  89. package/dist/toolchain.d.ts.map +1 -1
  90. package/dist/toolchain.js +4 -5
  91. package/dist/toolchain.js.map +1 -1
  92. package/dist/venue-rules.d.ts.map +1 -1
  93. package/dist/venue-rules.js +11 -9
  94. package/dist/venue-rules.js.map +1 -1
  95. package/docs/prior-art/README.md +11 -10
  96. package/docs/prior-art/citation-checkers.md +174 -0
  97. package/docs/prior-art/test-tooling.md +7 -30
  98. package/docs/references.md +126 -0
  99. package/docs/testing.md +118 -0
  100. package/docs/toolchain.md +6 -4
  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 +9 -2
  109. package/eslint-rules/paper-typography.test.mjs +146 -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.test.mjs +37 -1
  113. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  114. package/eslint-rules/tex-build.mjs +13 -13
  115. package/eslint.config.mjs +6 -2
  116. package/hooks/hooks.harness.mjs +6 -11
  117. package/hooks/paper-edit-guard.hook.mjs +2 -2
  118. package/hooks/paper-edit-guard.test.mjs +61 -0
  119. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  120. package/hooks/paper-skills-nudge.test.mjs +38 -0
  121. package/hooks/paper-status-gates.test.mjs +42 -0
  122. package/lib/agent-cli-version.harness.mjs +7 -0
  123. package/lib/check.d.mts +17 -0
  124. package/lib/check.mjs +50 -0
  125. package/lib/check.test.ts +67 -0
  126. package/lib/markdown.mjs +16 -69
  127. package/lib/markdown.test.mjs +246 -0
  128. package/lib/paper-config.harness.mjs +28 -9
  129. package/lib/paper-config.mjs +7 -2
  130. package/lib/skill-checks.mjs +18 -46
  131. package/lib/skill-checks.test.mjs +297 -0
  132. package/lib/skill-corpus.mjs +11 -13
  133. package/lib/skill-corpus.test.mjs +54 -0
  134. package/lib/skill-trigger-cases.harness.mjs +57 -4
  135. package/package.json +6 -5
  136. package/scripts/check.harness.mjs +4 -10
  137. package/scripts/check.mjs +45 -42
  138. package/scripts/check.test.mjs +104 -0
  139. package/scripts/coverage-config.test.ts +406 -0
  140. package/scripts/eslint-report-guard.test.mjs +48 -0
  141. package/scripts/harness-api.frozen.json +0 -2
  142. package/scripts/layer-legacy-frozen.d.mts +9 -1
  143. package/scripts/layer-legacy-frozen.mjs +28 -11
  144. package/scripts/layer-legacy-frozen.test.ts +143 -1
  145. package/scripts/rules-are-content-only.harness.mjs +3 -11
  146. package/scripts/rules-are-content-only.mjs +29 -19
  147. package/scripts/rules-are-content-only.test.mjs +61 -0
  148. package/scripts/rules-see-files.mjs +11 -9
  149. package/scripts/rules-see-files.test.mjs +71 -0
  150. package/scripts/test.mjs +44 -0
  151. package/scripts/test.test.mjs +52 -0
  152. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  153. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  154. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  155. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  156. package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
  157. package/skills/paper-pipeline/scripts/README.md +13 -132
  158. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  159. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  160. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  161. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  162. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  163. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  164. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  165. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  166. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  167. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  168. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  169. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  170. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  171. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  172. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  173. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  174. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  175. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  176. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  177. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  178. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  179. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  180. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  181. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  182. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  183. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  184. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  185. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  186. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  187. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  188. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  189. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  190. package/skills/render-paper/checkers-requirements.txt +30 -19
  191. package/skills/render-paper/ensure-checkers.sh +20 -4
  192. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  193. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  194. package/skills/tighten-paper/structure.test.mjs +116 -0
  195. package/skills/verify-citations/SKILL.md +30 -14
  196. package/skills/verify-citations/SKILL.md.spec.ts +29 -13
  197. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
  198. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  199. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  200. package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
  201. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  202. package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
  203. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
  204. package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
  205. package/src/adapters/banal/failure.test.ts +70 -1
  206. package/src/adapters/banal/index.test.ts +16 -0
  207. package/src/adapters/banal/output.test.ts +5 -0
  208. package/src/adapters/curl/download.test.ts +43 -1
  209. package/src/adapters/memory/index.test.ts +25 -0
  210. package/src/adapters/node/files.io.ts +3 -2
  211. package/src/adapters/node/files.test.ts +13 -1
  212. package/src/adapters/node/process.test.ts +38 -0
  213. package/src/adapters/pdfjs/fill.test.ts +34 -0
  214. package/src/adapters/pdfjs/fill.ts +44 -25
  215. package/src/adapters/references/clock.io.ts +8 -0
  216. package/src/adapters/references/index.test.ts +560 -0
  217. package/src/adapters/references/index.ts +203 -19
  218. package/src/adapters/references/pool.test.ts +31 -0
  219. package/src/adapters/references/pool.ts +24 -0
  220. package/src/build-engine.harness.mjs +3 -7
  221. package/src/build-engine.test.ts +137 -0
  222. package/src/build.harness.mjs +5 -9
  223. package/src/build.test.ts +362 -0
  224. package/src/build.ts +23 -12
  225. package/src/cli.harness.mjs +10 -17
  226. package/src/cli.test.ts +584 -0
  227. package/src/cli.ts +112 -66
  228. package/src/doctor.harness.mjs +3 -8
  229. package/src/doctor.test.ts +64 -0
  230. package/src/doctor.ts +12 -4
  231. package/src/domain/lookup-cache.test.ts +178 -0
  232. package/src/domain/lookup-cache.ts +202 -0
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +27 -7
  236. package/src/facts-file.test.ts +110 -0
  237. package/src/hooks-settings.harness.mjs +43 -13
  238. package/src/hooks-settings.ts +41 -35
  239. package/src/init.test.ts +308 -5
  240. package/src/init.ts +3 -3
  241. package/src/latex-log.harness.mjs +32 -7
  242. package/src/latex-log.ts +6 -13
  243. package/src/latex-loop.harness.mjs +8 -7
  244. package/src/link-skills.harness.mjs +3 -8
  245. package/src/link-skills.test.ts +105 -0
  246. package/src/link-skills.ts +75 -21
  247. package/src/new-paper.harness.mjs +3 -7
  248. package/src/new-paper.test.ts +31 -1
  249. package/src/paper-files.test.ts +12 -0
  250. package/src/paper-files.ts +9 -5
  251. package/src/paper-settings.test.ts +20 -0
  252. package/src/pdf-facts.harness.mjs +3 -6
  253. package/src/pdf-facts.test.ts +144 -0
  254. package/src/pdf-facts.ts +17 -19
  255. package/src/pdf-geometry.harness.mjs +3 -7
  256. package/src/pdf-geometry.ts +1 -1
  257. package/src/ports/check-references.ts +16 -2
  258. package/src/presets.test.ts +2 -0
  259. package/src/presets.ts +7 -1
  260. package/src/references.test.ts +257 -10
  261. package/src/references.ts +93 -17
  262. package/src/rules-config.harness.mjs +10 -8
  263. package/src/structure.harness.mjs +3 -7
  264. package/src/tex-requirements.harness.mjs +34 -7
  265. package/src/tex-requirements.ts +12 -6
  266. package/src/toolchain.harness.mjs +3 -7
  267. package/src/toolchain.test.ts +280 -0
  268. package/src/toolchain.ts +8 -7
  269. package/src/venue-rules.test.ts +60 -0
  270. package/src/venue-rules.ts +13 -11
  271. package/action.mutations.mjs +0 -162
  272. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  273. package/eslint-rules/latex-language.mutations.mjs +0 -196
  274. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  275. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  276. package/eslint-rules/papers.mutations.mjs +0 -186
  277. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  278. package/eslint-rules/tex-build.mutations.mjs +0 -258
  279. package/hooks/hooks.mutations.mjs +0 -337
  280. package/lib/agent-cli-version.mutations.mjs +0 -109
  281. package/lib/mutation-driver.harness.mjs +0 -227
  282. package/lib/mutation-driver.mjs +0 -397
  283. package/lib/mutation-driver.mutations.mjs +0 -68
  284. package/lib/paper-config.mutations.mjs +0 -143
  285. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  286. package/scripts/check.mutations.mjs +0 -110
  287. package/scripts/exclusive.mjs +0 -138
  288. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  289. package/scripts/mutation-batteries-frozen.mjs +0 -238
  290. package/scripts/mutation-batteries.frozen.json +0 -108
  291. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  292. package/scripts/rules-see-files.mutations.mjs +0 -131
  293. package/scripts/run-mutations.mjs +0 -100
  294. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  295. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  296. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  297. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  298. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  299. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  300. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  301. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  302. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  303. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  304. package/skills/skill-contract.mutations.mjs +0 -179
  305. package/src/build.mutations.mjs +0 -196
  306. package/src/cli.mutations.mjs +0 -414
  307. package/src/doctor.mutations.mjs +0 -175
  308. package/src/hooks-settings.mutations.mjs +0 -99
  309. package/src/link-skills.mutations.mjs +0 -140
  310. package/src/new-paper.mutations.mjs +0 -65
  311. package/src/structure.mutations.mjs +0 -83
@@ -0,0 +1,274 @@
1
+ /**
2
+ * bib-authors.mjs's comparison (`checkAuthors`) and its reading of DBLP's answer (`dblpHits`).
3
+ *
4
+ * `checkAuthors` takes its lookup and its pause as parameters for exactly this: the buckets it
5
+ * sorts entries into — finding, matched, not applicable, NOT CHECKED — are our logic, and each is
6
+ * driven here with a lookup that answers on purpose. `dblpHits` is our parser of DBLP's search
7
+ * JSON; it is fed that documented shape (`result.hits.hit[].info`), which pins how WE read it,
8
+ * not how DBLP behaves — the colocated bib-authors.test.mjs keeps the live service out of tests.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { afterEach, test, vi } from "vitest";
12
+ import {
13
+ checkAuthors,
14
+ claimsPublished,
15
+ dblpHits,
16
+ parseBib,
17
+ surnames,
18
+ } from "./bib-authors.mjs";
19
+
20
+ afterEach(() => vi.unstubAllGlobals());
21
+
22
+ const entry = (key, author, title, booktitle = "Proc. X") => ({
23
+ type: "inproceedings",
24
+ key,
25
+ author,
26
+ title,
27
+ booktitle,
28
+ journal: "",
29
+ });
30
+ const hit = (
31
+ title,
32
+ authors,
33
+ venue = "X",
34
+ type = "Conference and Workshop Papers",
35
+ ) => ({
36
+ title,
37
+ authors,
38
+ venue,
39
+ year: "2024",
40
+ type,
41
+ });
42
+
43
+ test("checkAuthors sorts every kind of entry into its bucket", async () => {
44
+ const parsed = [
45
+ entry("same", "Ada Lovelace and Alan Turing", "Same Paper"),
46
+ entry("dropped", "Ada Lovelace", "Dropped Author"),
47
+ entry("order", "Alan Turing and Ada Lovelace", "Order Swapped"),
48
+ entry("pre", "Ada Lovelace", "A Preprint", "arXiv preprint"),
49
+ entry("etal", "Ada Lovelace and others", "Truncated"),
50
+ entry("nomatch", "Ada Lovelace", "Nobody Has This"),
51
+ entry("onlycorr", "Ada Lovelace", "Only On CoRR"),
52
+ entry("down", "Ada Lovelace", "Service Down"),
53
+ {
54
+ type: "mdref",
55
+ key: "sw",
56
+ unparsed: true,
57
+ author: "",
58
+ title: "",
59
+ booktitle: "",
60
+ journal: "",
61
+ },
62
+ ];
63
+ const answers = {
64
+ "Same Paper": [hit("Same Paper.", ["Ada Lovelace", "Alan Turing"])],
65
+ "Dropped Author": [hit("Dropped Author", ["Ada Lovelace", "Alan Turing"])],
66
+ "Order Swapped": [hit("Order Swapped", ["Ada Lovelace", "Alan Turing"])],
67
+ "Nobody Has This": [hit("Something Else", ["X"])],
68
+ "Only On CoRR": [
69
+ hit("Only On CoRR", ["Ada Lovelace"], "CoRR", "Informal Publications"),
70
+ ],
71
+ };
72
+ const pauses = [];
73
+ const result = await checkAuthors(parsed, {
74
+ lookup: async (title) => {
75
+ if (title === "Service Down") throw new Error("DBLP 500");
76
+ return answers[title];
77
+ },
78
+ pause: (ms) => pauses.push(ms),
79
+ });
80
+ assert.deepEqual(
81
+ { ...result, pauses },
82
+ {
83
+ findings: [
84
+ {
85
+ key: "dropped",
86
+ venue: "X 2024",
87
+ ours: ["lovelace"],
88
+ theirs: ["lovelace", "turing"],
89
+ missing: ["turing"],
90
+ extra: [],
91
+ orderDiffers: false,
92
+ },
93
+ {
94
+ key: "order",
95
+ venue: "X 2024",
96
+ ours: ["turing", "lovelace"],
97
+ theirs: ["lovelace", "turing"],
98
+ missing: [],
99
+ extra: [],
100
+ orderDiffers: true,
101
+ },
102
+ ],
103
+ skipped: [
104
+ {
105
+ key: "sw",
106
+ why: "no author/title — a reference to software or a dataset, DBLP does not apply",
107
+ },
108
+ {
109
+ key: "etal",
110
+ why: "author list ends in `and others` — completeness not checkable",
111
+ },
112
+ { key: "nomatch", why: "no DBLP title match" },
113
+ { key: "onlycorr", why: "only a preprint record on DBLP" },
114
+ ],
115
+ unchecked: [{ key: "down", why: "DBLP lookup failed: DBLP 500" }],
116
+ matched: ["same"],
117
+ // 900 ms after each compared entry; 1500 and 3000 between the three failed attempts.
118
+ pauses: [900, 900, 900, 1500, 3000],
119
+ },
120
+ );
121
+ });
122
+
123
+ test("a lookup that fails once and then answers is retried, not reported", async () => {
124
+ let calls = 0;
125
+ const result = await checkAuthors([entry("k", "Ada Lovelace", "Flaky")], {
126
+ lookup: async () => {
127
+ if (calls++ === 0)
128
+ throw Object.assign(new Error("DBLP 429"), { retryable: true });
129
+ return [hit("Flaky", ["Ada Lovelace"])];
130
+ },
131
+ pause: () => {},
132
+ });
133
+ assert.deepEqual(result, {
134
+ findings: [],
135
+ skipped: [],
136
+ unchecked: [],
137
+ matched: ["k"],
138
+ });
139
+ });
140
+
141
+ test("the default pause really waits", async () => {
142
+ vi.useFakeTimers();
143
+ try {
144
+ const done = checkAuthors([entry("k", "Ada Lovelace", "T")], {
145
+ lookup: async () => [hit("T", ["Ada Lovelace"])],
146
+ });
147
+ await vi.advanceTimersByTimeAsync(900);
148
+ assert.deepEqual((await done).matched, ["k"]);
149
+ } finally {
150
+ vi.useRealTimers();
151
+ }
152
+ });
153
+
154
+ /** A stubbed fetch that answers every request with `status` and `body`, recording the URLs. */
155
+ function fakeDblp(status, body) {
156
+ const urls = [];
157
+ vi.stubGlobal("fetch", async (url) => {
158
+ urls.push(url);
159
+ return new Response(body === undefined ? null : JSON.stringify(body), {
160
+ status,
161
+ });
162
+ });
163
+ return urls;
164
+ }
165
+
166
+ test("dblpHits reads DBLP's hit list: one author or several, the disambiguation number dropped", async () => {
167
+ const urls = fakeDblp(200, {
168
+ result: {
169
+ hits: {
170
+ hit: [
171
+ {
172
+ info: {
173
+ title: "One",
174
+ venue: "ICSE",
175
+ year: "2024",
176
+ type: "Conference and Workshop Papers",
177
+ authors: { author: { text: "Ada Lovelace 0001" } },
178
+ },
179
+ },
180
+ {
181
+ info: {
182
+ title: "Two",
183
+ authors: { author: [{ text: "A B" }, { text: "C D" }] },
184
+ },
185
+ },
186
+ { info: {} },
187
+ {},
188
+ ],
189
+ },
190
+ },
191
+ });
192
+ assert.deepEqual(
193
+ { hits: await dblpHits("a title"), urls },
194
+ {
195
+ hits: [
196
+ {
197
+ venue: "ICSE",
198
+ year: "2024",
199
+ type: "Conference and Workshop Papers",
200
+ title: "One",
201
+ authors: ["Ada Lovelace"],
202
+ },
203
+ {
204
+ venue: "",
205
+ year: "",
206
+ type: "",
207
+ title: "Two",
208
+ authors: ["A B", "C D"],
209
+ },
210
+ { venue: "", year: "", type: "", title: "", authors: [] },
211
+ { venue: "", year: "", type: "", title: "", authors: [] },
212
+ ],
213
+ urls: ["https://dblp.org/search/publ/api/?q=a%20title&format=json&h=6"],
214
+ },
215
+ );
216
+ });
217
+
218
+ test("dblpHits: no hits is an empty list; 429 is retryable; other errors are not", async () => {
219
+ fakeDblp(200, {});
220
+ const none = await dblpHits("x");
221
+ fakeDblp(429);
222
+ const limited = await dblpHits("x").catch((e) => [e.message, e.retryable]);
223
+ fakeDblp(500);
224
+ const down = await dblpHits("x").catch((e) => [e.message, e.retryable]);
225
+ assert.deepEqual(
226
+ [none, limited, down],
227
+ [[], ["DBLP 429", true], ["DBLP 500", undefined]],
228
+ );
229
+ });
230
+
231
+ test("parseBib reads quoted and bare field values, and a field that is absent", () => {
232
+ assert.deepEqual(
233
+ parseBib(
234
+ '@article{k, author = "Ada Lovelace", title = {T}, journal = J, }',
235
+ ),
236
+ [
237
+ {
238
+ type: "article",
239
+ key: "k",
240
+ author: "Ada Lovelace",
241
+ title: "T",
242
+ booktitle: "",
243
+ journal: "J",
244
+ },
245
+ ],
246
+ );
247
+ });
248
+
249
+ test("parseBib: braces nested inside a value, and a bare last value with no comma after it", () => {
250
+ assert.deepEqual(
251
+ parseBib("@article{k, title = {The {BERT} Model}, journal = J}"),
252
+ [
253
+ {
254
+ type: "article",
255
+ key: "k",
256
+ author: "",
257
+ title: "The {BERT} Model",
258
+ booktitle: "",
259
+ journal: "J",
260
+ },
261
+ ],
262
+ );
263
+ });
264
+
265
+ test("surnames of nothing is nothing; a name with an empty family part contributes nothing", () => {
266
+ assert.deepEqual(
267
+ [surnames(""), surnames(", Ada and Lovelace")],
268
+ [[], ["lovelace"]],
269
+ );
270
+ });
271
+
272
+ test("an entry naming no venue at all does not claim to be published", () => {
273
+ assert.equal(claimsPublished({ booktitle: "", journal: "" }), false);
274
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * bib-authors.mjs as a process: which file it picks from a paper directory, the three
3
+ * bibliography sources it reads (.bib, a .tex's filecontents or its sibling refs.bib, a markdown
4
+ * reference list), and the report and exit code for each outcome. DBLP is a preload that answers
5
+ * by title from a table (the documented `result.hits.hit[].info` shape) — no network.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { join } from "node:path";
9
+ import { dirname } 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 SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "bib-authors.mjs");
15
+ const root = useTempDir("bib-authors-cli-");
16
+
17
+ const DBLP_ANSWERS = {
18
+ "Dropped Author": [["Ada Lovelace", "Alan Turing"]],
19
+ "Swapped Order": [["Ada Lovelace", "Alan Turing"]],
20
+ "All Good": [["Ada Lovelace"]],
21
+ };
22
+ const bib = (key, author, title, venue = "booktitle = {Proc. X}") =>
23
+ `@inproceedings{${key}, author = {${author}}, title = {${title}}, ${venue}}\n`;
24
+
25
+ writeTree(root, {
26
+ // The retry backoff (1.5 s, then 3 s) and the 900 ms courtesy pause are real sleeps in the
27
+ // script. Under CPU load they made "DBLP down" take 11.7 s once and time out. The preload
28
+ // stands in for the clock too: every timer fires at once, so no test here depends on time.
29
+ "dblp.mjs":
30
+ "const realSetTimeout = globalThis.setTimeout;\n" +
31
+ "globalThis.setTimeout = (fn, _ms, ...args) => realSetTimeout(fn, 0, ...args);\n" +
32
+ `const answers = ${JSON.stringify(DBLP_ANSWERS)};\n` +
33
+ "globalThis.fetch = async (url) => {\n" +
34
+ " const q = new URL(url).searchParams.get('q');\n" +
35
+ " if (q === 'Service Down') return new Response(null, { status: 500 });\n" +
36
+ " const hit = (answers[q] ?? []).map((authors) => ({ info: { title: q, venue: 'X', year: '2024',\n" +
37
+ " type: 'Conference and Workshop Papers', authors: { author: authors.map((text) => ({ text })) } } }));\n" +
38
+ " return new Response(JSON.stringify({ result: { hits: { hit } } }), { status: 200 });\n};\n",
39
+ "findings/paper.bib":
40
+ bib("dropped", "Ada Lovelace and Grace Hopper", "Dropped Author") +
41
+ bib("order", "Alan Turing and Ada Lovelace", "Swapped Order"),
42
+ "findings/other.bib": bib("never", "X", "Never Read"),
43
+ "twotex/b.tex": "\\documentclass{article}\n",
44
+ "twotex/a.tex":
45
+ "\\begin{filecontents*}{refs.bib}\n" +
46
+ bib("pre", "Ada Lovelace", "A Preprint", "journal = {arXiv preprint}") +
47
+ "\\end{filecontents*}\n",
48
+ "sibling/paper.tex": "\\documentclass{article}\n",
49
+ "sibling/refs.bib": bib("good", "Ada Lovelace", "All Good"),
50
+ "nobib/paper.tex": "\\documentclass{article}\n",
51
+ "md/paper.md":
52
+ "# P\n\n## References\n\n1. A. Lovelace. *All Good.* Proc. X, 2024.\n2. some-npm-package, https://npm.example\n",
53
+ "down/refs.bib": bib("down", "Ada Lovelace", "Service Down"),
54
+ "empty/.keep": "",
55
+ });
56
+
57
+ const run = (...args) => {
58
+ const r = runNode(SCRIPT, args, {
59
+ nodeArgs: ["--import", join(root, "dblp.mjs")],
60
+ });
61
+ return {
62
+ ...r,
63
+ stdout: r.stdout.replaceAll(root, "<root>"),
64
+ stderr: r.stderr.replaceAll(root, "<root>"),
65
+ };
66
+ };
67
+
68
+ test("refusals: no argument, no such path, a directory with nothing to read, a .tex with no bibliography", () => {
69
+ assert.deepEqual(
70
+ [
71
+ run(),
72
+ run(join(root, "nope")),
73
+ run(join(root, "empty")),
74
+ run(join(root, "nobib", "paper.tex")),
75
+ ].map(({ status, stdout, stderr }) => ({ status, stdout, stderr })),
76
+ [
77
+ {
78
+ status: 2,
79
+ stdout: "",
80
+ stderr:
81
+ "bib-authors: usage: bib-authors.mjs <paper-dir|file.bib|file.tex> [--json]\n",
82
+ },
83
+ {
84
+ status: 2,
85
+ stdout: "",
86
+ stderr: "bib-authors: no such path: <root>/nope\n",
87
+ },
88
+ {
89
+ status: 2,
90
+ stdout: "",
91
+ stderr: "bib-authors: no .bib, .tex or paper.md in <root>/empty\n",
92
+ },
93
+ {
94
+ status: 2,
95
+ stdout: "",
96
+ stderr:
97
+ "bib-authors: no bibliography found in <root>/nobib/paper.tex (no filecontents block, no refs.bib beside it)\n",
98
+ },
99
+ ],
100
+ );
101
+ });
102
+
103
+ test("a paper directory: the canonical paper.bib is read, and each difference is printed — FAIL, exit 1", () => {
104
+ assert.deepEqual(run(join(root, "findings")), {
105
+ status: 1,
106
+ stderr: "",
107
+ stdout: [
108
+ "== bib-authors: <root>/findings/paper.bib ==",
109
+ "",
110
+ " 🔴 dropped (DBLP: X 2024)",
111
+ " MISSING from ours : turing",
112
+ " EXTRA in ours : hopper",
113
+ "",
114
+ " 🔴 order (DBLP: X 2024)",
115
+ " ORDER differs",
116
+ " ours : turing > lovelace",
117
+ " DBLP : lovelace > turing",
118
+ "",
119
+ "-- 2 entries · 2 difference(s) · 0 not applicable · 0 NOT CHECKED",
120
+ "FAIL: our author list disagrees with the version we claim to cite.",
121
+ "",
122
+ ].join("\n"),
123
+ });
124
+ });
125
+
126
+ test("two .tex files and no paper.tex: the first by name, said out loud; its filecontents is read", () => {
127
+ const r = run(join(root, "twotex"), "--json");
128
+ assert.deepEqual(
129
+ { status: r.status, stderr: r.stderr, report: JSON.parse(r.stdout) },
130
+ {
131
+ status: 0,
132
+ stderr:
133
+ "⚠️ <root>/twotex holds several .tex files and no canonical paper.tex: a.tex, b.tex — took a.tex. If that is the wrong one, name the file explicitly.\n",
134
+ report: {
135
+ file: "<root>/twotex/a.tex",
136
+ entries: 1,
137
+ findings: [],
138
+ skipped: [],
139
+ unchecked: [],
140
+ },
141
+ },
142
+ );
143
+ });
144
+
145
+ test("a .tex with no filecontents reads the refs.bib beside it — PASS, exit 0", () => {
146
+ const r = run(join(root, "sibling", "paper.tex"));
147
+ assert.deepEqual(r, {
148
+ status: 0,
149
+ stderr: "",
150
+ stdout:
151
+ "== bib-authors: <root>/sibling/refs.bib ==\n\n-- 1 entries · 0 difference(s) · 0 not applicable · 0 NOT CHECKED\nPASS: no author-list disagreement.\n",
152
+ });
153
+ });
154
+
155
+ test("a markdown reference list: a software entry is not applicable, not dropped", () => {
156
+ const r = run(join(root, "md"));
157
+ assert.deepEqual(r, {
158
+ status: 0,
159
+ stderr: "",
160
+ stdout:
161
+ "== bib-authors: <root>/md/paper.md ==\n" +
162
+ " · n/a ref2 — no author/title — a reference to software or a dataset, DBLP does not apply\n\n" +
163
+ "-- 1 entries · 0 difference(s) · 1 not applicable · 0 NOT CHECKED\nPASS: no author-list disagreement.\n",
164
+ });
165
+ });
166
+
167
+ test("DBLP down: NOT CHECKED, PARTIAL, exit 2 — never a pass", () => {
168
+ const r = run(join(root, "down"));
169
+ assert.deepEqual(r, {
170
+ status: 2,
171
+ stderr: "",
172
+ stdout:
173
+ "== bib-authors: <root>/down/refs.bib ==\n" +
174
+ " ⚠️ NOT CHECKED down — DBLP lookup failed: DBLP 500\n\n" +
175
+ "-- 1 entries · 0 difference(s) · 0 not applicable · 1 NOT CHECKED\n" +
176
+ "PARTIAL: no disagreement among the entries reached, but 1 could not be checked — this is NOT a pass.\n",
177
+ });
178
+ });
@@ -28,11 +28,10 @@
28
28
  *
29
29
  * ⚠️ Discovery credit and a deliberate NON-dependency: this class was found by running
30
30
  * `rebiber` (yuchenlin/rebiber), which rewrites bib entries to their DBLP records. We do
31
- * NOT depend on it here. Two reasons, both practical: its install needs a Python toolchain
32
- * that fights modern setuptools (bibtexparser wheel fails with `AttributeError:
33
- * install_layout`; the workaround is vendoring a tarball), and its output REPLACES entries
34
- * with DBLP's very long official booktitles, which is wrong for a page-limited paper. What
35
- * we actually needed was the comparison, and that is one HTTPS call with no dependencies.
31
+ * NOT depend on it here. Re-measured 2026-09-27 on 1.4.0 (GitHub b917e36): it installs cleanly
32
+ * now but carries 409 MB of venue data, its bundled data still misses entries live DBLP finds,
33
+ * and what it does is REWRITE an entry — we need the comparison of an author list against the
34
+ * published record, and that is one HTTPS call with no dependencies.
36
35
  *
37
36
  * 🔴 The normalisation below is load-bearing, not tidiness. A first cut of this comparison
38
37
  * reported 13 mismatches on our bibliography, of which ELEVEN were "Last, First" vs
@@ -278,7 +277,8 @@ export function surnames(authorField) {
278
277
  .filter((s) => s && s !== "others");
279
278
  }
280
279
 
281
- const truncated = (authorField) => /\band\s+others\b/i.test(authorField || "");
280
+ // Only called for an entry that has an author (checkAuthors filters on it first).
281
+ export const truncated = (authorField) => /\band\s+others\b/i.test(authorField);
282
282
 
283
283
  /** Is the entry claiming a PUBLISHED venue (as opposed to a preprint)? */
284
284
  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
+ });