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.
- package/CLAUDE.md +18 -97
- package/CONTRIBUTING.md +7 -21
- package/README.md +11 -7
- package/dist/adapters/references/clock.io.d.ts +5 -0
- package/dist/adapters/references/clock.io.d.ts.map +1 -0
- package/dist/adapters/references/clock.io.js +7 -0
- package/dist/adapters/references/clock.io.js.map +1 -0
- package/dist/adapters/references/index.d.ts +24 -2
- package/dist/adapters/references/index.d.ts.map +1 -1
- package/dist/adapters/references/index.js +125 -15
- package/dist/adapters/references/index.js.map +1 -1
- package/dist/build.d.ts +4 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +10 -3
- package/dist/build.js.map +1 -1
- package/dist/domain/lookup-cache.d.ts +75 -0
- package/dist/domain/lookup-cache.d.ts.map +1 -0
- package/dist/domain/lookup-cache.js +137 -0
- package/dist/domain/lookup-cache.js.map +1 -0
- package/dist/ports/check-references.d.ts +12 -2
- package/dist/ports/check-references.d.ts.map +1 -1
- package/dist/ports/check-references.js +0 -5
- package/dist/ports/check-references.js.map +1 -1
- package/dist/references.d.ts +11 -3
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +63 -15
- package/dist/references.js.map +1 -1
- package/docs/prior-art/README.md +11 -10
- package/docs/prior-art/citation-checkers.md +174 -0
- package/docs/references.md +126 -0
- package/docs/testing.md +118 -0
- package/docs/toolchain.md +6 -4
- package/package.json +1 -1
- package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
- package/skills/render-paper/checkers-requirements.txt +30 -19
- package/skills/render-paper/ensure-checkers.sh +20 -4
- package/skills/verify-citations/SKILL.md +26 -10
- package/skills/verify-citations/SKILL.md.spec.ts +25 -9
- package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +28 -10
- package/skills/verify-citations/scripts/bib-authors.mjs +5 -6
- package/skills/verify-citations/scripts/verify-cites.mjs +296 -141
- package/skills/verify-citations/scripts/verify-cites.net.test.mjs +356 -20
- package/skills/verify-citations/scripts/verify-cites.test.mjs +18 -2
- package/src/adapters/references/clock.io.ts +8 -0
- package/src/adapters/references/index.test.ts +341 -9
- package/src/adapters/references/index.ts +198 -29
- package/src/build.ts +10 -3
- package/src/domain/lookup-cache.test.ts +178 -0
- package/src/domain/lookup-cache.ts +202 -0
- package/src/ports/check-references.ts +16 -2
- package/src/references.test.ts +160 -10
- 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 {
|
|
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: ["
|
|
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": [[
|
|
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:
|
|
151
|
+
results: [{ display_name: TITLE, publication_year: 1990 }, {}],
|
|
143
152
|
}),
|
|
144
153
|
[`${S2}/search`]: () =>
|
|
145
|
-
json(200, { data: [{ title: TITLE, year:
|
|
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(
|
|
219
|
+
[CR]: () => json(404),
|
|
210
220
|
[OA]: () => json(503),
|
|
211
221
|
[S2]: () => json(404),
|
|
212
222
|
[DOI]: () => json(200, { responseCode: 1 }),
|
|
@@ -233,6 +243,330 @@ 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: an arXiv DOI (10.48550/arXiv.<id>) names an arXiv id, so arXiv is asked by that id.
|
|
275
|
+
// Measured 2026-09-27 on a real bibliography: OpenAlex maps 10.48550/arXiv.2310.06770 (SWE-bench)
|
|
276
|
+
// to an unrelated work; with Crossref and Semantic Scholar refusing and arXiv asked only by title
|
|
277
|
+
// "when there is no DOI", nothing contradicted it and the paper came out `exists: false`.
|
|
278
|
+
test("an arXiv DOI is asked of arXiv by its id, whose match outweighs a registry's wrong record", async () => {
|
|
279
|
+
const calls = fakeFetch({
|
|
280
|
+
[CR]: () => json(429),
|
|
281
|
+
[OA]: () =>
|
|
282
|
+
json(200, {
|
|
283
|
+
id: "W1",
|
|
284
|
+
display_name: "GardenBench",
|
|
285
|
+
publication_year: 2023,
|
|
286
|
+
}),
|
|
287
|
+
[S2]: () => json(429),
|
|
288
|
+
[AX]: () => text(200, feed(TITLE, 2017)),
|
|
289
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
290
|
+
});
|
|
291
|
+
const v = await verifyCitationLive(
|
|
292
|
+
{ id: "c", doi: "10.48550/arXiv.1706.03762", title: TITLE, year: "2017" },
|
|
293
|
+
{ cache: {} },
|
|
294
|
+
);
|
|
295
|
+
assert.deepEqual(
|
|
296
|
+
{
|
|
297
|
+
verdict: v.verdict,
|
|
298
|
+
matched: v.matched_db,
|
|
299
|
+
arxiv: calls.filter((u) => u.startsWith(AX)),
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
verdict: "true",
|
|
303
|
+
matched: "arxiv",
|
|
304
|
+
arxiv: [`${AX}?id_list=1706.03762&max_results=1`],
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Guards: NVD's "not found" must not be cached. A CVE cited before NVD indexes it would otherwise
|
|
310
|
+
// stay "fabricated" in the committed cache forever; only positive existence is kept, as for doi.org.
|
|
311
|
+
test("an NVD not-found answer is not cached, so a later build asks again", async () => {
|
|
312
|
+
const cache = {};
|
|
313
|
+
fakeFetch({ [NVD]: () => json(200, { totalResults: 0 }) });
|
|
314
|
+
await verifyCitationLive({ id: "c", cve: "CVE-2026-0001" }, { cache });
|
|
315
|
+
const again = fakeFetch({ [NVD]: () => json(200, { totalResults: 1 }) });
|
|
316
|
+
await verifyCitationLive({ id: "c", cve: "CVE-2026-0001" }, { cache });
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
{ asked: again.length, keys: Object.keys(cache) },
|
|
319
|
+
{ asked: 1, keys: ["nvd:CVE-2026-0001"] },
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Guards: a title-only cache key must keep punctuation that names a different work. A shared key
|
|
324
|
+
// for `C` and `C++` would answer the second from the first's registry responses, no request made.
|
|
325
|
+
test("title-only keys keep punctuation: a C++ title is not served the C title's cached answers", async () => {
|
|
326
|
+
const cache = {};
|
|
327
|
+
const miss = () =>
|
|
328
|
+
json(200, { message: { items: [] }, results: [], data: [] });
|
|
329
|
+
fakeFetch({
|
|
330
|
+
[CR]: miss,
|
|
331
|
+
[OA]: miss,
|
|
332
|
+
[S2]: miss,
|
|
333
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
334
|
+
});
|
|
335
|
+
await verifyCitationLive(
|
|
336
|
+
{ id: "a", title: "Fuzzing C Compilers" },
|
|
337
|
+
{ cache },
|
|
338
|
+
);
|
|
339
|
+
const asked = fakeFetch({
|
|
340
|
+
[CR]: miss,
|
|
341
|
+
[OA]: miss,
|
|
342
|
+
[S2]: miss,
|
|
343
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
344
|
+
});
|
|
345
|
+
await verifyCitationLive(
|
|
346
|
+
{ id: "b", title: "Fuzzing C++ Compilers" },
|
|
347
|
+
{ cache },
|
|
348
|
+
);
|
|
349
|
+
expect(asked.length).toBeGreaterThan(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Guards: `paperlint build` skips its reachability probe when no citation `wouldAsk`. If it said
|
|
353
|
+
// "no" for a citation a live run asks about, a "fully cached" paper would go online anyway; if it
|
|
354
|
+
// said "yes" for one a live run answers from the cache, every warm build would probe the network.
|
|
355
|
+
test("wouldAsk: false exactly when a live run over the same cache makes no request", async () => {
|
|
356
|
+
const cites = [
|
|
357
|
+
{ id: "d", doi: "doi:10.1/X.", title: TITLE },
|
|
358
|
+
{ id: "a", arxiv: "arXiv:1706.03762", title: TITLE },
|
|
359
|
+
{ id: "t", title: TITLE, year: 2017 },
|
|
360
|
+
{ id: "v", cve: "cve-2024-0001" },
|
|
361
|
+
{ id: "g", commit: "github.com/a/b/commit/abc1234" },
|
|
362
|
+
];
|
|
363
|
+
fakeFetch({
|
|
364
|
+
[CR]: () =>
|
|
365
|
+
json(200, {
|
|
366
|
+
message: {
|
|
367
|
+
title: [TITLE],
|
|
368
|
+
issued: { "date-parts": [[2017]] },
|
|
369
|
+
items: [],
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
[OA]: () => json(200, { results: [] }),
|
|
373
|
+
[S2]: () => json(200, { data: [] }),
|
|
374
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
375
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
376
|
+
[NVD]: () => json(200, { totalResults: 1 }),
|
|
377
|
+
});
|
|
378
|
+
const cache = {};
|
|
379
|
+
const cold = [];
|
|
380
|
+
for (const c of cites) cold.push(await wouldAsk(c, cache));
|
|
381
|
+
for (const c of cites) await verifyCitationLive(c, { cache });
|
|
382
|
+
const warm = [];
|
|
383
|
+
for (const c of cites) warm.push(await wouldAsk(c, cache));
|
|
384
|
+
const again = fakeFetch({});
|
|
385
|
+
for (const c of cites) await verifyCitationLive(c, { cache });
|
|
386
|
+
assert.deepEqual(
|
|
387
|
+
{ cold, warm, again },
|
|
388
|
+
{
|
|
389
|
+
cold: [true, true, true, true, false],
|
|
390
|
+
warm: [false, false, false, false, false],
|
|
391
|
+
again: [],
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("🔴 the first match ends the lookups: nothing asked after it could change the verdict", async () => {
|
|
397
|
+
const calls = fakeFetch({
|
|
398
|
+
[CR]: () =>
|
|
399
|
+
json(200, {
|
|
400
|
+
message: { title: [TITLE], issued: { "date-parts": [[2017]] } },
|
|
401
|
+
}),
|
|
402
|
+
});
|
|
403
|
+
const result = await verifyCitationLive({
|
|
404
|
+
id: "m",
|
|
405
|
+
doi: "10.1/x",
|
|
406
|
+
title: TITLE,
|
|
407
|
+
});
|
|
408
|
+
assert.deepEqual(
|
|
409
|
+
{ verdict: result.verdict, matched: result.matched_db, calls },
|
|
410
|
+
{
|
|
411
|
+
verdict: "true",
|
|
412
|
+
matched: "crossref",
|
|
413
|
+
// Not OpenAlex, not Semantic Scholar, not doi.org: a match wins over anything they say.
|
|
414
|
+
calls: ["https://api.crossref.org/works/10.1%2Fx"],
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ── #120: a service that refuses is asked once per run ─────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/** Three title-only citations nobody can find; `s2` answers Semantic Scholar's search. */
|
|
422
|
+
async function threeTitles(s2, { concurrent = false } = {}) {
|
|
423
|
+
const calls = fakeFetch({
|
|
424
|
+
[CR]: () => json(200, { message: { items: [] } }),
|
|
425
|
+
[OA]: () => json(200, { results: [] }),
|
|
426
|
+
[S2]: s2,
|
|
427
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
428
|
+
});
|
|
429
|
+
const breaker = createBreaker();
|
|
430
|
+
const cites = ["One", "Two", "Three"].map((t) => ({
|
|
431
|
+
id: t,
|
|
432
|
+
title: `${t} Paper`,
|
|
433
|
+
}));
|
|
434
|
+
const run = (c) => verifyCitationLive(c, { breaker });
|
|
435
|
+
const results = concurrent
|
|
436
|
+
? await Promise.all(cites.map(run))
|
|
437
|
+
: await (async () => {
|
|
438
|
+
const out = [];
|
|
439
|
+
for (const c of cites) out.push(await run(c));
|
|
440
|
+
return out;
|
|
441
|
+
})();
|
|
442
|
+
const count = (prefix) => calls.filter((u) => u.startsWith(prefix)).length;
|
|
443
|
+
return {
|
|
444
|
+
asked: {
|
|
445
|
+
crossref: count(CR),
|
|
446
|
+
openalex: count(OA),
|
|
447
|
+
s2: count(S2),
|
|
448
|
+
arxiv: count(AX),
|
|
449
|
+
},
|
|
450
|
+
refused: results.map((r) => r.refused),
|
|
451
|
+
verdicts: results.map((r) => r.verdict),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const REFUSED = (why) => [
|
|
456
|
+
[`semantic_scholar: ${why}`],
|
|
457
|
+
[`semantic_scholar: semantic_scholar refused earlier in this run (${why})`],
|
|
458
|
+
[`semantic_scholar: semantic_scholar refused earlier in this run (${why})`],
|
|
459
|
+
];
|
|
460
|
+
|
|
461
|
+
test("#120: a 429 on the first request — Semantic Scholar is asked ONCE; the other services go on", async () => {
|
|
462
|
+
assert.deepEqual(await threeTitles(() => json(429)), {
|
|
463
|
+
asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
|
|
464
|
+
refused: REFUSED("http 429"),
|
|
465
|
+
verdicts: ["unresolvable", "unresolvable", "unresolvable"],
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("#120: an HTML page where JSON was asked trips the breaker, and says so", async () => {
|
|
470
|
+
assert.deepEqual(
|
|
471
|
+
await threeTitles(() =>
|
|
472
|
+
text(200, "<!doctype html><title>slow down</title>"),
|
|
473
|
+
),
|
|
474
|
+
{
|
|
475
|
+
asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
|
|
476
|
+
refused: REFUSED("answered HTML, not JSON"),
|
|
477
|
+
verdicts: ["unresolvable", "unresolvable", "unresolvable"],
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("#120: a body that is not JSON and not HTML is a refusal too", async () => {
|
|
483
|
+
const r = await threeTitles(() => text(200, "rate limited"));
|
|
484
|
+
assert.deepEqual(r.refused[0], [
|
|
485
|
+
"semantic_scholar: answered something that is not JSON",
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("#120: a timeout trips the breaker — the 15 s is paid once, not per citation", async () => {
|
|
490
|
+
vi.useFakeTimers();
|
|
491
|
+
// Never answers; the request's own timeout aborts it.
|
|
492
|
+
const pending = threeTitles(
|
|
493
|
+
(signal) =>
|
|
494
|
+
new Promise((_, reject) =>
|
|
495
|
+
signal.addEventListener("abort", () =>
|
|
496
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
let done = false;
|
|
501
|
+
void pending.finally(() => (done = true));
|
|
502
|
+
while (!done) await vi.advanceTimersByTimeAsync(1000);
|
|
503
|
+
assert.deepEqual(await pending, {
|
|
504
|
+
asked: { crossref: 3, openalex: 3, s2: 1, arxiv: 3 },
|
|
505
|
+
refused: REFUSED("timeout"),
|
|
506
|
+
verdicts: ["unresolvable", "unresolvable", "unresolvable"],
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("#120: citations in flight at once still ask a refusing service once — the first request goes alone", async () => {
|
|
511
|
+
assert.deepEqual(
|
|
512
|
+
(await threeTitles(() => json(429), { concurrent: true })).asked,
|
|
513
|
+
{
|
|
514
|
+
crossref: 3,
|
|
515
|
+
openalex: 3,
|
|
516
|
+
s2: 1,
|
|
517
|
+
arxiv: 3,
|
|
518
|
+
},
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Guards the stated bound: requests already in flight when a service starts refusing still land
|
|
523
|
+
// (at most as many as run at once), and nothing is asked after the first refusal is recorded.
|
|
524
|
+
test("#120: a service that starts refusing costs at most the requests already in flight", async () => {
|
|
525
|
+
const breaker = createBreaker();
|
|
526
|
+
let attempts = 0;
|
|
527
|
+
let release;
|
|
528
|
+
const first = new Promise((r) => (release = r));
|
|
529
|
+
const call = (answer) =>
|
|
530
|
+
breaker.call("s2", async () => {
|
|
531
|
+
attempts++;
|
|
532
|
+
return answer();
|
|
533
|
+
});
|
|
534
|
+
const opener = call(() => first);
|
|
535
|
+
const waiters = Array.from({ length: 5 }, () =>
|
|
536
|
+
call(async () => ({ ok: false, reason: "http 429" })),
|
|
537
|
+
);
|
|
538
|
+
release({ ok: true, value: 1 });
|
|
539
|
+
await Promise.all([opener, ...waiters]);
|
|
540
|
+
const after = await call(async () => ({ ok: true, value: 2 }));
|
|
541
|
+
assert.deepEqual(
|
|
542
|
+
{ attempts, after },
|
|
543
|
+
{
|
|
544
|
+
attempts: 6,
|
|
545
|
+
after: { ok: false, reason: "s2 refused earlier in this run (http 429)" },
|
|
546
|
+
},
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("#120: a service that answered keeps being asked; one that refuses later is cut off from then on", async () => {
|
|
551
|
+
let n = 0;
|
|
552
|
+
const r = await threeTitles(() =>
|
|
553
|
+
++n === 1 ? json(200, { data: [] }) : json(503),
|
|
554
|
+
);
|
|
555
|
+
assert.deepEqual(
|
|
556
|
+
{ s2: r.asked.s2, refused: r.refused },
|
|
557
|
+
{
|
|
558
|
+
s2: 2,
|
|
559
|
+
refused: [
|
|
560
|
+
undefined,
|
|
561
|
+
["semantic_scholar: http 503"],
|
|
562
|
+
[
|
|
563
|
+
"semantic_scholar: semantic_scholar refused earlier in this run (http 503)",
|
|
564
|
+
],
|
|
565
|
+
],
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
236
570
|
test("offline: no request at all, the verdict from the citation alone", async () => {
|
|
237
571
|
const calls = fakeFetch({});
|
|
238
572
|
const result = await verifyCitationLive(
|
|
@@ -265,23 +599,25 @@ test("a registry that never answers is abandoned after the timeout, as unreachab
|
|
|
265
599
|
test("a registry that answers 200 with no body, or a record without its title, still classifies", async () => {
|
|
266
600
|
const calls = fakeFetch({
|
|
267
601
|
[CR]: () => json(200, {}),
|
|
268
|
-
[OA]: () =>
|
|
602
|
+
[`${OA}/doi:10.1%2Fx`]: () =>
|
|
603
|
+
json(200, { id: "W1", publication_year: 2017 }),
|
|
604
|
+
[`${OA}/doi:10.1%2Fy`]: () => json(200, {}),
|
|
269
605
|
[S2]: () => json(200, { title: "", year: 2017 }),
|
|
270
606
|
[DOI]: () => json(200, { responseCode: 1 }),
|
|
271
607
|
});
|
|
272
|
-
const
|
|
273
|
-
id:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
// mismatch, so the DOI stands confirmed.
|
|
608
|
+
const run = async (doi) => {
|
|
609
|
+
const r = await verifyCitationLive({ id: doi, doi, title: TITLE });
|
|
610
|
+
return [r.verdict, r.matched_db];
|
|
611
|
+
};
|
|
612
|
+
// A record with no title is incomparable, which is not a mismatch, so the DOI stands confirmed:
|
|
613
|
+
// x by OpenAlex's untitled record; y, which OpenAlex does not know, by Semantic Scholar's.
|
|
279
614
|
assert.deepEqual(
|
|
615
|
+
{ x: await run("10.1/x"), y: await run("10.1/y"), calls: calls.length },
|
|
280
616
|
{
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
617
|
+
x: ["true", "openalex"],
|
|
618
|
+
y: ["true", "semantic_scholar"],
|
|
619
|
+
// x: Crossref, OpenAlex (match, the lookups end). y: Crossref, OpenAlex, Semantic Scholar.
|
|
620
|
+
calls: 5,
|
|
284
621
|
},
|
|
285
|
-
{ verdict: "true", matched_db: "openalex", calls: 4 },
|
|
286
622
|
);
|
|
287
623
|
});
|
|
@@ -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,30 @@ 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
|
-
|
|
741
|
+
// Guards: the arXiv id is read out of an arXiv DOI whenever the entry has no USABLE arXiv id, so
|
|
742
|
+
// a blank `arxiv` field (JSON input) does not keep arXiv from being asked.
|
|
743
|
+
test.each([
|
|
744
|
+
[{ doi: "10.48550/arXiv.2310.06770" }, "2310.06770"],
|
|
745
|
+
[{ doi: "10.48550/arXiv.2310.06770", arxiv: " " }, "2310.06770"],
|
|
746
|
+
[
|
|
747
|
+
{ doi: "10.48550/arXiv.2310.06770", arxiv: "arXiv:2401.00001" },
|
|
748
|
+
"2401.00001",
|
|
749
|
+
],
|
|
750
|
+
[{ doi: "10.1145/3548606" }, undefined],
|
|
751
|
+
])("normalizeIdentifiers(%o).arxiv is %s", (citation, arxiv) => {
|
|
752
|
+
assert.equal(normalizeIdentifiers(citation).arxiv || undefined, arxiv);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("levenshtein, normalizeTitle and titleIdentity take a missing string as empty", () => {
|
|
741
756
|
assert.deepEqual(
|
|
742
757
|
[
|
|
743
758
|
levenshtein(null, "ab"),
|
|
744
759
|
levenshtein("abc", undefined),
|
|
745
760
|
levenshtein("", ""),
|
|
746
761
|
normalizeTitle(undefined),
|
|
762
|
+
titleIdentity(undefined),
|
|
747
763
|
],
|
|
748
|
-
[2, 3, 0, ""],
|
|
764
|
+
[2, 3, 0, "", ""],
|
|
749
765
|
);
|
|
750
766
|
});
|
|
751
767
|
|
|
@@ -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
|
+
});
|