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
@@ -2,17 +2,39 @@
2
2
  * The online reference checks, as the `CheckReferences` port: `verify-cites` (the cited work
3
3
  * exists, the title matches — Crossref, OpenAlex, Semantic Scholar, arXiv) and `bib-authors` (the
4
4
  * authors are the published version's — DBLP). Both ship in this package's verify-citations skill;
5
- * this adapter only runs them and shapes their answers.
5
+ * this adapter only runs them, shapes their answers, and serves them from the paper's lookup cache.
6
+ *
7
+ * 🔴 THE CACHE HOLDS ANSWERS, AND ONLY THE CHECKERS' OWN SEAMS TOUCH IT. verify-cites takes a
8
+ * `cache` object keyed per resolver × identifier and stores only successful responses; it is
9
+ * handed the paper's cached responses and whatever it adds is new. bib-authors takes a `lookup`
10
+ * (title → DBLP hits) and a `pause`; the lookup is wrapped to answer from the cache, and the pause
11
+ * is skipped after a cached answer — DBLP's 900 ms pace and its retries run only for titles never
12
+ * answered. Neither checker's logic changes. Verdicts are derived from the answers on every build
13
+ * (`domain/lookup-cache.ts` says why).
6
14
  *
7
15
  * "Not checked" is decided up front, by one request: when Crossref cannot be reached at all, the
8
16
  * checkers would degrade every entry to `unresolvable` / `unchecked`, which reads like a result.
9
- * Saying `not-checked` with the reason is the honest record.
17
+ * Saying `not-checked` with the reason is the honest record. That request is skipped when every
18
+ * question this bibliography would ask is already answered in the cache: a warm build is offline.
10
19
  */
11
20
  import type {
12
21
  CheckReferences,
13
22
  EntryVerdict,
14
23
  } from "../../ports/check-references.ts";
24
+ import {
25
+ freshPart,
26
+ type CachedDblp,
27
+ type CachedResponse,
28
+ type DblpHit,
29
+ type LookupCache,
30
+ } from "../../domain/lookup-cache.ts";
31
+ import { sha256Hex } from "../../domain/sha256.ts";
32
+ import { mapLimit } from "./pool.ts";
15
33
  import { unreachable } from "./reach.io.ts";
34
+ import { sleep, todayUtc } from "./clock.io.ts";
35
+
36
+ /** Citations looked up at once (#107). */
37
+ const LOOKUPS_IN_FLIGHT = 6;
16
38
  // @ts-expect-error — a skill script in .mjs, it has no types
17
39
  import * as cites from "../../../skills/verify-citations/scripts/verify-cites.mjs";
18
40
  // @ts-expect-error — a skill script in .mjs, it has no types
@@ -22,6 +44,8 @@ interface CiteResult {
22
44
  readonly id: string;
23
45
  readonly verdict: "true" | "false" | "unresolvable";
24
46
  readonly reason?: string;
47
+ /** "<service>: <reason>" for each question a refusing service did not answer (#120). */
48
+ readonly refused?: readonly string[];
25
49
  }
26
50
  interface AuthorFinding {
27
51
  readonly key: string;
@@ -60,6 +84,18 @@ const authorsOf = (key: string, a: AuthorBuckets): EntryVerdict["authors"] =>
60
84
  ? "match"
61
85
  : "skipped";
62
86
 
87
+ /**
88
+ * Why a work was not confirmed: verify-cites' reason, then — when some registry refused — which
89
+ * one, so "unresolvable" is not read as "every registry looked and found nothing". Nothing for a
90
+ * confirmed work: a refusal after a confirmation changed nothing.
91
+ */
92
+ const unconfirmed = (
93
+ c: CiteResult | undefined,
94
+ ): readonly (string | undefined)[] =>
95
+ c && c.verdict !== "true"
96
+ ? [c.reason, ...(c.refused ?? []).map((r) => `not asked: ${r}`)]
97
+ : [];
98
+
63
99
  /** One entry's verdict from the two checkers' answers. */
64
100
  function entryVerdict(
65
101
  key: string,
@@ -69,7 +105,7 @@ function entryVerdict(
69
105
  const c = found.find((x) => x.id === key);
70
106
  const mismatch = a.findings.find((f) => f.key === key);
71
107
  const why = [
72
- c && c.verdict !== "true" ? c.reason : undefined,
108
+ ...unconfirmed(c),
73
109
  mismatch ? describeAuthors(mismatch) : undefined,
74
110
  a.unchecked.find((u) => u.key === key)?.why,
75
111
  ]
@@ -83,20 +119,168 @@ function entryVerdict(
83
119
  };
84
120
  }
85
121
 
86
- export const onlineReferences: CheckReferences = async (bib) => {
87
- const why = await unreachable();
88
- if (why !== null) return { kind: "not-checked", why };
89
- const cache = {};
90
- const found: CiteResult[] = [];
91
- for (const c of cites.parseBib(bib) as { id?: string }[])
92
- if (c.id) found.push(await cites.verifyCitationLive(c, { cache }));
93
- const a = (await authors.checkAuthors(
94
- authors.parseBib(bib),
95
- )) as AuthorBuckets;
96
- const keys = new Set([
97
- ...found.map((c) => c.id),
98
- ...a.findings.map((f) => f.key),
122
+ interface BibAuthorsEntry {
123
+ readonly key: string;
124
+ readonly title: string;
125
+ readonly author: string;
126
+ }
127
+
128
+ /** Whether bib-authors will ask DBLP about this entry — the filter its `checkAuthors` applies. */
129
+ const asksDblp = (e: BibAuthorsEntry): boolean =>
130
+ Boolean(e.title && e.author) &&
131
+ (authors.claimsPublished(e) as boolean) &&
132
+ !(authors.truncated(e.author) as boolean);
133
+
134
+ /** Is every question this bibliography would ask already answered in `cache`? */
135
+ async function fullyCached(
136
+ citations: readonly object[],
137
+ entries: readonly BibAuthorsEntry[],
138
+ cache: LookupCache,
139
+ store: Readonly<Record<string, unknown>>,
140
+ ): Promise<boolean> {
141
+ const asks = await Promise.all(
142
+ citations.map((c) => cites.wouldAsk(c, store) as Promise<boolean>),
143
+ );
144
+ return (
145
+ !asks.includes(true) &&
146
+ entries.filter(asksDblp).every((e) => cache.dblp.has(dblpTitleKey(e.title)))
147
+ );
148
+ }
149
+
150
+ /**
151
+ * The key a DBLP answer is stored under in the lookup cache: the title's identity (verify-cites'
152
+ * `titleIdentity` — the same rule its own title keys use, so the two caches cannot drift), hashed.
153
+ * A re-cased or re-braced title keeps its answer; `C` and `C++` do not share one.
154
+ */
155
+ export const dblpTitleKey = (title: string): string =>
156
+ sha256Hex(new TextEncoder().encode(cites.titleIdentity(title))).slice(0, 16);
157
+
158
+ export interface ReferencesCheckerOptions {
159
+ /** The day a new answer is stamped with, YYYY-MM-DD. */
160
+ readonly today: () => string;
161
+ }
162
+
163
+ /**
164
+ * bib-authors' `lookup` and `pause`, answering from the cached DBLP hits first.
165
+ *
166
+ * 🔴 DBLP IS ASKED UNTIL IT REFUSES ONCE, THEN NOT AT ALL FOR THE REST OF THE RUN (#120). A 429, an
167
+ * HTML page where JSON was asked, a timeout: bib-authors would retry each title three times with
168
+ * backoff, and move on to the next title to do it again — 51 titles × 3 × up to 15 s. After the
169
+ * first refusal every lookup fails at once with the reason, and no pause is waited, so each entry
170
+ * lands in bib-authors' `unchecked` bucket ("never a pass") for the price of ONE request.
171
+ *
172
+ * The 900 ms pace protects DBLP, so it is kept only after an answer DBLP actually gave: not after a
173
+ * cached answer, and not once DBLP is off.
174
+ */
175
+ function cachedDblp(cache: LookupCache, today: () => string) {
176
+ const dblp = new Map<string, CachedDblp>(cache.dblp);
177
+ let lastWasCached = false;
178
+ let refused: string | null = null;
179
+ return {
180
+ dblp,
181
+ lookup: async (title: string): Promise<readonly DblpHit[]> => {
182
+ const hit = dblp.get(dblpTitleKey(title));
183
+ lastWasCached = hit !== undefined;
184
+ if (hit) return hit.hits;
185
+ if (refused !== null)
186
+ throw new Error(`DBLP refused earlier in this run (${refused})`);
187
+ try {
188
+ const hits = (await authors.dblpHits(title)) as DblpHit[];
189
+ dblp.set(dblpTitleKey(title), { fetched: today(), title, hits });
190
+ return hits;
191
+ } catch (e) {
192
+ refused = (e as Error).message;
193
+ throw new Error(`DBLP refused earlier in this run (${refused})`);
194
+ }
195
+ },
196
+ pause: (ms: number): Promise<void> =>
197
+ lastWasCached || refused !== null ? Promise.resolve() : sleep(ms),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * The cache after a run: the old answers with every new one laid over them, dated today — or the
203
+ * very object passed in when nothing was fetched, so the caller can tell there is nothing to write.
204
+ * "New" is measured against `usable`, the part the run answered from: an answer too old to use was
205
+ * asked again, and its fresh copy replaces the old one. An old answer the run could not refresh
206
+ * (the service refused) stays, and is asked again next build.
207
+ */
208
+ function grown(
209
+ loaded: { readonly cache: LookupCache; readonly usable: LookupCache },
210
+ run: {
211
+ readonly store: Readonly<Record<string, unknown>>;
212
+ readonly dblp: ReadonlyMap<string, CachedDblp>;
213
+ },
214
+ today: () => string,
215
+ ): LookupCache {
216
+ const { cache, usable } = loaded;
217
+ const { store, dblp } = run;
218
+ const fresh = Object.entries(store).filter(([k]) => !usable.citations.has(k));
219
+ const freshDblp = [...dblp].filter(([k]) => !usable.dblp.has(k));
220
+ if (fresh.length === 0 && freshDblp.length === 0) return cache;
221
+ const dated = fresh.map(([k, response]): [string, CachedResponse] => [
222
+ k,
223
+ { fetched: today(), response: response as CachedResponse["response"] },
99
224
  ]);
100
- const entries = [...keys].map((key) => entryVerdict(key, found, a));
101
- return { kind: "checked", entries };
102
- };
225
+ return {
226
+ citations: new Map([...cache.citations, ...dated]),
227
+ dblp: new Map([...cache.dblp, ...freshDblp]),
228
+ };
229
+ }
230
+
231
+ /** The checker, with its clock injected; `onlineReferences` is the one the CLI wires. */
232
+ export const referencesChecker =
233
+ ({ today }: ReferencesCheckerOptions): CheckReferences =>
234
+ async (bib, cache) => {
235
+ // Answers past MAX_AGE_DAYS are left out, so the run asks them again (and `grown` re-dates them).
236
+ const usable = freshPart(cache, today());
237
+ const citations = (cites.parseBib(bib) as { id?: string }[]).filter(
238
+ (c) => c.id,
239
+ );
240
+ const parsed = authors.parseBib(bib) as BibAuthorsEntry[];
241
+ const store: Record<string, unknown> = Object.fromEntries(
242
+ [...usable.citations].map(([k, v]) => [k, v.response]),
243
+ );
244
+ if (!(await fullyCached(citations, parsed, usable, store))) {
245
+ const why = await unreachable();
246
+ if (why !== null) return { check: { kind: "not-checked", why }, cache };
247
+ }
248
+ // 🔴 #107: the lookups run LOOKUPS_IN_FLIGHT at a time, and the DBLP author pass runs beside
249
+ // them. Each citation's own requests go to four different services one after another, so a
250
+ // pool of 6 is at most 6 requests to any one of them. The store is shared: two citations with
251
+ // the same identifier may both miss it and ask twice — an extra request, never a different
252
+ // answer.
253
+ // One breaker for the run: a service that refuses is not asked again, not once per citation
254
+ // (#120). Requests already in flight when it starts refusing still land — at most six.
255
+ const breaker = cites.createBreaker() as unknown;
256
+ const d = cachedDblp(usable, today);
257
+ const [found, a] = await Promise.all([
258
+ mapLimit(
259
+ citations,
260
+ LOOKUPS_IN_FLIGHT,
261
+ (c) =>
262
+ cites.verifyCitationLive(c, {
263
+ cache: store,
264
+ breaker,
265
+ }) as Promise<CiteResult>,
266
+ ),
267
+ authors.checkAuthors(parsed, {
268
+ lookup: d.lookup,
269
+ pause: d.pause,
270
+ }) as Promise<AuthorBuckets>,
271
+ ]);
272
+ const keys = new Set([
273
+ ...found.map((c) => c.id),
274
+ ...a.findings.map((f) => f.key),
275
+ ]);
276
+ const entries = [...keys].map((key) => entryVerdict(key, found, a));
277
+ return {
278
+ check: { kind: "checked", entries },
279
+ cache: grown({ cache, usable }, { store, dblp: d.dblp }, today),
280
+ };
281
+ };
282
+
283
+ /** The checker the CLI wires: the real clock. */
284
+ export const onlineReferences: CheckReferences = referencesChecker({
285
+ today: todayUtc,
286
+ });
@@ -0,0 +1,31 @@
1
+ /** mapLimit: results in input order, never more than `limit` in flight, an empty list at once. */
2
+ import assert from "node:assert/strict";
3
+ import { test } from "vitest";
4
+ import { mapLimit } from "./pool.ts";
5
+
6
+ test("results keep the input order although they finish out of order", async () => {
7
+ const out = await mapLimit([30, 5, 20, 1], 2, async (ms) => {
8
+ await new Promise((r) => {
9
+ setTimeout(r, ms);
10
+ });
11
+ return ms * 10;
12
+ });
13
+ assert.deepEqual(out, [300, 50, 200, 10]);
14
+ });
15
+
16
+ test("never more than `limit` calls in flight; fewer items than the limit start fewer workers", async () => {
17
+ let now = 0;
18
+ let peak = 0;
19
+ const track = async () => {
20
+ peak = Math.max(peak, ++now);
21
+ await new Promise((r) => {
22
+ setTimeout(r, 2);
23
+ });
24
+ now--;
25
+ };
26
+ await mapLimit(Array.from({ length: 10 }), 3, track);
27
+ const three = peak;
28
+ peak = 0;
29
+ await mapLimit([1, 2], 6, track);
30
+ assert.deepEqual([three, peak, await mapLimit([], 6, track)], [3, 2, []]);
31
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `fn` over `items` with at most `limit` calls in flight, results in `items`' order — a promise
3
+ * pool, the one piece of concurrency this adapter needs, so it is written here rather than
4
+ * depended on. A rejection rejects the whole map, as `Promise.all` would: callers that must not
5
+ * lose the other results catch inside `fn`.
6
+ */
7
+ export async function mapLimit<T, R>(
8
+ items: readonly T[],
9
+ limit: number,
10
+ fn: (item: T) => Promise<R>,
11
+ ): Promise<R[]> {
12
+ const out: R[] = new Array<R>(items.length);
13
+ let next = 0;
14
+ const worker = async (): Promise<void> => {
15
+ while (next < items.length) {
16
+ const i = next++;
17
+ out[i] = await fn(items[i] as T);
18
+ }
19
+ };
20
+ await Promise.all(
21
+ Array.from({ length: Math.min(limit, items.length) }, worker),
22
+ );
23
+ return out;
24
+ }
@@ -8,7 +8,6 @@
8
8
  * 2. prepareEngine: use the cache · use the system · refuse without a human · ask, decline,
9
9
  * accept and install · dry-run never asks or installs.
10
10
  */
11
- import assert from "node:assert/strict";
12
11
  import {
13
12
  chmodSync,
14
13
  mkdirSync,
@@ -20,15 +19,12 @@ import {
20
19
  import { tmpdir } from "node:os";
21
20
  import { basename, delimiter, dirname, join } from "node:path";
22
21
  import { fileURLToPath } from "node:url";
22
+ import { createChecker } from "../lib/check.mjs";
23
23
 
24
24
  const HERE = dirname(fileURLToPath(import.meta.url));
25
25
  const E = await import(join(HERE, "build-engine.ts"));
26
26
 
27
- let n = 0;
28
- const check = (label, cond, detail = "") => {
29
- assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
30
- n++;
31
- };
27
+ const check = createChecker();
32
28
 
33
29
  // ── 1. texts ────────────────────────────────────────────────────────────────────────────
34
30
  const noCache = {
@@ -311,4 +307,4 @@ const x_run = { found: {} };
311
307
  }
312
308
 
313
309
  rmSync(work, { recursive: true, force: true });
314
- console.log(`build-engine: ${n} checks passed`);
310
+ console.log(`build-engine: ${check.count} checks passed`);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * build-engine.ts arms the harness does not reach: a system pdflatex that lacks packages (said
3
+ * before asking), an install that fails, an install that leaves packages missing, a cache used from
4
+ * an environment with no PATH, and the refusal line for a decision that is neither refuse nor ask.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { test } from "vitest";
10
+ import { useTempDir } from "../test/support.mjs";
11
+ import { prepareEngine, refusal } from "./build-engine.ts";
12
+
13
+ const work = useTempDir("paperlint-build-engine-test-");
14
+ const TEX = { packages: { acmart: ["acmart.cls"] }, tools: {} };
15
+ /** A runnable pdflatex in `dir`. */
16
+ const texAt = (dir: string): string => {
17
+ mkdirSync(dir, { recursive: true });
18
+ writeFileSync(join(dir, "pdflatex"), "");
19
+ chmodSync(join(dir, "pdflatex"), 0o755);
20
+ return dir;
21
+ };
22
+ /** kpsewhich answers per bin directory. */
23
+ const fakeRun = (found: Record<string, string>) =>
24
+ ((cmd: string) => ({
25
+ stdout: found[dirname(cmd)] ?? "",
26
+ status: 0,
27
+ })) as never;
28
+
29
+ async function go(o: Parameters<typeof prepareEngine>[0]) {
30
+ const out: string[] = [];
31
+ const err: string[] = [];
32
+ const r = await prepareEngine({
33
+ log: (l) => out.push(l),
34
+ err: (l) => err.push(l),
35
+ platform: "linux",
36
+ interactive: true,
37
+ ask: async () => "y",
38
+ ...o,
39
+ });
40
+ return { r, out, err };
41
+ }
42
+
43
+ test("a system pdflatex lacking packages is named before asking; a failed install stops the run", async () => {
44
+ const sys = texAt(join(work, "sys", "bin"));
45
+ const x = await go({
46
+ tex: TEX,
47
+ env: { PAPERLINT_TEXLIVE_DIR: join(work, "empty-cache"), PATH: sys },
48
+ run: fakeRun({}),
49
+ install: () => ({ ok: false }),
50
+ });
51
+ assert.deepEqual(
52
+ [x.r, x.out],
53
+ [
54
+ { ok: false, code: 1 },
55
+ [`${join(sys, "pdflatex")} is not used: it lacks acmart`],
56
+ ],
57
+ );
58
+ });
59
+
60
+ test("an install that verifies but still lacks a package is refused, naming it", async () => {
61
+ const cache = join(work, "cache-lacking");
62
+ const x = await go({
63
+ tex: TEX,
64
+ env: { PAPERLINT_TEXLIVE_DIR: cache, PATH: join(work, "nothing") },
65
+ run: fakeRun({}),
66
+ install: () => {
67
+ texAt(join(cache, "2026", "bin", "x86_64-linux"));
68
+ return { ok: true, tree: { dir: "", year: "2026", bin: "" } };
69
+ },
70
+ });
71
+ assert.deepEqual(x.r, { ok: false, code: 1 });
72
+ assert.match(x.err.join("\n"), /missing: 1 package\(s\): acmart/);
73
+ });
74
+
75
+ test("an install that leaves no tree at all is refused with nothing named missing", async () => {
76
+ const x = await go({
77
+ tex: TEX,
78
+ env: {
79
+ PAPERLINT_TEXLIVE_DIR: join(work, "cache-none"),
80
+ PATH: join(work, "nothing"),
81
+ },
82
+ run: fakeRun({}),
83
+ install: () => ({ ok: true, tree: { dir: "", year: "2026", bin: "" } }),
84
+ });
85
+ assert.deepEqual(x.r, { ok: false, code: 1 });
86
+ assert.match(x.err.join("\n"), /missing: 0 package\(s\): \)/);
87
+ });
88
+
89
+ test("a complete cache is used from an environment with no PATH at all", async () => {
90
+ const bin = texAt(join(work, "cache-ok", "2026", "bin", "x86_64-linux"));
91
+ const x = await go({
92
+ tex: TEX,
93
+ env: { PAPERLINT_TEXLIVE_DIR: join(work, "cache-ok") },
94
+ run: fakeRun({ [bin]: "/t/acmart.cls\n" }),
95
+ });
96
+ assert.deepEqual(x.r, {
97
+ ok: true,
98
+ env: { PAPERLINT_TEXLIVE_DIR: join(work, "cache-ok"), PATH: `${bin}:` },
99
+ });
100
+ });
101
+
102
+ test("the refusal line for a decision to use a tree names nothing missing", () => {
103
+ assert.equal(
104
+ refusal({ kind: "use-cache" } as never, { system: null } as never),
105
+ "✗ paperlint build: no TeX Live with every package these papers need — run `npx paperlint toolchain` (missing: 0 package(s): )",
106
+ );
107
+ });
108
+
109
+ test("without an ask or an install given: Enter is yes, and the default install runs (here: every mirror fails)", async () => {
110
+ const cache = join(work, "cache-default");
111
+ const calls: string[] = [];
112
+ const out: string[] = [];
113
+ const err: string[] = [];
114
+ const r = await prepareEngine({
115
+ tex: TEX,
116
+ log: (l) => out.push(l),
117
+ err: (l) => err.push(l),
118
+ platform: "linux",
119
+ interactive: true,
120
+ env: {
121
+ PAPERLINT_TEXLIVE_DIR: cache,
122
+ PAPERLINT_CTAN_MIRROR: "https://mirror.invalid/tlnet",
123
+ PATH: join(work, "nothing"),
124
+ },
125
+ run: ((cmd: string) => {
126
+ calls.push(cmd.split("/").pop() ?? cmd);
127
+ return {
128
+ status: 22,
129
+ stdout: "",
130
+ stderr: "curl: (6) could not resolve\n",
131
+ };
132
+ }) as never,
133
+ });
134
+ assert.deepEqual(r, { ok: false, code: 1 });
135
+ assert.ok(calls.includes("curl"), calls.join(","));
136
+ assert.match(err.join("\n"), /no CTAN mirror returned install-tl/);
137
+ });
@@ -10,7 +10,6 @@
10
10
  * `build.sh` is never executed (checked by the trace it would leave on disk and by the list of
11
11
  * processes started), and a failed build removes the stale `paper.pdf` (checked on disk).
12
12
  */
13
- import assert from "node:assert/strict";
14
13
  import {
15
14
  mkdtempSync,
16
15
  mkdirSync,
@@ -23,6 +22,7 @@ import {
23
22
  import { tmpdir } from "node:os";
24
23
  import { join, dirname, delimiter } from "node:path";
25
24
  import { fileURLToPath } from "node:url";
25
+ import { createChecker } from "../lib/check.mjs";
26
26
 
27
27
  const HERE = dirname(fileURLToPath(import.meta.url));
28
28
  const {
@@ -44,11 +44,7 @@ const { packageVenuesDir } = await import(
44
44
  join(HERE, "..", "skills", "paper-pipeline", "scripts", "consumer.mjs")
45
45
  );
46
46
 
47
- let n = 0;
48
- const check = (label, cond) => {
49
- assert.ok(cond, label);
50
- n++;
51
- };
47
+ const check = createChecker();
52
48
 
53
49
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-build-")));
54
50
  // 🔴 banal is looked for under the temp root, never in the developer's cache: with the real
@@ -511,8 +507,8 @@ try {
511
507
  check("on full success there is no remedy", remedyFor([r]) === "");
512
508
 
513
509
  // ── 🔴 A STALE PDF NEVER SURVIVES A RUN THAT DID NOT REPLACE IT — every path of buildPapers ──
514
- // The order matters for the battery: the up-front removal is killed by the no-engine case, the
515
- // existence check by the empty document, and neither case depends on the other defence.
510
+ // The order matters here: the up-front removal is exercised by the no-engine case below, the
511
+ // existence check by the empty-document case, and neither depends on the other defence.
516
512
  const REMOVED =
517
513
  "paper.pdf removed — a stale PDF must not pass for this build";
518
514
 
@@ -680,5 +676,5 @@ try {
680
676
  }
681
677
 
682
678
  console.log(
683
- `✓ ${String(n)} assertions passed — build: paperlint compiles, build.sh never runs, a red build leaves no PDF`,
679
+ `✓ ${String(check.count)} assertions passed — build: paperlint compiles, build.sh never runs, a red build leaves no PDF`,
684
680
  );