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
@@ -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,9 @@ import {
30
29
  normalizeDoi,
31
30
  normalizeArxiv,
32
31
  normalizeIdentifiers,
32
+ normalizeTitle,
33
+ titleIdentity,
34
+ isValidCveId,
33
35
  } from "./verify-cites.mjs";
34
36
 
35
37
  /** One vitest case per check, named by it. */
@@ -52,17 +54,17 @@ ok(
52
54
  "case/normalize identical titles",
53
55
  );
54
56
  ok(
55
- titlesMatch(
57
+ titleSimilarity(
56
58
  "Deep Residual Learning for Image Recognition",
57
59
  "Deep Residual Learning for Image Recognitionn",
58
- ),
60
+ ) >= 0.7,
59
61
  "one typo still ≥0.70",
60
62
  );
61
63
  ok(
62
- !titlesMatch(
64
+ titleSimilarity(
63
65
  "Attention Is All You Need",
64
66
  "A Survey of Reinforcement Learning",
65
- ),
67
+ ) < 0.7,
66
68
  "unrelated titles < 0.70",
67
69
  );
68
70
  ok(yearMatch(2020, 2021), "year ±1 ok");
@@ -733,3 +735,147 @@ console.log("\n[S4] .bib parser edge cases:");
733
735
  "bibitem DOI trailing period not swallowed",
734
736
  );
735
737
  }
738
+
739
+ // ── Edges of the pure layer: the defensive arms, each with the input that reaches it ──────────
740
+
741
+ test("levenshtein, normalizeTitle and titleIdentity take a missing string as empty", () => {
742
+ assert.deepEqual(
743
+ [
744
+ levenshtein(null, "ab"),
745
+ levenshtein("abc", undefined),
746
+ levenshtein("", ""),
747
+ normalizeTitle(undefined),
748
+ titleIdentity(undefined),
749
+ ],
750
+ [2, 3, 0, "", ""],
751
+ );
752
+ });
753
+
754
+ test("normalizeDoi / normalizeArxiv pass a missing value through untouched", () => {
755
+ assert.deepEqual(
756
+ [
757
+ normalizeDoi(undefined),
758
+ normalizeDoi(""),
759
+ normalizeArxiv(null),
760
+ normalizeArxiv(""),
761
+ ],
762
+ [undefined, "", null, ""],
763
+ );
764
+ });
765
+
766
+ test("titleRelation: an acronym of one letter is not an acronym; scripts are told apart", () => {
767
+ assert.deepEqual(
768
+ [
769
+ // "x" is one letter: it cannot spell the initials of anything.
770
+ titleRelation("x", "extensible markup language"),
771
+ titleRelation("Нейронные сети", "Neural networks"),
772
+ titleRelation("ニューラルネットワーク", "Neural networks"),
773
+ titleRelation("Νευρωνικά δίκτυα", "Neural networks"),
774
+ titleRelation("الشبكات العصبية", "Neural networks"),
775
+ titleRelation("!!!", "Neural networks"),
776
+ ],
777
+ [
778
+ "different",
779
+ "incomparable",
780
+ "incomparable",
781
+ "incomparable",
782
+ "incomparable",
783
+ "incomparable",
784
+ ],
785
+ );
786
+ });
787
+
788
+ test("yearMatch reads a year out of a string and ignores one that is not a year", () => {
789
+ assert.deepEqual(
790
+ [
791
+ yearMatch("2017-06", 2018),
792
+ yearMatch("2017", 2020),
793
+ yearMatch("n.d.", 2020),
794
+ ],
795
+ [true, false, true],
796
+ );
797
+ });
798
+
799
+ test("classifyResolver: a title search with no `records` field is a miss", () => {
800
+ assert.deepEqual(
801
+ classifyResolver(
802
+ { title: "A Paper" },
803
+ { db: "crossref", transport: "ok", query: "title" },
804
+ ),
805
+ { db: "crossref", status: "title_miss" },
806
+ );
807
+ });
808
+
809
+ test("checkNvd with no CVE on the citation says nothing; isValidCveId of nothing is false", () => {
810
+ assert.deepEqual(
811
+ [checkNvd({}, null), isValidCveId(undefined)],
812
+ [null, false],
813
+ );
814
+ });
815
+
816
+ test("reduceVerdict: an arXiv id missed only by a non-authoritative source is unresolvable", () => {
817
+ const r = reduceVerdict({ id: "a", arxiv: "1706.03762" }, [
818
+ {
819
+ db: "semantic_scholar",
820
+ status: "id_unmatched",
821
+ query: "arxiv",
822
+ authoritative: false,
823
+ },
824
+ ]);
825
+ assert.deepEqual(r, {
826
+ id: "a",
827
+ verdict: "unresolvable",
828
+ reason:
829
+ "arXiv id 1706.03762 missed only by non-authoritative sources (the reachable arXiv API did not definitively reject it) — cannot assert fabrication → unresolvable",
830
+ matched_db: null,
831
+ });
832
+ });
833
+
834
+ test("parseBib: @comment/@string/@preamble are skipped, a bare value is read, an empty one is absent", () => {
835
+ const bib = [
836
+ "@comment{c, not an entry}",
837
+ "@string{s, x = {y}}",
838
+ '@article{k1, title = "Quoted Title", year = 2017, eprint = {1706.03762}, archiveprefix = {arXiv}}',
839
+ "@misc{k2, title = {T}, year = ,}",
840
+ ].join("\n");
841
+ assert.deepEqual(parseBib(bib), [
842
+ { id: "k1", title: "Quoted Title", year: "2017", arxiv: "1706.03762" },
843
+ { id: "k2", title: "T" },
844
+ ]);
845
+ });
846
+
847
+ test("parseBib's \\bibitem fallback reads an arXiv id and an \\emph title", () => {
848
+ const text = [
849
+ "\\begin{thebibliography}{9}",
850
+ "\\bibitem{a} A. Author. \\emph{A Long Enough Title}. arXiv:1706.03762, 2017.",
851
+ "\\end{thebibliography}",
852
+ ].join("\n");
853
+ assert.deepEqual(parseBib(text), [
854
+ {
855
+ id: "a",
856
+ arxiv: "1706.03762",
857
+ year: "2017",
858
+ title: "A Long Enough Title",
859
+ },
860
+ ]);
861
+ });
862
+
863
+ test("parseArxivFeed: an entry without a title is skipped, one without a date has no year", () => {
864
+ assert.deepEqual(
865
+ parseArxivFeed("<entry><id>x</id></entry><entry><title>T</title></entry>"),
866
+ [{ title: "T", year: null }],
867
+ );
868
+ });
869
+
870
+ test("a two-letter token ending in s is depluralised to one letter, which spells no acronym", () => {
871
+ assert.equal(
872
+ titleRelation("TS models", "totally separate models"),
873
+ "different",
874
+ );
875
+ });
876
+
877
+ test("parseArxivFeed keeps an entry titled Error when it has no <id> to prove it is the sentinel", () => {
878
+ assert.deepEqual(parseArxivFeed("<entry><title>Error</title></entry>"), [
879
+ { title: "Error", year: null },
880
+ ]);
881
+ });
@@ -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
  },
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { afterAll as after, test } from "vitest";
7
7
  import type { AbsolutePath } from "../../domain/paths.ts";
8
- import { nodeFiles } from "./files.io.ts";
8
+ import { errorCode, nodeFiles } from "./files.io.ts";
9
9
 
10
10
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-files-test-")));
11
11
  after(() => rmSync(root, { recursive: true, force: true }));
@@ -26,3 +26,15 @@ test("Files: writeAtomic creates the directory and leaves no .part behind", () =
26
26
  );
27
27
  assert.equal(existsSync(`${p}.part`), false);
28
28
  });
29
+
30
+ test("Files: a read that fails for another reason than absence throws — a directory is not an empty file", () => {
31
+ assert.throws(() => nodeFiles.readBytes(at()), { code: "EISDIR" });
32
+ });
33
+
34
+ test("errorCode: an Error's errno code; a thrown non-Error has none", () => {
35
+ assert.equal(
36
+ errorCode(Object.assign(new Error("x"), { code: "ENOENT" })),
37
+ "ENOENT",
38
+ );
39
+ assert.equal(errorCode("ENOENT"), undefined);
40
+ });
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import assert from "node:assert/strict";
7
7
  import { test } from "vitest";
8
+ import type { AbsolutePath } from "../../domain/paths.ts";
8
9
  import type { Command } from "../../ports/process.ts";
9
10
  import { spawnProcess } from "./process.io.ts";
10
11
 
@@ -44,3 +45,40 @@ test("the environment is the Command's, whole: nothing leaks in from the parent"
44
45
  // Guards: `Command.env` is the child's entire environment — the adapter merges nothing.
45
46
  assert.deepEqual(r.kind === "exited" && r.stdout, "[][1]\n");
46
47
  });
48
+
49
+ test("a program killed by a signal is `signalled`, with what it printed first", () => {
50
+ assert.deepEqual(run.run(sh("echo before; kill -TERM $$")), {
51
+ kind: "signalled",
52
+ signal: "SIGTERM",
53
+ stdout: "before\n",
54
+ stderr: "",
55
+ });
56
+ });
57
+
58
+ test("output past maxOutputBytes is a spawn failure, named by Node's own message", () => {
59
+ const r = run.run({ ...sh("head -c 100000 /dev/zero"), maxOutputBytes: 10 });
60
+ assert.equal(r.kind, "spawn-failed");
61
+ assert.match(r.kind === "spawn-failed" ? r.message : "", /ENOBUFS/);
62
+ });
63
+
64
+ test("cwd is the Command's when it names one", () => {
65
+ const r = run.run({ ...sh("pwd"), cwd: "/" as AbsolutePath });
66
+ assert.deepEqual(r.kind === "exited" && r.stdout, "/\n");
67
+ });
68
+
69
+ test("a spawn that reports no status, no signal and no error reads as exit 1, not success", () => {
70
+ const silent = spawnProcess((() => ({
71
+ pid: 0,
72
+ output: [],
73
+ stdout: "",
74
+ stderr: "",
75
+ status: null,
76
+ signal: null,
77
+ })) as never);
78
+ assert.deepEqual(silent.run(sh("true")), {
79
+ kind: "exited",
80
+ status: 1,
81
+ stdout: "",
82
+ stderr: "",
83
+ });
84
+ });
@@ -109,3 +109,37 @@ check(
109
109
  "fillsOf: an empty item is `unknown`",
110
110
  L.fillsOf(OPS, ops(), [" "])[0]?.kind === "unknown",
111
111
  );
112
+
113
+ // ── argument shapes pdf.js versions differ on ────────────────────────────────────────────
114
+ test("isUpright on a short transform; hexOf on a non-array and on non-numbers", () => {
115
+ assert.deepEqual(
116
+ [L.isUpright([]), L.hexOf(null), L.hexOf([255.4, "x", undefined])],
117
+ [true, "#000000", "#ff0000"],
118
+ );
119
+ });
120
+
121
+ test("the walk reads odd show-text arguments as no characters, and odd state operators as defaults", () => {
122
+ const drawn = L.drawnCharacters(
123
+ OPS,
124
+ ops(
125
+ [OPS.restore], // a restore with nothing saved: the default state
126
+ [OPS.setTextRenderingMode], // a mode with no argument: 0
127
+ [99], // an operator the walk does not read
128
+ [OPS.showText, "not an array"],
129
+ [OPS.showText, ["not glyphs"]],
130
+ [OPS.showText, [[7, null, { unicode: undefined }, { unicode: "A" }]]],
131
+ ),
132
+ );
133
+ assert.deepEqual(drawn, [{ ch: "A", fill: { kind: "unknown" } }]);
134
+ });
135
+
136
+ test("an item that matches only up to the end of the drawn text is not found", () => {
137
+ const list = ops(
138
+ [OPS.setFillRGBColor, ["#112233"]],
139
+ [OPS.showText, glyphs("ab")],
140
+ );
141
+ assert.deepEqual(L.fillsOf(OPS, list, ["bc", "b"]), [
142
+ { kind: "unknown" },
143
+ { kind: "rgb", hex: "#112233" },
144
+ ]);
145
+ });
@@ -81,40 +81,45 @@ function applyState(
81
81
  return true;
82
82
  }
83
83
 
84
+ /** One drawn character and the fill it was drawn in. */
85
+ interface Drawn {
86
+ readonly ch: string;
87
+ readonly fill: Fill;
88
+ }
89
+
84
90
  /** Every drawn character, in content order, with the fill it was drawn in. */
85
- export function drawnCharacters(
86
- ops: ColourOps,
87
- list: OperatorList,
88
- ): { chars: string[]; fills: Fill[] } {
91
+ export function drawnCharacters(ops: ColourOps, list: OperatorList): Drawn[] {
89
92
  const s = { cur: { fill: { kind: "unknown" } as Fill, mode: 0 }, stack: [] };
90
- const chars: string[] = [];
91
- const fills: Fill[] = [];
93
+ const drawn: Drawn[] = [];
92
94
  list.fnArray.forEach((fn, i) => {
93
95
  const args = list.argsArray[i];
94
96
  if (applyState(ops, fn, args, s)) return;
95
97
  if (fn !== ops.showText && fn !== ops.showSpacedText) return;
96
98
  const fill: Fill = s.cur.mode === 3 ? { kind: "invisible" } : s.cur.fill;
97
- for (const ch of squeeze(glyphText(args))) {
98
- chars.push(ch);
99
- fills.push(fill);
100
- }
99
+ for (const ch of squeeze(glyphText(args))) drawn.push({ ch, fill });
101
100
  });
102
- return { chars, fills };
101
+ return drawn;
103
102
  }
104
103
 
105
104
  /** How far ahead the walk looks for an item it cannot find where it expects it. */
106
105
  export const LOOKAHEAD = 4000;
107
106
 
108
- /** The position at or after `from` where `want` starts in `chars`, or -1 within the look-ahead. */
107
+ /**
108
+ * Where `want` starts in `drawn`, at or after `from` within the look-ahead, and the fill it was
109
+ * drawn in — or null. The fill comes from the matched element itself, not a second lookup.
110
+ */
109
111
  function findRun(
110
- chars: readonly string[],
112
+ drawn: readonly Drawn[],
111
113
  want: readonly string[],
112
114
  from: number,
113
- ): number {
114
- const limit = Math.min(chars.length, from + LOOKAHEAD);
115
- for (let q = from; q < limit; q++)
116
- if (want.every((ch, k) => chars[q + k] === ch)) return q;
117
- return -1;
115
+ ): { readonly at: number; readonly fill: Fill } | null {
116
+ const window = drawn.slice(from, from + LOOKAHEAD);
117
+ for (const [i, d] of window.entries()) {
118
+ const at = from + i;
119
+ if (want.every((ch, k) => drawn[at + k]?.ch === ch))
120
+ return { at, fill: d.fill };
121
+ }
122
+ return null;
118
123
  }
119
124
 
120
125
  /**
@@ -128,14 +133,28 @@ export function fillsOf(
128
133
  list: OperatorList,
129
134
  items: readonly string[],
130
135
  ): Fill[] {
131
- const { chars, fills } = drawnCharacters(ops, list);
136
+ return fillsFor(ops, list, items, (text) => text).map((p) => p.fill);
137
+ }
138
+
139
+ /**
140
+ * Each item paired with its fill — `fillsOf` for items that are not bare strings, so a caller never
141
+ * matches the two lists up by index.
142
+ */
143
+ export function fillsFor<T>(
144
+ ops: ColourOps,
145
+ list: OperatorList,
146
+ items: readonly T[],
147
+ textOf: (item: T) => string,
148
+ ): { readonly item: T; readonly fill: Fill }[] {
149
+ const drawn = drawnCharacters(ops, list);
132
150
  let at = 0;
133
- return items.map((text): Fill => {
151
+ const fillOf = (text: string): Fill => {
134
152
  const want = [...squeeze(text)];
135
153
  if (want.length === 0) return { kind: "unknown" };
136
- const q = findRun(chars, want, at);
137
- if (q < 0) return { kind: "unknown" };
138
- at = q + want.length;
139
- return fills[q] ?? { kind: "unknown" };
140
- });
154
+ const hit = findRun(drawn, want, at);
155
+ if (hit === null) return { kind: "unknown" };
156
+ at = hit.at + want.length;
157
+ return hit.fill;
158
+ };
159
+ return items.map((item) => ({ item, fill: fillOf(textOf(item)) }));
141
160
  }
@@ -0,0 +1,8 @@
1
+ /** The clock, for the lookup cache: the day an answer was fetched, in UTC. */
2
+ export const todayUtc = (): string => new Date().toISOString().slice(0, 10);
3
+
4
+ /** Wait `ms` — DBLP's pace between requests. */
5
+ export const sleep = (ms: number): Promise<void> =>
6
+ new Promise((r) => {
7
+ setTimeout(r, ms);
8
+ });