paperlint 3.1.1 → 3.1.3

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 (52) hide show
  1. package/CLAUDE.md +18 -97
  2. package/CONTRIBUTING.md +7 -21
  3. package/README.md +11 -7
  4. package/dist/adapters/references/clock.io.d.ts +5 -0
  5. package/dist/adapters/references/clock.io.d.ts.map +1 -0
  6. package/dist/adapters/references/clock.io.js +7 -0
  7. package/dist/adapters/references/clock.io.js.map +1 -0
  8. package/dist/adapters/references/index.d.ts +24 -2
  9. package/dist/adapters/references/index.d.ts.map +1 -1
  10. package/dist/adapters/references/index.js +125 -15
  11. package/dist/adapters/references/index.js.map +1 -1
  12. package/dist/build.d.ts +4 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +10 -3
  15. package/dist/build.js.map +1 -1
  16. package/dist/domain/lookup-cache.d.ts +75 -0
  17. package/dist/domain/lookup-cache.d.ts.map +1 -0
  18. package/dist/domain/lookup-cache.js +137 -0
  19. package/dist/domain/lookup-cache.js.map +1 -0
  20. package/dist/ports/check-references.d.ts +12 -2
  21. package/dist/ports/check-references.d.ts.map +1 -1
  22. package/dist/ports/check-references.js +0 -5
  23. package/dist/ports/check-references.js.map +1 -1
  24. package/dist/references.d.ts +11 -3
  25. package/dist/references.d.ts.map +1 -1
  26. package/dist/references.js +63 -15
  27. package/dist/references.js.map +1 -1
  28. package/docs/prior-art/README.md +11 -10
  29. package/docs/prior-art/citation-checkers.md +174 -0
  30. package/docs/references.md +126 -0
  31. package/docs/testing.md +118 -0
  32. package/docs/toolchain.md +6 -4
  33. package/package.json +1 -1
  34. package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
  35. package/skills/render-paper/checkers-requirements.txt +30 -19
  36. package/skills/render-paper/ensure-checkers.sh +20 -4
  37. package/skills/verify-citations/SKILL.md +26 -10
  38. package/skills/verify-citations/SKILL.md.spec.ts +25 -9
  39. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +28 -10
  40. package/skills/verify-citations/scripts/bib-authors.mjs +5 -6
  41. package/skills/verify-citations/scripts/verify-cites.mjs +296 -141
  42. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +356 -20
  43. package/skills/verify-citations/scripts/verify-cites.test.mjs +18 -2
  44. package/src/adapters/references/clock.io.ts +8 -0
  45. package/src/adapters/references/index.test.ts +341 -9
  46. package/src/adapters/references/index.ts +198 -29
  47. package/src/build.ts +10 -3
  48. package/src/domain/lookup-cache.test.ts +178 -0
  49. package/src/domain/lookup-cache.ts +202 -0
  50. package/src/ports/check-references.ts +16 -2
  51. package/src/references.test.ts +160 -10
  52. package/src/references.ts +93 -17
@@ -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>;
@@ -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);
@@ -304,3 +422,35 @@ describe("the reference rules over refs.bib, and on other files", () => {
304
422
  );
305
423
  });
306
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 (result.kind === "not-checked")
133
- return `references NOT checked — ${result.why}; 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
  }