paperlint 3.1.1 → 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 (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 +285 -140
  42. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +321 -20
  43. package/skills/verify-citations/scripts/verify-cites.test.mjs +4 -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
@@ -6,7 +6,11 @@
6
6
  */
7
7
  import assert from "node:assert/strict";
8
8
  import { afterEach, expect, test, vi } from "vitest";
9
- import { verifyCitationLive } from "./verify-cites.mjs";
9
+ import {
10
+ createBreaker,
11
+ verifyCitationLive,
12
+ wouldAsk,
13
+ } from "./verify-cites.mjs";
10
14
 
11
15
  const json = (status, body) =>
12
16
  new Response(body === undefined ? null : JSON.stringify(body), { status });
@@ -18,7 +22,7 @@ const text = (status, body) => new Response(body, { status });
18
22
  */
19
23
  function fakeFetch(routes) {
20
24
  const calls = [];
21
- vi.stubGlobal("fetch", async (url) => {
25
+ vi.stubGlobal("fetch", async (url, init) => {
22
26
  calls.push(url);
23
27
  const hit = Object.keys(routes)
24
28
  .filter((p) => url.startsWith(p))
@@ -26,7 +30,7 @@ function fakeFetch(routes) {
26
30
  if (hit === undefined) throw new Error(`unrouted request: ${url}`);
27
31
  const r = routes[hit];
28
32
  if (r instanceof Error) throw r;
29
- return r();
33
+ return r(init?.signal);
30
34
  });
31
35
  return calls;
32
36
  }
@@ -90,8 +94,10 @@ test("records with fields missing: no title, a subtitle, a year only in `publish
90
94
  const calls = fakeFetch({
91
95
  [CR]: () =>
92
96
  json(200, {
97
+ // No title and a subtitle that is not the claimed title's: no match, so the lookups go on
98
+ // (a match would end them) and every registry's missing fields are read.
93
99
  message: {
94
- subtitle: ["All You Need"],
100
+ subtitle: ["Unrelated Words About Gardening"],
95
101
  published: { "date-parts": [[2017]] },
96
102
  },
97
103
  }),
@@ -130,19 +136,22 @@ test("an arXiv id nobody has: the arXiv API's empty feed and S2's 404", async ()
130
136
  });
131
137
 
132
138
  test("a title only: every registry's search endpoint, hits and all", async () => {
139
+ // The first three registries' hits carry the title with a year too far off to match, so the
140
+ // lookups go on (a match would end them) and every search endpoint's answer is read; arXiv's
141
+ // hit is the one that matches.
133
142
  const calls = fakeFetch({
134
143
  [`${CR}?`]: () =>
135
144
  json(200, {
136
145
  message: {
137
- items: [{ title: [TITLE], issued: { "date-parts": [[2017]] } }, {}],
146
+ items: [{ title: [TITLE], issued: { "date-parts": [[1990]] } }, {}],
138
147
  },
139
148
  }),
140
149
  [`${OA}?`]: () =>
141
150
  json(200, {
142
- results: [{ display_name: TITLE, publication_year: 2017 }, {}],
151
+ results: [{ display_name: TITLE, publication_year: 1990 }, {}],
143
152
  }),
144
153
  [`${S2}/search`]: () =>
145
- json(200, { data: [{ title: TITLE, year: 2017 }, {}] }),
154
+ json(200, { data: [{ title: TITLE, year: 1990 }, {}] }),
146
155
  [AX]: () => text(200, feed(TITLE, 2017)),
147
156
  });
148
157
  const result = await verifyCitationLive({
@@ -205,8 +214,9 @@ test("CVEs: found by count, found by list, not found, NVD down, and a malformed
205
214
 
206
215
  test("a second lookup is served from the cache; failures and a nonexistent DOI are not cached", async () => {
207
216
  const cache = {};
217
+ // No registry confirms the DOI, so every lookup runs: a match would end them (tested below).
208
218
  fakeFetch({
209
- [CR]: () => json(200, { message: { title: [TITLE] } }),
219
+ [CR]: () => json(404),
210
220
  [OA]: () => json(503),
211
221
  [S2]: () => json(404),
212
222
  [DOI]: () => json(200, { responseCode: 1 }),
@@ -233,6 +243,295 @@ test("a second lookup is served from the cache; failures and a nonexistent DOI a
233
243
  );
234
244
  });
235
245
 
246
+ // Guards: each resolver's cache key names the identifier that resolver ASKED. arXiv is asked by
247
+ // arXiv id even when the entry has a DOI; keyed by the DOI, a corrected arXiv id kept the old
248
+ // id's answer and was never asked.
249
+ test("a corrected arXiv id is asked again even when the DOI is unchanged", async () => {
250
+ const cache = {};
251
+ const routes = {
252
+ [CR]: () => json(404),
253
+ [OA]: () => json(404),
254
+ [S2]: () => json(404),
255
+ [AX]: () => text(200, "<feed></feed>"),
256
+ [DOI]: () => json(200, { responseCode: 1 }),
257
+ };
258
+ fakeFetch(routes);
259
+ await verifyCitationLive(
260
+ { id: "c", doi: "10.1/x", arxiv: "2401.00001", title: TITLE },
261
+ { cache },
262
+ );
263
+ const again = fakeFetch(routes);
264
+ await verifyCitationLive(
265
+ { id: "c", doi: "10.1/x", arxiv: "2401.00002", title: TITLE },
266
+ { cache },
267
+ );
268
+ assert.deepEqual(
269
+ again.filter((u) => u.startsWith(AX)),
270
+ [`${AX}?id_list=2401.00002&max_results=1`],
271
+ );
272
+ });
273
+
274
+ // Guards: NVD's "not found" must not be cached. A CVE cited before NVD indexes it would otherwise
275
+ // stay "fabricated" in the committed cache forever; only positive existence is kept, as for doi.org.
276
+ test("an NVD not-found answer is not cached, so a later build asks again", async () => {
277
+ const cache = {};
278
+ fakeFetch({ [NVD]: () => json(200, { totalResults: 0 }) });
279
+ await verifyCitationLive({ id: "c", cve: "CVE-2026-0001" }, { cache });
280
+ const again = fakeFetch({ [NVD]: () => json(200, { totalResults: 1 }) });
281
+ await verifyCitationLive({ id: "c", cve: "CVE-2026-0001" }, { cache });
282
+ assert.deepEqual(
283
+ { asked: again.length, keys: Object.keys(cache) },
284
+ { asked: 1, keys: ["nvd:CVE-2026-0001"] },
285
+ );
286
+ });
287
+
288
+ // Guards: a title-only cache key must keep punctuation that names a different work. A shared key
289
+ // for `C` and `C++` would answer the second from the first's registry responses, no request made.
290
+ test("title-only keys keep punctuation: a C++ title is not served the C title's cached answers", async () => {
291
+ const cache = {};
292
+ const miss = () =>
293
+ json(200, { message: { items: [] }, results: [], data: [] });
294
+ fakeFetch({
295
+ [CR]: miss,
296
+ [OA]: miss,
297
+ [S2]: miss,
298
+ [AX]: () => text(200, "<feed></feed>"),
299
+ });
300
+ await verifyCitationLive(
301
+ { id: "a", title: "Fuzzing C Compilers" },
302
+ { cache },
303
+ );
304
+ const asked = fakeFetch({
305
+ [CR]: miss,
306
+ [OA]: miss,
307
+ [S2]: miss,
308
+ [AX]: () => text(200, "<feed></feed>"),
309
+ });
310
+ await verifyCitationLive(
311
+ { id: "b", title: "Fuzzing C++ Compilers" },
312
+ { cache },
313
+ );
314
+ expect(asked.length).toBeGreaterThan(0);
315
+ });
316
+
317
+ // Guards: `paperlint build` skips its reachability probe when no citation `wouldAsk`. If it said
318
+ // "no" for a citation a live run asks about, a "fully cached" paper would go online anyway; if it
319
+ // said "yes" for one a live run answers from the cache, every warm build would probe the network.
320
+ test("wouldAsk: false exactly when a live run over the same cache makes no request", async () => {
321
+ const cites = [
322
+ { id: "d", doi: "doi:10.1/X.", title: TITLE },
323
+ { id: "a", arxiv: "arXiv:1706.03762", title: TITLE },
324
+ { id: "t", title: TITLE, year: 2017 },
325
+ { id: "v", cve: "cve-2024-0001" },
326
+ { id: "g", commit: "github.com/a/b/commit/abc1234" },
327
+ ];
328
+ fakeFetch({
329
+ [CR]: () =>
330
+ json(200, {
331
+ message: {
332
+ title: [TITLE],
333
+ issued: { "date-parts": [[2017]] },
334
+ items: [],
335
+ },
336
+ }),
337
+ [OA]: () => json(200, { results: [] }),
338
+ [S2]: () => json(200, { data: [] }),
339
+ [AX]: () => text(200, "<feed></feed>"),
340
+ [DOI]: () => json(200, { responseCode: 1 }),
341
+ [NVD]: () => json(200, { totalResults: 1 }),
342
+ });
343
+ const cache = {};
344
+ const cold = [];
345
+ for (const c of cites) cold.push(await wouldAsk(c, cache));
346
+ for (const c of cites) await verifyCitationLive(c, { cache });
347
+ const warm = [];
348
+ for (const c of cites) warm.push(await wouldAsk(c, cache));
349
+ const again = fakeFetch({});
350
+ for (const c of cites) await verifyCitationLive(c, { cache });
351
+ assert.deepEqual(
352
+ { cold, warm, again },
353
+ {
354
+ cold: [true, true, true, true, false],
355
+ warm: [false, false, false, false, false],
356
+ again: [],
357
+ },
358
+ );
359
+ });
360
+
361
+ test("🔴 the first match ends the lookups: nothing asked after it could change the verdict", async () => {
362
+ const calls = fakeFetch({
363
+ [CR]: () =>
364
+ json(200, {
365
+ message: { title: [TITLE], issued: { "date-parts": [[2017]] } },
366
+ }),
367
+ });
368
+ const result = await verifyCitationLive({
369
+ id: "m",
370
+ doi: "10.1/x",
371
+ title: TITLE,
372
+ });
373
+ assert.deepEqual(
374
+ { verdict: result.verdict, matched: result.matched_db, calls },
375
+ {
376
+ verdict: "true",
377
+ matched: "crossref",
378
+ // Not OpenAlex, not Semantic Scholar, not doi.org: a match wins over anything they say.
379
+ calls: ["https://api.crossref.org/works/10.1%2Fx"],
380
+ },
381
+ );
382
+ });
383
+
384
+ // ── #120: a service that refuses is asked once per run ─────────────────────────────────
385
+
386
+ /** Three title-only citations nobody can find; `s2` answers Semantic Scholar's search. */
387
+ async function threeTitles(s2, { concurrent = false } = {}) {
388
+ const calls = fakeFetch({
389
+ [CR]: () => json(200, { message: { items: [] } }),
390
+ [OA]: () => json(200, { results: [] }),
391
+ [S2]: s2,
392
+ [AX]: () => text(200, "<feed></feed>"),
393
+ });
394
+ const breaker = createBreaker();
395
+ const cites = ["One", "Two", "Three"].map((t) => ({
396
+ id: t,
397
+ title: `${t} Paper`,
398
+ }));
399
+ const run = (c) => verifyCitationLive(c, { breaker });
400
+ const results = concurrent
401
+ ? await Promise.all(cites.map(run))
402
+ : await (async () => {
403
+ const out = [];
404
+ for (const c of cites) out.push(await run(c));
405
+ return out;
406
+ })();
407
+ const count = (prefix) => calls.filter((u) => u.startsWith(prefix)).length;
408
+ return {
409
+ asked: {
410
+ crossref: count(CR),
411
+ openalex: count(OA),
412
+ s2: count(S2),
413
+ arxiv: count(AX),
414
+ },
415
+ refused: results.map((r) => r.refused),
416
+ verdicts: results.map((r) => r.verdict),
417
+ };
418
+ }
419
+
420
+ const REFUSED = (why) => [
421
+ [`semantic_scholar: ${why}`],
422
+ [`semantic_scholar: semantic_scholar refused earlier in this run (${why})`],
423
+ [`semantic_scholar: semantic_scholar refused earlier in this run (${why})`],
424
+ ];
425
+
426
+ test("#120: a 429 on the first request — Semantic Scholar is asked ONCE; the other services go on", async () => {
427
+ assert.deepEqual(await threeTitles(() => json(429)), {
428
+ asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
429
+ refused: REFUSED("http 429"),
430
+ verdicts: ["unresolvable", "unresolvable", "unresolvable"],
431
+ });
432
+ });
433
+
434
+ test("#120: an HTML page where JSON was asked trips the breaker, and says so", async () => {
435
+ assert.deepEqual(
436
+ await threeTitles(() =>
437
+ text(200, "<!doctype html><title>slow down</title>"),
438
+ ),
439
+ {
440
+ asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
441
+ refused: REFUSED("answered HTML, not JSON"),
442
+ verdicts: ["unresolvable", "unresolvable", "unresolvable"],
443
+ },
444
+ );
445
+ });
446
+
447
+ test("#120: a body that is not JSON and not HTML is a refusal too", async () => {
448
+ const r = await threeTitles(() => text(200, "rate limited"));
449
+ assert.deepEqual(r.refused[0], [
450
+ "semantic_scholar: answered something that is not JSON",
451
+ ]);
452
+ });
453
+
454
+ test("#120: a timeout trips the breaker — the 15 s is paid once, not per citation", async () => {
455
+ vi.useFakeTimers();
456
+ // Never answers; the request's own timeout aborts it.
457
+ const pending = threeTitles(
458
+ (signal) =>
459
+ new Promise((_, reject) =>
460
+ signal.addEventListener("abort", () =>
461
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
462
+ ),
463
+ ),
464
+ );
465
+ let done = false;
466
+ void pending.finally(() => (done = true));
467
+ while (!done) await vi.advanceTimersByTimeAsync(1000);
468
+ assert.deepEqual(await pending, {
469
+ asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
470
+ refused: REFUSED("timeout"),
471
+ verdicts: ["unresolvable", "unresolvable", "unresolvable"],
472
+ });
473
+ });
474
+
475
+ test("#120: citations in flight at once still ask a refusing service once — the first request goes alone", async () => {
476
+ assert.deepEqual(
477
+ (await threeTitles(() => json(429), { concurrent: true })).asked,
478
+ {
479
+ crossref: 3,
480
+ openalex: 3,
481
+ s2: 1,
482
+ arxiv: 3,
483
+ },
484
+ );
485
+ });
486
+
487
+ // Guards the stated bound: requests already in flight when a service starts refusing still land
488
+ // (at most as many as run at once), and nothing is asked after the first refusal is recorded.
489
+ test("#120: a service that starts refusing costs at most the requests already in flight", async () => {
490
+ const breaker = createBreaker();
491
+ let attempts = 0;
492
+ let release;
493
+ const first = new Promise((r) => (release = r));
494
+ const call = (answer) =>
495
+ breaker.call("s2", async () => {
496
+ attempts++;
497
+ return answer();
498
+ });
499
+ const opener = call(() => first);
500
+ const waiters = Array.from({ length: 5 }, () =>
501
+ call(async () => ({ ok: false, reason: "http 429" })),
502
+ );
503
+ release({ ok: true, value: 1 });
504
+ await Promise.all([opener, ...waiters]);
505
+ const after = await call(async () => ({ ok: true, value: 2 }));
506
+ assert.deepEqual(
507
+ { attempts, after },
508
+ {
509
+ attempts: 6,
510
+ after: { ok: false, reason: "s2 refused earlier in this run (http 429)" },
511
+ },
512
+ );
513
+ });
514
+
515
+ test("#120: a service that answered keeps being asked; one that refuses later is cut off from then on", async () => {
516
+ let n = 0;
517
+ const r = await threeTitles(() =>
518
+ ++n === 1 ? json(200, { data: [] }) : json(503),
519
+ );
520
+ assert.deepEqual(
521
+ { s2: r.asked.s2, refused: r.refused },
522
+ {
523
+ s2: 2,
524
+ refused: [
525
+ undefined,
526
+ ["semantic_scholar: http 503"],
527
+ [
528
+ "semantic_scholar: semantic_scholar refused earlier in this run (http 503)",
529
+ ],
530
+ ],
531
+ },
532
+ );
533
+ });
534
+
236
535
  test("offline: no request at all, the verdict from the citation alone", async () => {
237
536
  const calls = fakeFetch({});
238
537
  const result = await verifyCitationLive(
@@ -265,23 +564,25 @@ test("a registry that never answers is abandoned after the timeout, as unreachab
265
564
  test("a registry that answers 200 with no body, or a record without its title, still classifies", async () => {
266
565
  const calls = fakeFetch({
267
566
  [CR]: () => json(200, {}),
268
- [OA]: () => json(200, { id: "W1", publication_year: 2017 }),
567
+ [`${OA}/doi:10.1%2Fx`]: () =>
568
+ json(200, { id: "W1", publication_year: 2017 }),
569
+ [`${OA}/doi:10.1%2Fy`]: () => json(200, {}),
269
570
  [S2]: () => json(200, { title: "", year: 2017 }),
270
571
  [DOI]: () => json(200, { responseCode: 1 }),
271
572
  });
272
- const result = await verifyCitationLive({
273
- id: "b",
274
- doi: "10.1/x",
275
- title: TITLE,
276
- });
277
- // OpenAlex resolves the DOI to a record with no title: an incomparable title is not a
278
- // mismatch, so the DOI stands confirmed.
573
+ const run = async (doi) => {
574
+ const r = await verifyCitationLive({ id: doi, doi, title: TITLE });
575
+ return [r.verdict, r.matched_db];
576
+ };
577
+ // A record with no title is incomparable, which is not a mismatch, so the DOI stands confirmed:
578
+ // x by OpenAlex's untitled record; y, which OpenAlex does not know, by Semantic Scholar's.
279
579
  assert.deepEqual(
580
+ { x: await run("10.1/x"), y: await run("10.1/y"), calls: calls.length },
280
581
  {
281
- verdict: result.verdict,
282
- matched_db: result.matched_db,
283
- calls: calls.length,
582
+ x: ["true", "openalex"],
583
+ y: ["true", "semantic_scholar"],
584
+ // x: Crossref, OpenAlex (match, the lookups end). y: Crossref, OpenAlex, Semantic Scholar.
585
+ calls: 5,
284
586
  },
285
- { verdict: "true", matched_db: "openalex", calls: 4 },
286
587
  );
287
588
  });
@@ -30,6 +30,7 @@ import {
30
30
  normalizeArxiv,
31
31
  normalizeIdentifiers,
32
32
  normalizeTitle,
33
+ titleIdentity,
33
34
  isValidCveId,
34
35
  } from "./verify-cites.mjs";
35
36
 
@@ -737,15 +738,16 @@ console.log("\n[S4] .bib parser edge cases:");
737
738
 
738
739
  // ── Edges of the pure layer: the defensive arms, each with the input that reaches it ──────────
739
740
 
740
- test("levenshtein and normalizeTitle take a missing string as empty", () => {
741
+ test("levenshtein, normalizeTitle and titleIdentity take a missing string as empty", () => {
741
742
  assert.deepEqual(
742
743
  [
743
744
  levenshtein(null, "ab"),
744
745
  levenshtein("abc", undefined),
745
746
  levenshtein("", ""),
746
747
  normalizeTitle(undefined),
748
+ titleIdentity(undefined),
747
749
  ],
748
- [2, 3, 0, ""],
750
+ [2, 3, 0, "", ""],
749
751
  );
750
752
  });
751
753
 
@@ -0,0 +1,8 @@
1
+ /** The clock, for the lookup cache: the day an answer was fetched, in UTC. */
2
+ export const todayUtc = (): string => new Date().toISOString().slice(0, 10);
3
+
4
+ /** Wait `ms` — DBLP's pace between requests. */
5
+ export const sleep = (ms: number): Promise<void> =>
6
+ new Promise((r) => {
7
+ setTimeout(r, ms);
8
+ });