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
@@ -6,9 +6,9 @@
6
6
  * on 2026-08-29 (the midpoint cut, the review build, years mistaken for line numbers), ported from
7
7
  * the harness of the function this replaces.
8
8
  */
9
- import assert from "node:assert/strict";
10
9
  import { dirname, join } from "node:path";
11
10
  import { fileURLToPath } from "node:url";
11
+ import { createChecker } from "../lib/check.mjs";
12
12
 
13
13
  const HERE = dirname(fileURLToPath(import.meta.url));
14
14
  const {
@@ -21,11 +21,7 @@ const {
21
21
  STUB_WORDS,
22
22
  } = await import(join(HERE, "pdf-geometry.ts"));
23
23
 
24
- let n = 0;
25
- const check = (label, cond) => {
26
- assert.ok(cond, label);
27
- n++;
28
- };
24
+ const check = createChecker();
29
25
 
30
26
  const W = 612; // US letter, points
31
27
 
@@ -250,5 +246,5 @@ check(
250
246
  );
251
247
 
252
248
  console.log(
253
- `✓ ${String(n)} assertions passed — pdf-geometry: columns, stub/review, words from runs, fonts`,
249
+ `✓ ${String(check.count)} assertions passed — pdf-geometry: columns, stub/review, words from runs, fonts`,
254
250
  );
@@ -136,7 +136,7 @@ export function wordsOf(run: TextRun): Word[] {
136
136
  const y0 = run.baseline - run.ascent * run.size;
137
137
  const y1 = run.baseline - run.descent * run.size;
138
138
  return [...run.text.matchAll(/\S+/g)].map((m) => {
139
- const x0 = run.x + (m.index ?? 0) * perChar;
139
+ const x0 = run.x + m.index * perChar;
140
140
  return { x0, y0, x1: x0 + m[0].length * perChar, y1, text: m[0] };
141
141
  });
142
142
  }
@@ -3,6 +3,7 @@
3
3
  * Crossref answer. The adapter (`adapters/references/`) runs the package's two checkers; a test
4
4
  * passes a function.
5
5
  */
6
+ import type { LookupCache } from "../domain/lookup-cache.ts";
6
7
 
7
8
  /** What one bibliography entry came to. */
8
9
  export interface EntryVerdict {
@@ -24,5 +25,18 @@ export type ReferencesCheck =
24
25
  /** Nothing could be asked — no network, every service down. Never a pass. */
25
26
  | { readonly kind: "not-checked"; readonly why: string };
26
27
 
27
- /** The bibliography's text (BibTeX) → the verdicts. Never throws: a failure is `not-checked`. */
28
- export type CheckReferences = (bib: string) => Promise<ReferencesCheck>;
28
+ /** A check's verdicts, and the lookup cache grown by whatever it had to fetch. */
29
+ export interface ReferencesRun {
30
+ readonly check: ReferencesCheck;
31
+ /** The cache passed in, plus every new successful answer; the same object when nothing was fetched. */
32
+ readonly cache: LookupCache;
33
+ }
34
+
35
+ /**
36
+ * The bibliography's text (BibTeX) and the paper's lookup cache → the verdicts, derived afresh from
37
+ * cached and newly fetched answers. Never throws: a failure is `not-checked`.
38
+ */
39
+ export type CheckReferences = (
40
+ bib: string,
41
+ cache: LookupCache,
42
+ ) => Promise<ReferencesRun>;
@@ -230,6 +230,8 @@ describe("labelOf — display only, never used to resolve", () => {
230
230
  ["paperlint:agenticdev", "agenticdev"],
231
231
  ["./venues/usenix-sec.jsonc", "usenix-sec"],
232
232
  ["../shared/acl.json", "acl"],
233
+ ["./.hidden", ".hidden"],
234
+ ["paperlint:", ""],
233
235
  ])("%s → %s", (spec, label) => {
234
236
  expect(labelOf(spec)).toBe(label);
235
237
  });
package/src/presets.ts CHANGED
@@ -293,5 +293,11 @@ export function paperPresetProblem(
293
293
  if (p.kind === "preset-problem")
294
294
  return `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`;
295
295
  if (p.kind !== "settings-problem") return null;
296
- return `${join(paperDir, CONFIG_FILE)}: ${p.problem.why}`;
296
+ return settingsProblemLine(paperDir, p.problem);
297
297
  }
298
+
299
+ /** The one line naming a paper's `paperlint.json` that does not parse, and why. */
300
+ export const settingsProblemLine = (
301
+ paperDir: string,
302
+ problem: SettingsProblem,
303
+ ): string => `${join(paperDir, CONFIG_FILE)}: ${problem.why}`;
@@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest";
11
11
  import { ESLint } from "eslint";
12
12
  import { join } from "node:path";
13
13
  import { memoryFiles } from "./adapters/memory/index.ts";
14
+ import { ok } from "./domain/result.ts";
14
15
  import type { AbsolutePath } from "./domain/paths.ts";
15
16
  import type {
16
17
  CheckReferences,
@@ -20,9 +21,16 @@ import { referencesStep } from "./build.ts";
20
21
  import {
21
22
  bibHash,
22
23
  bibliographyOf,
24
+ lookupCachePath,
23
25
  readReferences,
24
26
  referencesPath,
25
27
  } from "./references.ts";
28
+ import {
29
+ parseLookupCache,
30
+ serializeLookupCache,
31
+ type CachedResponse,
32
+ type LookupCache,
33
+ } from "./domain/lookup-cache.ts";
26
34
  import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
27
35
  // @ts-expect-error — an ESLint language in .mjs, it has no types
28
36
  import { texLanguage } from "../eslint-rules/latex-language.mjs";
@@ -55,22 +63,30 @@ const verdict = (
55
63
 
56
64
  const checker =
57
65
  (entries: readonly EntryVerdict[]): CheckReferences =>
58
- async () => ({ kind: "checked", entries });
59
- const offline: CheckReferences = async () => ({
60
- kind: "not-checked",
61
- why: "the citation services cannot be reached (fetch failed)",
66
+ async (_bib, cache) => ({ check: { kind: "checked", entries }, cache });
67
+ const offline: CheckReferences = async (_bib, cache) => ({
68
+ check: {
69
+ kind: "not-checked",
70
+ why: "the citation services cannot be reached (fetch failed)",
71
+ },
72
+ cache,
73
+ });
74
+
75
+ /** The step's context over `files`, with every port it does not use stubbed. */
76
+ const ctx = (files: ReturnType<typeof memoryFiles>) => ({
77
+ paperDir: PAPER,
78
+ env: {},
79
+ run: (() => ({})) as never,
80
+ readPdf: (() => null) as never,
81
+ measure: (() => null) as never,
82
+ files,
62
83
  });
63
84
 
64
85
  /** Run the build step on a paper held in memory; returns the files and the step's outcome. */
65
86
  async function build(tex: string, check: CheckReferences) {
66
87
  const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
67
88
  const out = await referencesStep.run({
68
- paperDir: PAPER,
69
- env: {},
70
- run: (() => ({})) as never,
71
- readPdf: (() => null) as never,
72
- measure: (() => null) as never,
73
- files,
89
+ ...ctx(files),
74
90
  checkReferences: check,
75
91
  });
76
92
  return { files, out };
@@ -144,6 +160,108 @@ describe("the references build step", () => {
144
160
  });
145
161
  });
146
162
 
163
+ const ANSWER: CachedResponse = {
164
+ fetched: "2026-09-27",
165
+ response: { db: "crossref", transport: "ok", query: "doi" },
166
+ };
167
+ /** A checker that fetches `key` unless the cache has it, recording the cache it was handed. */
168
+ function fetching(key: string) {
169
+ const seen: LookupCache[] = [];
170
+ const check: CheckReferences = async (_bib, cache) => {
171
+ seen.push(cache);
172
+ return {
173
+ check: { kind: "checked", entries: [verdict("schick2023")] },
174
+ cache: cache.citations.has(key)
175
+ ? cache
176
+ : {
177
+ citations: new Map([...cache.citations, [key, ANSWER]]),
178
+ dblp: cache.dblp,
179
+ },
180
+ };
181
+ };
182
+ return { check, seen };
183
+ }
184
+ const cacheText = (files: ReturnType<typeof memoryFiles>): string | null => {
185
+ const b = files.readBytes(lookupCachePath(PAPER) as AbsolutePath);
186
+ return b === null ? null : new TextDecoder().decode(b);
187
+ };
188
+
189
+ describe("the lookup cache — <paper>/repro/references-cache.json", () => {
190
+ it("a build that fetched writes the cache; the next build is handed it and writes nothing", async () => {
191
+ const f = fetching("crossref:doi:10.1/x");
192
+ const { files, out } = await build(TEX(ENTRIES), f.check);
193
+ expect(out).toEqual({
194
+ ok: true,
195
+ note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/references-cache.json",
196
+ });
197
+ const written = cacheText(files);
198
+ expect(written).toBe(
199
+ serializeLookupCache({
200
+ citations: new Map([["crossref:doi:10.1/x", ANSWER]]),
201
+ dblp: new Map(),
202
+ }),
203
+ );
204
+ const again = await referencesStep.run({
205
+ ...ctx(files),
206
+ checkReferences: f.check,
207
+ });
208
+ expect(again).toEqual({
209
+ ok: true,
210
+ note: "references: 1 checked, 0 failing → _build/references.json; nothing fetched",
211
+ });
212
+ // The second build was handed exactly what the first one wrote.
213
+ expect(parseLookupCache(written!)).toEqual(ok(f.seen[1]));
214
+ expect(cacheText(files)).toBe(written);
215
+ });
216
+ });
217
+
218
+ describe("the lookup cache — counting and refusing", () => {
219
+ it("two new answers are counted as two", async () => {
220
+ const two: CheckReferences = async (_bib, cache) => ({
221
+ check: { kind: "checked", entries: [verdict("schick2023")] },
222
+ cache: {
223
+ citations: new Map([
224
+ ["a", ANSWER],
225
+ ["b", ANSWER],
226
+ ]),
227
+ dblp: cache.dblp,
228
+ },
229
+ });
230
+ const { out } = await build(TEX(ENTRIES), two);
231
+ expect(out).toEqual({
232
+ ok: true,
233
+ note: "references: 1 checked, 0 failing → _build/references.json; 2 new answers → repro/references-cache.json",
234
+ });
235
+ });
236
+
237
+ it("no answer fetched and no cache on disk: no cache file is created", async () => {
238
+ const { files } = await build(
239
+ TEX(ENTRIES),
240
+ checker([verdict("schick2023")]),
241
+ );
242
+ expect(cacheText(files)).toBeNull();
243
+ });
244
+
245
+ it("🔴 a cache that does not parse is refused BY NAME and left alone — the check is not run over half a cache", async () => {
246
+ const files = memoryFiles({
247
+ [`${PAPER}/paper.tex`]: TEX(ENTRIES),
248
+ [lookupCachePath(PAPER)]: '{"schema": 7}',
249
+ });
250
+ const f = fetching("k");
251
+ const out = await referencesStep.run({
252
+ ...ctx(files),
253
+ checkReferences: f.check,
254
+ });
255
+ expect(out).toEqual({
256
+ ok: true,
257
+ note: "references NOT checked — repro/references-cache.json cannot be read (schema 7: this paperlint reads schema 1) — fix it, or delete it to ask every question again; lint will say so",
258
+ });
259
+ expect(f.seen).toEqual([]);
260
+ expect(cacheText(files)).toBe('{"schema": 7}');
261
+ expect(readReferences(files, PAPER)?.status).toBe("not-checked");
262
+ });
263
+ });
264
+
147
265
  describe("the reference rules", () => {
148
266
  it("one mismatched entry → paper/author-list on that entry's own line", async () => {
149
267
  const tex = TEX(ENTRIES);
@@ -207,3 +325,132 @@ describe("the reference rules", () => {
207
325
  expect(referencesPath(PAPER)).toBe(`${PAPER}/_build/references.json`);
208
326
  });
209
327
  });
328
+
329
+ describe("reading what is on disk", () => {
330
+ it("no paper.tex: the bibliography is refs.bib, from its first byte", () => {
331
+ const files = memoryFiles({ [`${PAPER}/refs.bib`]: ENTRIES });
332
+ expect(bibliographyOf(files, PAPER)).toEqual({
333
+ source: "refs.bib",
334
+ text: ENTRIES,
335
+ offset: 0,
336
+ });
337
+ });
338
+
339
+ it("a references.json of another schema, without entries, or not JSON reads as none", () => {
340
+ for (const body of [
341
+ JSON.stringify({ schema: 999, entries: [] }),
342
+ JSON.stringify({ schema: 1 }),
343
+ "null",
344
+ "{",
345
+ ]) {
346
+ const files = memoryFiles({ [referencesPath(PAPER)]: body });
347
+ expect(readReferences(files, PAPER)).toBeNull();
348
+ }
349
+ });
350
+ });
351
+
352
+ /** A record for `bib`, as the build would write it, with `patch` applied to the document. */
353
+ const record = (
354
+ files: ReturnType<typeof memoryFiles>,
355
+ body: Record<string, unknown>,
356
+ ) => {
357
+ const bib = bibliographyOf(files, PAPER)!;
358
+ files.writeAtomic(
359
+ referencesPath(PAPER) as AbsolutePath,
360
+ new TextEncoder().encode(
361
+ JSON.stringify({
362
+ schema: 1,
363
+ bib: { source: bib.source, sha256: bibHash(bib) },
364
+ ...body,
365
+ }),
366
+ ),
367
+ );
368
+ };
369
+
370
+ describe("the reference rules over a record written by hand", () => {
371
+ it("not-checked with no reason recorded says so", async () => {
372
+ const tex = TEX(ENTRIES);
373
+ const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
374
+ record(files, { status: "not-checked", entries: [] });
375
+ const msgs = await lint(files, tex);
376
+ expect(msgs.map((m) => m.ruleId)).toEqual(["paper/refs-checked"]);
377
+ expect(msgs[0]!.message).toMatch(/no reason recorded/);
378
+ });
379
+
380
+ it("a verdict without a reason, and one for a key the bibliography lacks, still report", async () => {
381
+ const tex = TEX(ENTRIES);
382
+ const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
383
+ record(files, {
384
+ status: "checked",
385
+ entries: [
386
+ { key: "schick2023", exists: "true", authors: "mismatch" },
387
+ { key: "ghost", exists: "false", authors: "match" },
388
+ ],
389
+ });
390
+ const msgs = await lint(files, tex);
391
+ expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
392
+ ["paper/cite-exists", 1],
393
+ ["paper/author-list", 3],
394
+ ]);
395
+ });
396
+ });
397
+
398
+ describe("the reference rules over refs.bib, and on other files", () => {
399
+ it("an external refs.bib: findings sit at the start of paper.tex, naming the key", async () => {
400
+ const tex = "\\documentclass{acmart}\n\\begin{document}x\\end{document}\n";
401
+ const files = memoryFiles({
402
+ [`${PAPER}/paper.tex`]: tex,
403
+ [`${PAPER}/refs.bib`]: ENTRIES,
404
+ });
405
+ record(files, {
406
+ status: "checked",
407
+ entries: [verdict("schick2023", "mismatch")],
408
+ });
409
+ const msgs = await lint(files, tex);
410
+ expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
411
+ ["paper/author-list", 1],
412
+ ]);
413
+ expect(msgs[0]!.message).toMatch(/schick2023/);
414
+ });
415
+
416
+ it("only paper.tex is judged", () => {
417
+ const files = memoryFiles({});
418
+ const rules = referenceRules({ files });
419
+ for (const rule of Object.values(rules))
420
+ expect(rule.create({ filename: `${PAPER}/notes.tex` } as never)).toEqual(
421
+ {},
422
+ );
423
+ });
424
+ });
425
+
426
+ describe("the lookup cache — refreshed answers", () => {
427
+ // Guards: an answer asked again after MAX_AGE_DAYS keeps its key, so the key count does not change.
428
+ // Counted by size, the refreshed answer was not written, and every later build asked it again.
429
+ it("an answer refreshed under the same key is counted and written", async () => {
430
+ const OLD: CachedResponse = { ...ANSWER, fetched: "2026-08-01" };
431
+ const put =
432
+ (answer: CachedResponse): CheckReferences =>
433
+ async (_bib, cache) => ({
434
+ check: { kind: "checked", entries: [verdict("schick2023")] },
435
+ cache: {
436
+ citations: new Map([...cache.citations, ["a", answer]]),
437
+ dblp: cache.dblp,
438
+ },
439
+ });
440
+ const { files } = await build(TEX(ENTRIES), put(OLD));
441
+ const again = await referencesStep.run({
442
+ ...ctx(files),
443
+ checkReferences: put(ANSWER),
444
+ });
445
+ expect({ again, file: cacheText(files) }).toEqual({
446
+ again: {
447
+ ok: true,
448
+ note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/references-cache.json",
449
+ },
450
+ file: serializeLookupCache({
451
+ citations: new Map([["a", ANSWER]]),
452
+ dblp: new Map(),
453
+ }),
454
+ });
455
+ });
456
+ });
package/src/references.ts CHANGED
@@ -17,6 +17,12 @@
17
17
  * stale when it changes (`pdf_sha256`); these are about the bibliography and are stale when IT
18
18
  * changes (`bib.sha256`). One staleness key per file, the way `pdf/fresh` already works.
19
19
  *
20
+ * 🔴 WHAT THE CHECKERS ANSWERED IS KEPT, COMMITTED, IN `<paper>/repro/references-cache.json`
21
+ * (`domain/lookup-cache.ts`). Loaded before the check and saved after it — only when something new
22
+ * was fetched, a refreshed answer included — so a build asks each question at most once every
23
+ * MAX_AGE_DAYS, not once per build (#107: 217 s for 27 references, every build). The verdicts above are NOT cached: they are derived from the cached
24
+ * answers on every build, so a fix to the checkers reaches every paper at once.
25
+ *
20
26
  * 🔴 THE STEP NEVER FAILS THE BUILD. A build without network still builds the PDF; the reference
21
27
  * step records `not-checked` and the lint rule says so, as a warning. Recording "not checked" as
22
28
  * a pass would be the counter that counts what it never looked at.
@@ -25,10 +31,18 @@ import { join } from "node:path";
25
31
  import type { AbsolutePath } from "./domain/paths.ts";
26
32
  import { sha256Hex } from "./domain/sha256.ts";
27
33
  import type { Files } from "./ports/files.ts";
34
+ import {
35
+ EMPTY_LOOKUP_CACHE,
36
+ parseLookupCache,
37
+ serializeLookupCache,
38
+ type LookupCache,
39
+ } from "./domain/lookup-cache.ts";
40
+ import { ok, type Result } from "./domain/result.ts";
28
41
  import type {
29
42
  CheckReferences,
30
43
  EntryVerdict,
31
44
  ReferencesCheck,
45
+ ReferencesRun,
32
46
  } from "./ports/check-references.ts";
33
47
  // @ts-expect-error — an ESLint rule module in .mjs, it has no types
34
48
  import { bibRange } from "../eslint-rules/paper-typography.mjs";
@@ -36,6 +50,12 @@ import { bibRange } from "../eslint-rules/paper-typography.mjs";
36
50
  export const REFERENCES_SCHEMA = 1;
37
51
  export const REFERENCES_FILE = "references.json";
38
52
 
53
+ /** The lookup cache's place in a paper: committed, beside the paper's other reproduction files. */
54
+ export const LOOKUP_CACHE_FILE = "repro/references-cache.json";
55
+
56
+ export const lookupCachePath = (paperDir: string): string =>
57
+ join(paperDir, LOOKUP_CACHE_FILE);
58
+
39
59
  /** Where a paper's reference verdicts live — beside `paper.facts.json`. */
40
60
  export const referencesPath = (paperDir: string): string =>
41
61
  join(paperDir, "_build", REFERENCES_FILE);
@@ -106,10 +126,48 @@ export function readReferences(
106
126
  }
107
127
  }
108
128
 
129
+ /** The paper's lookup cache: empty when there is none, or why the file on disk cannot be read. */
130
+ export function readLookupCache(
131
+ files: Files,
132
+ paperDir: string,
133
+ ): Result<LookupCache, string> {
134
+ const raw = text(files, lookupCachePath(paperDir));
135
+ return raw === null ? ok(EMPTY_LOOKUP_CACHE) : parseLookupCache(raw);
136
+ }
137
+
109
138
  /**
110
- * The build step's work: check the bibliography and record the verdicts. Returns the one-line
111
- * note the build prints. A checker that throws is recorded as `not-checked` — the step reports,
112
- * it never refuses.
139
+ * How many answers the run fetched: entries of `after` that are not the very object `before` held
140
+ * under that key. The checker keeps every answer it did not fetch as the same object, so this counts
141
+ * both new keys and answers asked again past MAX_AGE_DAYS under an existing key — which a count of
142
+ * keys would miss, leaving the refreshed answer unwritten and asked again on every build.
143
+ */
144
+ const newAnswers = (before: LookupCache, after: LookupCache): number => {
145
+ const changed = <V>(b: ReadonlyMap<string, V>, a: ReadonlyMap<string, V>) =>
146
+ [...a].filter(([k, v]) => b.get(k) !== v).length;
147
+ return (
148
+ changed(before.citations, after.citations) +
149
+ changed(before.dblp, after.dblp)
150
+ );
151
+ };
152
+
153
+ /** Run the checker; a throw is `not-checked`, with the cache as it was. */
154
+ async function run(
155
+ check: CheckReferences,
156
+ bib: string,
157
+ cache: LookupCache,
158
+ ): Promise<ReferencesRun> {
159
+ try {
160
+ return await check(bib, cache);
161
+ } catch (e) {
162
+ return { check: { kind: "not-checked", why: (e as Error).message }, cache };
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The build step's work: check the bibliography, serving what it can from the paper's lookup
168
+ * cache, and record the verdicts. Returns the one-line note the build prints. A checker that
169
+ * throws, or a cache that does not parse, is recorded as `not-checked` — the step reports, it
170
+ * never refuses. A cache that does not parse is left as it is, and named.
113
171
  */
114
172
  export async function recordReferences(
115
173
  files: Files,
@@ -118,21 +176,39 @@ export async function recordReferences(
118
176
  ): Promise<string> {
119
177
  const bib = bibliographyOf(files, paperDir);
120
178
  if (bib === null) return "no bibliography — nothing to check";
121
- let result: ReferencesCheck;
122
- try {
123
- result = await check(bib.text);
124
- } catch (e) {
125
- result = { kind: "not-checked", why: (e as Error).message };
126
- }
127
- const doc = documentOf(bib, result);
128
- files.writeAtomic(
129
- at(referencesPath(paperDir)),
130
- new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`),
131
- );
132
- if (doc.status === "not-checked")
133
- return `references NOT checked — ${doc.why ?? "no reason given"}; lint will say so`;
179
+ const record = (c: ReferencesCheck): ReferencesDocument => {
180
+ const doc = documentOf(bib, c);
181
+ files.writeAtomic(
182
+ at(referencesPath(paperDir)),
183
+ new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`),
184
+ );
185
+ return doc;
186
+ };
187
+ const notChecked = (why: string): string => {
188
+ record({ kind: "not-checked", why });
189
+ return `references NOT checked — ${why}; lint will say so`;
190
+ };
191
+ const cache = readLookupCache(files, paperDir);
192
+ if (!cache.ok)
193
+ return notChecked(
194
+ `${LOOKUP_CACHE_FILE} cannot be read (${cache.error}) — fix it, or delete it to ask every question again`,
195
+ );
196
+ const result = await run(check, bib.text, cache.value);
197
+ if (result.check.kind === "not-checked") return notChecked(result.check.why);
198
+ const doc = record(result.check);
199
+ const fetched = newAnswers(cache.value, result.cache);
200
+ if (fetched > 0)
201
+ files.writeAtomic(
202
+ at(lookupCachePath(paperDir)),
203
+ new TextEncoder().encode(serializeLookupCache(result.cache)),
204
+ );
134
205
  const bad = doc.entries.filter(
135
206
  (e) => e.exists === "false" || e.authors === "mismatch",
136
207
  ).length;
137
- return `references: ${String(doc.entries.length)} checked, ${String(bad)} failing → _build/${REFERENCES_FILE}`;
208
+ return (
209
+ `references: ${String(doc.entries.length)} checked, ${String(bad)} failing → _build/${REFERENCES_FILE}; ` +
210
+ (fetched > 0
211
+ ? `${String(fetched)} new answer${fetched === 1 ? "" : "s"} → ${LOOKUP_CACHE_FILE}`
212
+ : "nothing fetched")
213
+ );
138
214
  }
@@ -3,20 +3,16 @@
3
3
  * paperlint ships. The end-to-end path (a project's paperlint.json → `paperlint lint`) is in `cli.harness.mjs`;
4
4
  * this pins the parser's output shape and each refusal.
5
5
  */
6
- import assert from "node:assert/strict";
7
6
  import { dirname, join } from "node:path";
8
7
  import { fileURLToPath } from "node:url";
8
+ import { createChecker } from "../lib/check.mjs";
9
9
 
10
10
  const HERE = dirname(fileURLToPath(import.meta.url));
11
11
  const { parseRuleBlocks, shippedRuleIds, unknownKeys } = await import(
12
12
  join(HERE, "rules-config.ts")
13
13
  );
14
14
 
15
- let n = 0;
16
- const check = (label, cond, detail = "") => {
17
- assert.ok(cond, detail ? `${label} — ${detail}` : label);
18
- n++;
19
- };
15
+ const check = createChecker();
20
16
 
21
17
  const SHIPPED = new Set(["pdf/last-page-balance", "paper/typography"]);
22
18
  const parse = (raw) => parseRuleBlocks(raw, "S", SHIPPED, "/consumer");
@@ -112,7 +108,13 @@ check(
112
108
  markdown: foreign,
113
109
  },
114
110
  },
115
- { plugins: { paper: { rules: { typography: {} } } } },
111
+ // A plugin that carries only a language (the `tex` plugin does) has no rules to ship.
112
+ {
113
+ plugins: {
114
+ paper: { rules: { typography: {} } },
115
+ tex: { languages: {} },
116
+ },
117
+ },
116
118
  { files: ["x"] },
117
119
  ],
118
120
  [foreign],
@@ -126,5 +128,5 @@ check(
126
128
  }
127
129
 
128
130
  console.log(
129
- `✓ ${String(n)} assertions passed — rules-config: blocks parsed with basePath, every refusal names its key`,
131
+ `✓ ${String(check.count)} assertions passed — rules-config: blocks parsed with basePath, every refusal names its key`,
130
132
  );
@@ -6,7 +6,6 @@
6
6
  * So every "found" here has a paired "not found on the neighboring directory", and the defaults
7
7
  * are separately checked against the shape of the live corpus.
8
8
  */
9
- import assert from "node:assert/strict";
10
9
  import {
11
10
  mkdtempSync,
12
11
  mkdirSync,
@@ -17,16 +16,13 @@ import {
17
16
  import { tmpdir } from "node:os";
18
17
  import { join, dirname } from "node:path";
19
18
  import { fileURLToPath } from "node:url";
19
+ import { createChecker } from "../lib/check.mjs";
20
20
 
21
21
  const HERE = dirname(fileURLToPath(import.meta.url));
22
22
  const { checkStructure, formatStructure, asEslintResults, STRUCTURE_DEFAULTS } =
23
23
  await import(join(HERE, "structure.ts"));
24
24
 
25
- let n = 0;
26
- const check = (label, cond) => {
27
- assert.ok(cond, label);
28
- n++;
29
- };
25
+ const check = createChecker();
30
26
 
31
27
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-struct-")));
32
28
  const papers = join(root, "papers");
@@ -177,5 +173,5 @@ try {
177
173
  }
178
174
 
179
175
  console.log(
180
- `✓ ${String(n)} assertions passed — structure: a missing file cannot complain for itself`,
176
+ `✓ ${String(check.count)} assertions passed — structure: a missing file cannot complain for itself`,
181
177
  );