paperlint 3.1.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 (284) 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/bin/paperlint.test.mjs +45 -0
  6. package/dist/adapters/node/files.io.d.ts +2 -0
  7. package/dist/adapters/node/files.io.d.ts.map +1 -1
  8. package/dist/adapters/node/files.io.js +3 -2
  9. package/dist/adapters/node/files.io.js.map +1 -1
  10. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  11. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  12. package/dist/adapters/pdfjs/fill.js +31 -21
  13. package/dist/adapters/pdfjs/fill.js.map +1 -1
  14. package/dist/adapters/references/index.d.ts.map +1 -1
  15. package/dist/adapters/references/index.js +14 -5
  16. package/dist/adapters/references/index.js.map +1 -1
  17. package/dist/adapters/references/pool.d.ts +8 -0
  18. package/dist/adapters/references/pool.d.ts.map +1 -0
  19. package/dist/adapters/references/pool.js +19 -0
  20. package/dist/adapters/references/pool.js.map +1 -0
  21. package/dist/build.d.ts +10 -2
  22. package/dist/build.d.ts.map +1 -1
  23. package/dist/build.js +7 -8
  24. package/dist/build.js.map +1 -1
  25. package/dist/cli.d.ts +27 -1
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +71 -55
  28. package/dist/cli.js.map +1 -1
  29. package/dist/doctor.d.ts +27 -1
  30. package/dist/doctor.d.ts.map +1 -1
  31. package/dist/doctor.js +4 -3
  32. package/dist/doctor.js.map +1 -1
  33. package/dist/domain/result.d.ts +0 -2
  34. package/dist/domain/result.d.ts.map +1 -1
  35. package/dist/domain/result.js +0 -4
  36. package/dist/domain/result.js.map +1 -1
  37. package/dist/hooks-settings.d.ts +8 -5
  38. package/dist/hooks-settings.d.ts.map +1 -1
  39. package/dist/hooks-settings.js +32 -32
  40. package/dist/hooks-settings.js.map +1 -1
  41. package/dist/init.d.ts +8 -0
  42. package/dist/init.d.ts.map +1 -1
  43. package/dist/init.js +3 -3
  44. package/dist/init.js.map +1 -1
  45. package/dist/latex-log.d.ts +0 -2
  46. package/dist/latex-log.d.ts.map +1 -1
  47. package/dist/latex-log.js +7 -13
  48. package/dist/latex-log.js.map +1 -1
  49. package/dist/link-skills.d.ts +15 -2
  50. package/dist/link-skills.d.ts.map +1 -1
  51. package/dist/link-skills.js +36 -20
  52. package/dist/link-skills.js.map +1 -1
  53. package/dist/paper-files.d.ts.map +1 -1
  54. package/dist/paper-files.js +6 -4
  55. package/dist/paper-files.js.map +1 -1
  56. package/dist/pdf-facts.d.ts +11 -0
  57. package/dist/pdf-facts.d.ts.map +1 -1
  58. package/dist/pdf-facts.js +16 -18
  59. package/dist/pdf-facts.js.map +1 -1
  60. package/dist/pdf-geometry.js +1 -1
  61. package/dist/pdf-geometry.js.map +1 -1
  62. package/dist/presets.d.ts +2 -0
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +3 -1
  65. package/dist/presets.js.map +1 -1
  66. package/dist/references.js +2 -2
  67. package/dist/references.js.map +1 -1
  68. package/dist/tex-requirements.d.ts +8 -0
  69. package/dist/tex-requirements.d.ts.map +1 -1
  70. package/dist/tex-requirements.js +6 -5
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/toolchain.d.ts +3 -2
  73. package/dist/toolchain.d.ts.map +1 -1
  74. package/dist/toolchain.js +4 -5
  75. package/dist/toolchain.js.map +1 -1
  76. package/dist/venue-rules.d.ts.map +1 -1
  77. package/dist/venue-rules.js +11 -9
  78. package/dist/venue-rules.js.map +1 -1
  79. package/docs/prior-art/README.md +1 -1
  80. package/docs/prior-art/test-tooling.md +7 -30
  81. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  82. package/eslint-rules/latex-language.harness.mjs +3 -3
  83. package/eslint-rules/latex-language.mjs +12 -9
  84. package/eslint-rules/latex-language.test.mjs +200 -0
  85. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  86. package/eslint-rules/paper-stages.harness.mjs +9 -14
  87. package/eslint-rules/paper-stages.test.mjs +135 -0
  88. package/eslint-rules/paper-typography.mjs +9 -2
  89. package/eslint-rules/paper-typography.test.mjs +146 -1
  90. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  91. package/eslint-rules/review-frontmatter.mjs +2 -1
  92. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  93. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  94. package/eslint-rules/tex-build.mjs +13 -13
  95. package/eslint.config.mjs +6 -2
  96. package/hooks/hooks.harness.mjs +6 -11
  97. package/hooks/paper-edit-guard.hook.mjs +2 -2
  98. package/hooks/paper-edit-guard.test.mjs +61 -0
  99. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  100. package/hooks/paper-skills-nudge.test.mjs +38 -0
  101. package/hooks/paper-status-gates.test.mjs +42 -0
  102. package/lib/agent-cli-version.harness.mjs +7 -0
  103. package/lib/check.d.mts +17 -0
  104. package/lib/check.mjs +50 -0
  105. package/lib/check.test.ts +67 -0
  106. package/lib/markdown.mjs +16 -69
  107. package/lib/markdown.test.mjs +246 -0
  108. package/lib/paper-config.harness.mjs +28 -9
  109. package/lib/paper-config.mjs +7 -2
  110. package/lib/skill-checks.mjs +18 -46
  111. package/lib/skill-checks.test.mjs +297 -0
  112. package/lib/skill-corpus.mjs +11 -13
  113. package/lib/skill-corpus.test.mjs +54 -0
  114. package/lib/skill-trigger-cases.harness.mjs +57 -4
  115. package/package.json +6 -5
  116. package/scripts/check.harness.mjs +4 -10
  117. package/scripts/check.mjs +45 -42
  118. package/scripts/check.test.mjs +104 -0
  119. package/scripts/coverage-config.test.ts +406 -0
  120. package/scripts/eslint-report-guard.test.mjs +48 -0
  121. package/scripts/harness-api.frozen.json +0 -2
  122. package/scripts/layer-legacy-frozen.d.mts +9 -1
  123. package/scripts/layer-legacy-frozen.mjs +28 -11
  124. package/scripts/layer-legacy-frozen.test.ts +143 -1
  125. package/scripts/rules-are-content-only.harness.mjs +3 -11
  126. package/scripts/rules-are-content-only.mjs +29 -19
  127. package/scripts/rules-are-content-only.test.mjs +61 -0
  128. package/scripts/rules-see-files.mjs +11 -9
  129. package/scripts/rules-see-files.test.mjs +71 -0
  130. package/scripts/test.mjs +44 -0
  131. package/scripts/test.test.mjs +52 -0
  132. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  133. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  134. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  135. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  136. package/skills/paper-pipeline/scripts/README.md +13 -132
  137. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  138. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  139. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  140. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  141. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  142. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  145. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  148. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  149. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  150. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  151. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  152. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  153. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  154. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  155. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  156. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  157. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  158. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  159. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  160. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  161. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  162. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  163. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  164. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  165. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  166. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  167. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  168. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  169. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  170. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  171. package/skills/tighten-paper/structure.test.mjs +116 -0
  172. package/skills/verify-citations/SKILL.md +5 -5
  173. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  174. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  175. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  176. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  177. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  178. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  179. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  180. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  181. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  182. package/src/adapters/banal/failure.test.ts +70 -1
  183. package/src/adapters/banal/index.test.ts +16 -0
  184. package/src/adapters/banal/output.test.ts +5 -0
  185. package/src/adapters/curl/download.test.ts +43 -1
  186. package/src/adapters/memory/index.test.ts +25 -0
  187. package/src/adapters/node/files.io.ts +3 -2
  188. package/src/adapters/node/files.test.ts +13 -1
  189. package/src/adapters/node/process.test.ts +38 -0
  190. package/src/adapters/pdfjs/fill.test.ts +34 -0
  191. package/src/adapters/pdfjs/fill.ts +44 -25
  192. package/src/adapters/references/index.test.ts +228 -0
  193. package/src/adapters/references/index.ts +21 -6
  194. package/src/adapters/references/pool.test.ts +31 -0
  195. package/src/adapters/references/pool.ts +24 -0
  196. package/src/build-engine.harness.mjs +3 -7
  197. package/src/build-engine.test.ts +137 -0
  198. package/src/build.harness.mjs +5 -9
  199. package/src/build.test.ts +362 -0
  200. package/src/build.ts +13 -9
  201. package/src/cli.harness.mjs +10 -17
  202. package/src/cli.test.ts +584 -0
  203. package/src/cli.ts +112 -66
  204. package/src/doctor.harness.mjs +3 -8
  205. package/src/doctor.test.ts +64 -0
  206. package/src/doctor.ts +12 -4
  207. package/src/domain/result.test.ts +4 -0
  208. package/src/domain/result.ts +0 -5
  209. package/src/engine.harness.mjs +27 -7
  210. package/src/facts-file.test.ts +110 -0
  211. package/src/hooks-settings.harness.mjs +43 -13
  212. package/src/hooks-settings.ts +41 -35
  213. package/src/init.test.ts +308 -5
  214. package/src/init.ts +3 -3
  215. package/src/latex-log.harness.mjs +32 -7
  216. package/src/latex-log.ts +6 -13
  217. package/src/latex-loop.harness.mjs +8 -7
  218. package/src/link-skills.harness.mjs +3 -8
  219. package/src/link-skills.test.ts +105 -0
  220. package/src/link-skills.ts +75 -21
  221. package/src/new-paper.harness.mjs +3 -7
  222. package/src/new-paper.test.ts +31 -1
  223. package/src/paper-files.test.ts +12 -0
  224. package/src/paper-files.ts +9 -5
  225. package/src/paper-settings.test.ts +20 -0
  226. package/src/pdf-facts.harness.mjs +3 -6
  227. package/src/pdf-facts.test.ts +144 -0
  228. package/src/pdf-facts.ts +17 -19
  229. package/src/pdf-geometry.harness.mjs +3 -7
  230. package/src/pdf-geometry.ts +1 -1
  231. package/src/presets.test.ts +2 -0
  232. package/src/presets.ts +7 -1
  233. package/src/references.test.ts +97 -0
  234. package/src/references.ts +2 -2
  235. package/src/rules-config.harness.mjs +10 -8
  236. package/src/structure.harness.mjs +3 -7
  237. package/src/tex-requirements.harness.mjs +34 -7
  238. package/src/tex-requirements.ts +12 -6
  239. package/src/toolchain.harness.mjs +3 -7
  240. package/src/toolchain.test.ts +280 -0
  241. package/src/toolchain.ts +8 -7
  242. package/src/venue-rules.test.ts +60 -0
  243. package/src/venue-rules.ts +13 -11
  244. package/action.mutations.mjs +0 -162
  245. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  246. package/eslint-rules/latex-language.mutations.mjs +0 -196
  247. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  248. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  249. package/eslint-rules/papers.mutations.mjs +0 -186
  250. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  251. package/eslint-rules/tex-build.mutations.mjs +0 -258
  252. package/hooks/hooks.mutations.mjs +0 -337
  253. package/lib/agent-cli-version.mutations.mjs +0 -109
  254. package/lib/mutation-driver.harness.mjs +0 -227
  255. package/lib/mutation-driver.mjs +0 -397
  256. package/lib/mutation-driver.mutations.mjs +0 -68
  257. package/lib/paper-config.mutations.mjs +0 -143
  258. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  259. package/scripts/check.mutations.mjs +0 -110
  260. package/scripts/exclusive.mjs +0 -138
  261. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  262. package/scripts/mutation-batteries-frozen.mjs +0 -238
  263. package/scripts/mutation-batteries.frozen.json +0 -108
  264. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  265. package/scripts/rules-see-files.mutations.mjs +0 -131
  266. package/scripts/run-mutations.mjs +0 -100
  267. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  268. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  269. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  270. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  271. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  272. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  273. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  274. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  275. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  276. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  277. package/skills/skill-contract.mutations.mjs +0 -179
  278. package/src/build.mutations.mjs +0 -196
  279. package/src/cli.mutations.mjs +0 -414
  280. package/src/doctor.mutations.mjs +0 -175
  281. package/src/hooks-settings.mutations.mjs +0 -99
  282. package/src/link-skills.mutations.mjs +0 -140
  283. package/src/new-paper.mutations.mjs +0 -65
  284. package/src/structure.mutations.mjs +0 -83
@@ -0,0 +1,287 @@
1
+ /**
2
+ * verify-cites.mjs's network layer — the four registries, the DOI authority and NVD — driven
3
+ * in-process with a fake `fetch`, so every transport outcome is produced on purpose and nothing
4
+ * touches the network. Each case compares the whole verdict AND the exact requests made: a
5
+ * resolver that asks the wrong URL is as broken as one that misreads the answer.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { afterEach, expect, test, vi } from "vitest";
9
+ import { verifyCitationLive } from "./verify-cites.mjs";
10
+
11
+ const json = (status, body) =>
12
+ new Response(body === undefined ? null : JSON.stringify(body), { status });
13
+ const text = (status, body) => new Response(body, { status });
14
+
15
+ /**
16
+ * A fake `fetch`: `routes` maps a URL prefix to a response factory (or an Error to throw). The
17
+ * longest matching prefix wins; an unrouted URL fails the test. Returns the URLs asked, in order.
18
+ */
19
+ function fakeFetch(routes) {
20
+ const calls = [];
21
+ vi.stubGlobal("fetch", async (url) => {
22
+ calls.push(url);
23
+ const hit = Object.keys(routes)
24
+ .filter((p) => url.startsWith(p))
25
+ .sort((a, b) => b.length - a.length)[0];
26
+ if (hit === undefined) throw new Error(`unrouted request: ${url}`);
27
+ const r = routes[hit];
28
+ if (r instanceof Error) throw r;
29
+ return r();
30
+ });
31
+ return calls;
32
+ }
33
+ afterEach(() => {
34
+ vi.unstubAllGlobals();
35
+ vi.useRealTimers();
36
+ });
37
+
38
+ const CR = "https://api.crossref.org/works";
39
+ const OA = "https://api.openalex.org/works";
40
+ const S2 = "https://api.semanticscholar.org/graph/v1/paper";
41
+ const AX = "https://export.arxiv.org/api/query";
42
+ const DOI = "https://doi.org/api/handles/";
43
+ const NVD = "https://services.nvd.nist.gov/rest/json/cves/2.0";
44
+ const TITLE = "Attention Is All You Need";
45
+ const feed = (title, year) =>
46
+ `<feed><entry><id>http://arxiv.org/abs/1706.03762v1</id><published>${year}-06-12</published><title>${title}</title></entry></feed>`;
47
+
48
+ test("a DOI every registry resolves to the claimed paper, and the authority knows: true", async () => {
49
+ const calls = fakeFetch({
50
+ [CR]: () =>
51
+ json(200, {
52
+ message: { title: [TITLE], issued: { "date-parts": [[2017]] } },
53
+ }),
54
+ [OA]: () =>
55
+ json(200, { id: "W1", display_name: TITLE, publication_year: 2017 }),
56
+ [S2]: () => json(200, { title: TITLE, year: 2017 }),
57
+ [DOI]: () => json(200, { responseCode: 1 }),
58
+ });
59
+ const cite = { id: "v", doi: "10.1/x", title: TITLE, year: 2017 };
60
+ expect({ result: await verifyCitationLive(cite), calls }).toMatchSnapshot();
61
+ });
62
+
63
+ test("a DOI nobody knows, and the authority says it does not exist: false", async () => {
64
+ const calls = fakeFetch({
65
+ [CR]: () => json(404),
66
+ [OA]: () => json(404),
67
+ [S2]: () => json(404),
68
+ [DOI]: () => json(404),
69
+ });
70
+ const result = await verifyCitationLive({ id: "f", doi: "10.1/nope" });
71
+ expect({ result, calls }).toMatchSnapshot();
72
+ });
73
+
74
+ test("every registry down (429, 503, 403, a thrown error): unresolvable, never false", async () => {
75
+ const calls = fakeFetch({
76
+ [CR]: () => json(429),
77
+ [OA]: () => json(503),
78
+ [S2]: () => json(403),
79
+ [DOI]: new TypeError("fetch failed"),
80
+ });
81
+ const result = await verifyCitationLive({
82
+ id: "u",
83
+ doi: "10.1/x",
84
+ title: TITLE,
85
+ });
86
+ expect({ result, calls }).toMatchSnapshot();
87
+ });
88
+
89
+ test("records with fields missing: no title, a subtitle, a year only in `published`, no id", async () => {
90
+ const calls = fakeFetch({
91
+ [CR]: () =>
92
+ json(200, {
93
+ message: {
94
+ subtitle: ["All You Need"],
95
+ published: { "date-parts": [[2017]] },
96
+ },
97
+ }),
98
+ [OA]: () => json(200, { display_name: TITLE }), // no `id` → no record
99
+ [S2]: () => json(200, { title: null }), // a null title → no record
100
+ [DOI]: () => json(200, { responseCode: "1" }), // not a number → unknown
101
+ });
102
+ const result = await verifyCitationLive({
103
+ id: "m",
104
+ doi: "10.1/x",
105
+ title: TITLE,
106
+ });
107
+ expect({ result, calls }).toMatchSnapshot();
108
+ });
109
+
110
+ test("an arXiv id: Semantic Scholar by arXiv id, the arXiv API by id_list", async () => {
111
+ const calls = fakeFetch({
112
+ [S2]: () => json(200, { title: TITLE, year: 2017 }),
113
+ [AX]: () => text(200, feed(TITLE, 2017)),
114
+ });
115
+ const result = await verifyCitationLive({
116
+ id: "a",
117
+ arxiv: "1706.03762",
118
+ title: TITLE,
119
+ });
120
+ expect({ result, calls }).toMatchSnapshot();
121
+ });
122
+
123
+ test("an arXiv id nobody has: the arXiv API's empty feed and S2's 404", async () => {
124
+ const calls = fakeFetch({
125
+ [S2]: () => json(404),
126
+ [AX]: () => text(200, "<feed></feed>"),
127
+ });
128
+ const result = await verifyCitationLive({ id: "n", arxiv: "9999.99999" });
129
+ expect({ result, calls }).toMatchSnapshot();
130
+ });
131
+
132
+ test("a title only: every registry's search endpoint, hits and all", async () => {
133
+ const calls = fakeFetch({
134
+ [`${CR}?`]: () =>
135
+ json(200, {
136
+ message: {
137
+ items: [{ title: [TITLE], issued: { "date-parts": [[2017]] } }, {}],
138
+ },
139
+ }),
140
+ [`${OA}?`]: () =>
141
+ json(200, {
142
+ results: [{ display_name: TITLE, publication_year: 2017 }, {}],
143
+ }),
144
+ [`${S2}/search`]: () =>
145
+ json(200, { data: [{ title: TITLE, year: 2017 }, {}] }),
146
+ [AX]: () => text(200, feed(TITLE, 2017)),
147
+ });
148
+ const result = await verifyCitationLive({
149
+ id: "t",
150
+ title: TITLE,
151
+ year: 2017,
152
+ });
153
+ expect({ result, calls }).toMatchSnapshot();
154
+ });
155
+
156
+ test("a title search whose answers are empty or shapeless is a miss, not a match", async () => {
157
+ const calls = fakeFetch({
158
+ [`${CR}?`]: () => json(200, {}),
159
+ [`${OA}?`]: () => json(200, {}),
160
+ [`${S2}/search`]: () => json(200, {}),
161
+ [AX]: () => text(200, ""),
162
+ });
163
+ const result = await verifyCitationLive({ id: "e", title: TITLE });
164
+ expect({ result, calls }).toMatchSnapshot();
165
+ });
166
+
167
+ test("a title search that cannot reach any registry is unresolvable", async () => {
168
+ const abort = Object.assign(new Error("aborted"), { name: "AbortError" });
169
+ const calls = fakeFetch({
170
+ [`${CR}?`]: () => json(500),
171
+ [`${OA}?`]: () => json(502),
172
+ [`${S2}/search`]: abort,
173
+ [AX]: () => text(503, ""),
174
+ });
175
+ const result = await verifyCitationLive({ id: "d", title: TITLE });
176
+ expect({ result, calls }).toMatchSnapshot();
177
+ });
178
+
179
+ test("S2 and arXiv unreachable for an arXiv id", async () => {
180
+ const calls = fakeFetch({ [S2]: () => json(500), [AX]: () => text(500, "") });
181
+ const result = await verifyCitationLive({ id: "x", arxiv: "1706.03762" });
182
+ expect({ result, calls }).toMatchSnapshot();
183
+ });
184
+
185
+ test("CVEs: found by count, found by list, not found, NVD down, and a malformed id", async () => {
186
+ const calls = fakeFetch({
187
+ [`${NVD}?cveId=CVE-2024-0001`]: () => json(200, { totalResults: 1 }),
188
+ [`${NVD}?cveId=CVE-2024-0002`]: () => json(200, { vulnerabilities: [{}] }),
189
+ [`${NVD}?cveId=CVE-2024-0003`]: () => json(404),
190
+ [`${NVD}?cveId=CVE-2024-0004`]: () => json(500),
191
+ [`${NVD}?cveId=CVE-2024-0005`]: () => json(200, {}),
192
+ });
193
+ const results = [];
194
+ for (const cve of [
195
+ "cve-2024-0001",
196
+ "CVE-2024-0002",
197
+ "CVE-2024-0003",
198
+ "CVE-2024-0004",
199
+ "CVE-2024-0005",
200
+ "CVE-24-1",
201
+ ])
202
+ results.push(await verifyCitationLive({ id: cve, cve }));
203
+ expect({ results, calls }).toMatchSnapshot();
204
+ });
205
+
206
+ test("a second lookup is served from the cache; failures and a nonexistent DOI are not cached", async () => {
207
+ const cache = {};
208
+ fakeFetch({
209
+ [CR]: () => json(200, { message: { title: [TITLE] } }),
210
+ [OA]: () => json(503),
211
+ [S2]: () => json(404),
212
+ [DOI]: () => json(200, { responseCode: 1 }),
213
+ [NVD]: () => json(200, { totalResults: 1 }),
214
+ });
215
+ const cite = { id: "c", doi: "10.1/x", title: TITLE, cve: "CVE-2024-0001" };
216
+ const first = await verifyCitationLive(cite, { cache });
217
+ const again = fakeFetch({ [OA]: () => json(503) });
218
+ const second = await verifyCitationLive(cite, { cache });
219
+ assert.deepEqual(
220
+ { same: second, keys: Object.keys(cache).sort(), again },
221
+ {
222
+ same: first,
223
+ // arXiv is not asked about a DOI at all, so it has nothing to cache.
224
+ keys: [
225
+ "crossref:doi:10.1/x",
226
+ "doi_authority:10.1/x",
227
+ "nvd:CVE-2024-0001",
228
+ "semantic_scholar:doi:10.1/x",
229
+ ],
230
+ // Only the resolver that failed the first time is asked again.
231
+ again: ["https://api.openalex.org/works/doi:10.1%2Fx"],
232
+ },
233
+ );
234
+ });
235
+
236
+ test("offline: no request at all, the verdict from the citation alone", async () => {
237
+ const calls = fakeFetch({});
238
+ const result = await verifyCitationLive(
239
+ { id: "o", doi: "10.1/x", title: TITLE },
240
+ { offline: true },
241
+ );
242
+ expect({ result, calls }).toMatchSnapshot();
243
+ });
244
+
245
+ test("a registry that never answers is abandoned after the timeout, as unreachable", async () => {
246
+ vi.useFakeTimers();
247
+ const calls = [];
248
+ vi.stubGlobal("fetch", (url, { signal }) => {
249
+ calls.push(url);
250
+ return new Promise((_, reject) =>
251
+ signal.addEventListener("abort", () =>
252
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
253
+ ),
254
+ );
255
+ });
256
+ const pending = verifyCitationLive({ id: "s", title: TITLE });
257
+ for (let i = 0; i < 4; i++) await vi.advanceTimersByTimeAsync(15000);
258
+ const result = await pending;
259
+ assert.deepEqual(
260
+ { verdict: result.verdict, calls: calls.length },
261
+ { verdict: "unresolvable", calls: 4 },
262
+ );
263
+ });
264
+
265
+ test("a registry that answers 200 with no body, or a record without its title, still classifies", async () => {
266
+ const calls = fakeFetch({
267
+ [CR]: () => json(200, {}),
268
+ [OA]: () => json(200, { id: "W1", publication_year: 2017 }),
269
+ [S2]: () => json(200, { title: "", year: 2017 }),
270
+ [DOI]: () => json(200, { responseCode: 1 }),
271
+ });
272
+ const result = await verifyCitationLive({
273
+ id: "b",
274
+ doi: "10.1/x",
275
+ title: TITLE,
276
+ });
277
+ // OpenAlex resolves the DOI to a record with no title: an incomparable title is not a
278
+ // mismatch, so the DOI stands confirmed.
279
+ assert.deepEqual(
280
+ {
281
+ verdict: result.verdict,
282
+ matched_db: result.matched_db,
283
+ calls: calls.length,
284
+ },
285
+ { verdict: "true", matched_db: "openalex", calls: 4 },
286
+ );
287
+ });
@@ -15,7 +15,6 @@ import { test } from "vitest";
15
15
  import {
16
16
  levenshtein,
17
17
  titleSimilarity,
18
- titlesMatch,
19
18
  yearMatch,
20
19
  titleRelation,
21
20
  bestTitleRelation,
@@ -30,6 +29,8 @@ import {
30
29
  normalizeDoi,
31
30
  normalizeArxiv,
32
31
  normalizeIdentifiers,
32
+ normalizeTitle,
33
+ isValidCveId,
33
34
  } from "./verify-cites.mjs";
34
35
 
35
36
  /** One vitest case per check, named by it. */
@@ -52,17 +53,17 @@ ok(
52
53
  "case/normalize identical titles",
53
54
  );
54
55
  ok(
55
- titlesMatch(
56
+ titleSimilarity(
56
57
  "Deep Residual Learning for Image Recognition",
57
58
  "Deep Residual Learning for Image Recognitionn",
58
- ),
59
+ ) >= 0.7,
59
60
  "one typo still ≥0.70",
60
61
  );
61
62
  ok(
62
- !titlesMatch(
63
+ titleSimilarity(
63
64
  "Attention Is All You Need",
64
65
  "A Survey of Reinforcement Learning",
65
- ),
66
+ ) < 0.7,
66
67
  "unrelated titles < 0.70",
67
68
  );
68
69
  ok(yearMatch(2020, 2021), "year ±1 ok");
@@ -733,3 +734,146 @@ console.log("\n[S4] .bib parser edge cases:");
733
734
  "bibitem DOI trailing period not swallowed",
734
735
  );
735
736
  }
737
+
738
+ // ── Edges of the pure layer: the defensive arms, each with the input that reaches it ──────────
739
+
740
+ test("levenshtein and normalizeTitle take a missing string as empty", () => {
741
+ assert.deepEqual(
742
+ [
743
+ levenshtein(null, "ab"),
744
+ levenshtein("abc", undefined),
745
+ levenshtein("", ""),
746
+ normalizeTitle(undefined),
747
+ ],
748
+ [2, 3, 0, ""],
749
+ );
750
+ });
751
+
752
+ test("normalizeDoi / normalizeArxiv pass a missing value through untouched", () => {
753
+ assert.deepEqual(
754
+ [
755
+ normalizeDoi(undefined),
756
+ normalizeDoi(""),
757
+ normalizeArxiv(null),
758
+ normalizeArxiv(""),
759
+ ],
760
+ [undefined, "", null, ""],
761
+ );
762
+ });
763
+
764
+ test("titleRelation: an acronym of one letter is not an acronym; scripts are told apart", () => {
765
+ assert.deepEqual(
766
+ [
767
+ // "x" is one letter: it cannot spell the initials of anything.
768
+ titleRelation("x", "extensible markup language"),
769
+ titleRelation("Нейронные сети", "Neural networks"),
770
+ titleRelation("ニューラルネットワーク", "Neural networks"),
771
+ titleRelation("Νευρωνικά δίκτυα", "Neural networks"),
772
+ titleRelation("الشبكات العصبية", "Neural networks"),
773
+ titleRelation("!!!", "Neural networks"),
774
+ ],
775
+ [
776
+ "different",
777
+ "incomparable",
778
+ "incomparable",
779
+ "incomparable",
780
+ "incomparable",
781
+ "incomparable",
782
+ ],
783
+ );
784
+ });
785
+
786
+ test("yearMatch reads a year out of a string and ignores one that is not a year", () => {
787
+ assert.deepEqual(
788
+ [
789
+ yearMatch("2017-06", 2018),
790
+ yearMatch("2017", 2020),
791
+ yearMatch("n.d.", 2020),
792
+ ],
793
+ [true, false, true],
794
+ );
795
+ });
796
+
797
+ test("classifyResolver: a title search with no `records` field is a miss", () => {
798
+ assert.deepEqual(
799
+ classifyResolver(
800
+ { title: "A Paper" },
801
+ { db: "crossref", transport: "ok", query: "title" },
802
+ ),
803
+ { db: "crossref", status: "title_miss" },
804
+ );
805
+ });
806
+
807
+ test("checkNvd with no CVE on the citation says nothing; isValidCveId of nothing is false", () => {
808
+ assert.deepEqual(
809
+ [checkNvd({}, null), isValidCveId(undefined)],
810
+ [null, false],
811
+ );
812
+ });
813
+
814
+ test("reduceVerdict: an arXiv id missed only by a non-authoritative source is unresolvable", () => {
815
+ const r = reduceVerdict({ id: "a", arxiv: "1706.03762" }, [
816
+ {
817
+ db: "semantic_scholar",
818
+ status: "id_unmatched",
819
+ query: "arxiv",
820
+ authoritative: false,
821
+ },
822
+ ]);
823
+ assert.deepEqual(r, {
824
+ id: "a",
825
+ verdict: "unresolvable",
826
+ reason:
827
+ "arXiv id 1706.03762 missed only by non-authoritative sources (the reachable arXiv API did not definitively reject it) — cannot assert fabrication → unresolvable",
828
+ matched_db: null,
829
+ });
830
+ });
831
+
832
+ test("parseBib: @comment/@string/@preamble are skipped, a bare value is read, an empty one is absent", () => {
833
+ const bib = [
834
+ "@comment{c, not an entry}",
835
+ "@string{s, x = {y}}",
836
+ '@article{k1, title = "Quoted Title", year = 2017, eprint = {1706.03762}, archiveprefix = {arXiv}}',
837
+ "@misc{k2, title = {T}, year = ,}",
838
+ ].join("\n");
839
+ assert.deepEqual(parseBib(bib), [
840
+ { id: "k1", title: "Quoted Title", year: "2017", arxiv: "1706.03762" },
841
+ { id: "k2", title: "T" },
842
+ ]);
843
+ });
844
+
845
+ test("parseBib's \\bibitem fallback reads an arXiv id and an \\emph title", () => {
846
+ const text = [
847
+ "\\begin{thebibliography}{9}",
848
+ "\\bibitem{a} A. Author. \\emph{A Long Enough Title}. arXiv:1706.03762, 2017.",
849
+ "\\end{thebibliography}",
850
+ ].join("\n");
851
+ assert.deepEqual(parseBib(text), [
852
+ {
853
+ id: "a",
854
+ arxiv: "1706.03762",
855
+ year: "2017",
856
+ title: "A Long Enough Title",
857
+ },
858
+ ]);
859
+ });
860
+
861
+ test("parseArxivFeed: an entry without a title is skipped, one without a date has no year", () => {
862
+ assert.deepEqual(
863
+ parseArxivFeed("<entry><id>x</id></entry><entry><title>T</title></entry>"),
864
+ [{ title: "T", year: null }],
865
+ );
866
+ });
867
+
868
+ test("a two-letter token ending in s is depluralised to one letter, which spells no acronym", () => {
869
+ assert.equal(
870
+ titleRelation("TS models", "totally separate models"),
871
+ "different",
872
+ );
873
+ });
874
+
875
+ test("parseArxivFeed keeps an entry titled Error when it has no <id> to prove it is the sentinel", () => {
876
+ assert.deepEqual(parseArxivFeed("<entry><title>Error</title></entry>"), [
877
+ { title: "Error", year: null },
878
+ ]);
879
+ });
@@ -4,7 +4,12 @@
4
4
  */
5
5
  import assert from "node:assert/strict";
6
6
  import { test } from "vitest";
7
- import { describe, describeLine, PERL_MISSING } from "./failure.ts";
7
+ import {
8
+ type BanalFailure,
9
+ describe,
10
+ describeLine,
11
+ PERL_MISSING,
12
+ } from "./failure.ts";
8
13
  import { acceptProbe } from "./probe.ts";
9
14
 
10
15
  test("describe: perl missing carries the fix", () => {
@@ -61,3 +66,67 @@ test("acceptProbe: one page and a body size is accepted; anything else is `probe
61
66
  assert.equal(!r.ok && r.error.kind, "probe-rejected");
62
67
  assert.equal(acceptProbe({ pages: [], bodyfontsize: 10 }).ok, false);
63
68
  });
69
+
70
+ test("describe: every other kind is one exact sentence", () => {
71
+ const cases: readonly [BanalFailure, string][] = [
72
+ [
73
+ { kind: "process-failed", status: 2, stderrHead: "boom" },
74
+ "banal failed (exit 2): boom",
75
+ ],
76
+ [
77
+ { kind: "signalled", signal: "SIGKILL", stderrHead: "" },
78
+ "banal failed (SIGKILL): no output",
79
+ ],
80
+ [
81
+ { kind: "signalled", signal: "SIGTERM", stderrHead: "late" },
82
+ "banal failed (SIGTERM): late",
83
+ ],
84
+ [{ kind: "spawn-failed", message: "EACCES" }, "banal failed: EACCES"],
85
+ [
86
+ { kind: "timed-out", afterMs: 500 },
87
+ "banal failed: no answer after 500 ms",
88
+ ],
89
+ [{ kind: "no-json", head: "" }, "banal printed no JSON: nothing"],
90
+ [{ kind: "no-json", head: "Usage:" }, "banal printed no JSON: Usage:"],
91
+ [
92
+ { kind: "banal-error", stderrHead: "bad xml" },
93
+ "banal could not read the banal input XML: bad xml",
94
+ ],
95
+ [
96
+ {
97
+ kind: "unexpected-shape",
98
+ issues: ["pages missing", "bodyfontsize not a number"],
99
+ },
100
+ "banal printed JSON that is not a measurement: pages missing; bodyfontsize not a number",
101
+ ],
102
+ [
103
+ { kind: "probe-rejected", got: { pages: [] } },
104
+ 'banal ran on a one-page probe but measured nothing: {"pages":[]}',
105
+ ],
106
+ [
107
+ { kind: "download-failed", url: "https://x/banal", detail: "HTTP 404" },
108
+ "could not download banal from https://x/banal: HTTP 404",
109
+ ],
110
+ [
111
+ { kind: "not-pinned", path: "/c/banal" },
112
+ "banal in /c/banal is not the pinned one (sha256 differs)",
113
+ ],
114
+ [
115
+ {
116
+ kind: "does-not-run",
117
+ path: "/c/banal",
118
+ why: { kind: "timed-out", afterMs: 9 },
119
+ },
120
+ "banal in /c/banal does not run: banal failed: no answer after 9 ms",
121
+ ],
122
+ ];
123
+ for (const [failure, sentence] of cases)
124
+ assert.deepEqual(describe(failure), [sentence]);
125
+ });
126
+
127
+ test("describeLine: a sha256 mismatch joins its three lines", () => {
128
+ assert.equal(
129
+ describeLine({ kind: "sha-mismatch", url: "u", expected: "e", got: "g" }),
130
+ "banal from u does not have the pinned sha256 — refusing to install it; expected e; got g",
131
+ );
132
+ });
@@ -103,6 +103,22 @@ test("installer: ensure reports the download, and Ready says where and what was
103
103
  assert.equal(i.check().ok, true);
104
104
  });
105
105
 
106
+ test("installer with no source: the pin is downloaded, and bytes that are not the pin are refused", () => {
107
+ const download = fixedDownload(BODY);
108
+ const io = memoryPorts({
109
+ run: scriptedProcess(() => exitedWith("")),
110
+ download,
111
+ });
112
+ const r = banalInstaller(io, parseBanalSettings({}, dirs)).ensure(() => {});
113
+ assert.ok(!r.ok);
114
+ assert.match(
115
+ r.error[0],
116
+ /does not have the pinned sha256 — refusing to install it/,
117
+ );
118
+ assert.equal(download.urls.length, 1);
119
+ assert.notEqual(download.urls[0], source.url);
120
+ });
121
+
106
122
  test("installer: a failure is lines of text, the first saying what happened", () => {
107
123
  const io = memoryPorts({
108
124
  run: scriptedProcess(() => ({ kind: "not-found", file: "perl" })),
@@ -60,6 +60,11 @@ const ROWS: readonly (readonly [
60
60
  exited({ stdout: "Usage: banal" }),
61
61
  { kind: "no-json", head: "Usage: banal" },
62
62
  ],
63
+ [
64
+ "nothing on stdout: the head is stderr's first line",
65
+ exited({ stdout: "", stderr: "\nCan't locate XML/Parser.pm\n" }),
66
+ { kind: "no-json", head: "Can't locate XML/Parser.pm" },
67
+ ],
63
68
  [
64
69
  // Guards: banal's own failure object exits 0 — it must not read as zero pages.
65
70
  'banal\'s `"error": true` with exit 0',
@@ -16,7 +16,8 @@ import { join } from "node:path";
16
16
  import { pathToFileURL } from "node:url";
17
17
  import { afterAll as after, test } from "vitest";
18
18
  import type { AbsolutePath } from "../../domain/paths.ts";
19
- import { curlDownload } from "./download.io.ts";
19
+ import { curlDownload, curlFailure } from "./download.io.ts";
20
+ import type { ProcessExit } from "../../ports/process.ts";
20
21
  import { spawnProcess } from "../node/index.ts";
21
22
 
22
23
  const root = realpathSync(
@@ -55,3 +56,44 @@ test("🔴 Download: no curl on PATH is named as that, not as a failed URL", ()
55
56
  // Guards: a missing curl is its own diagnosis — once, it read "spawnSync curl ENOENT".
56
57
  assert.deepEqual(!r.ok && r.error, { detail: "curl is not installed" });
57
58
  });
59
+
60
+ test("curlFailure: every way curl can end, in its own words", () => {
61
+ const cases: readonly [ProcessExit, string | null][] = [
62
+ [{ kind: "exited", status: 0, stdout: "", stderr: "" }, null],
63
+ [{ kind: "spawn-failed", message: "EACCES" }, "EACCES"],
64
+ [
65
+ { kind: "timed-out", afterMs: 40_000, stdout: "", stderr: "" },
66
+ "no answer after 40000 ms",
67
+ ],
68
+ [{ kind: "exited", status: 22, stdout: "", stderr: "" }, "curl exited 22"],
69
+ [
70
+ {
71
+ kind: "exited",
72
+ status: 22,
73
+ stdout: "",
74
+ stderr: "curl: (22) 404\nmore",
75
+ },
76
+ "curl: (22) 404",
77
+ ],
78
+ [
79
+ { kind: "signalled", signal: "SIGTERM", stdout: "", stderr: "" },
80
+ "SIGTERM",
81
+ ],
82
+ ];
83
+ for (const [exit, words] of cases) assert.equal(curlFailure(exit), words);
84
+ });
85
+
86
+ test("Download: curl exits 0 and writes nothing — a failure that says so, not empty bytes", () => {
87
+ const tmp = at("dl-silent");
88
+ mkdirSync(tmp, { recursive: true });
89
+ const d = curlDownload({
90
+ run: { run: () => ({ kind: "exited", status: 0, stdout: "", stderr: "" }) },
91
+ env: {},
92
+ tmpDir: tmp,
93
+ });
94
+ assert.deepEqual(d.fetch("https://example.test/banal", 1_000), {
95
+ ok: false,
96
+ error: { detail: "curl wrote no file" },
97
+ });
98
+ assert.deepEqual(readdirSync(tmp), []);
99
+ });
@@ -0,0 +1,25 @@
1
+ /** The in-memory test doubles keep the contracts of the ports they stand in for. */
2
+ import assert from "node:assert/strict";
3
+ import { test } from "vitest";
4
+ import { fixedDownload, memoryWorkspace } from "./index.ts";
5
+
6
+ test("memoryWorkspace: a `within` that throws is recorded as `threw`, and the error goes on", () => {
7
+ const ws = memoryWorkspace();
8
+ assert.throws(
9
+ () =>
10
+ ws.within("x-", (s) => {
11
+ s.write("f", "body", "read");
12
+ throw new Error("inside");
13
+ }),
14
+ /inside/,
15
+ );
16
+ assert.deepEqual(ws.ended, ["threw"]);
17
+ assert.deepEqual(ws.written, [{ name: "f", content: "body", mode: "read" }]);
18
+ });
19
+
20
+ test("fixedDownload: bytes are handed back as they are, and every URL is recorded", () => {
21
+ const bytes = new Uint8Array([1, 2, 3]);
22
+ const d = fixedDownload(bytes);
23
+ assert.deepEqual(d.fetch("https://a", 1), { ok: true, value: bytes });
24
+ assert.deepEqual(d.urls, ["https://a"]);
25
+ });
@@ -11,7 +11,8 @@ import { dirname } from "node:path";
11
11
  import type { AbsolutePath } from "../../domain/paths.ts";
12
12
  import type { Files } from "../../ports/files.ts";
13
13
 
14
- const code = (e: unknown): unknown =>
14
+ /** An error's errno code; anything thrown that is not an Error has none. */
15
+ export const errorCode = (e: unknown): unknown =>
15
16
  e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
16
17
 
17
18
  export const nodeFiles: Files = {
@@ -22,7 +23,7 @@ export const nodeFiles: Files = {
22
23
  try {
23
24
  return readFileSync(p);
24
25
  } catch (e) {
25
- if (code(e) === "ENOENT") return null;
26
+ if (errorCode(e) === "ENOENT") return null;
26
27
  throw e;
27
28
  }
28
29
  },