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
|
@@ -5,10 +5,18 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import { afterEach, test, vi } from "vitest";
|
|
8
|
-
import { onlineReferences } from "./index.ts";
|
|
8
|
+
import { dblpTitleKey, onlineReferences, referencesChecker } from "./index.ts";
|
|
9
|
+
import {
|
|
10
|
+
EMPTY_LOOKUP_CACHE,
|
|
11
|
+
type LookupCache,
|
|
12
|
+
} from "../../domain/lookup-cache.ts";
|
|
9
13
|
// @ts-expect-error — a skill script in .mjs, it has no types
|
|
10
14
|
import * as cites from "../../../skills/verify-citations/scripts/verify-cites.mjs";
|
|
11
15
|
|
|
16
|
+
/** The verdicts of a cold run — an empty cache, the adapter the CLI wires. */
|
|
17
|
+
const checkOnly = async (bib: string) =>
|
|
18
|
+
(await onlineReferences(bib, EMPTY_LOOKUP_CACHE)).check;
|
|
19
|
+
|
|
12
20
|
afterEach(() => {
|
|
13
21
|
vi.unstubAllGlobals();
|
|
14
22
|
vi.useRealTimers();
|
|
@@ -36,14 +44,17 @@ const dblp = (title: string, authors: string[]) =>
|
|
|
36
44
|
});
|
|
37
45
|
|
|
38
46
|
/** Route by URL prefix; the longest prefix wins. */
|
|
39
|
-
function fakeFetch(routes: Record<string, () => Response>) {
|
|
47
|
+
function fakeFetch(routes: Record<string, () => Response>): string[] {
|
|
48
|
+
const calls: string[] = [];
|
|
40
49
|
vi.stubGlobal("fetch", async (url: string) => {
|
|
50
|
+
calls.push(url);
|
|
41
51
|
const hit = Object.keys(routes)
|
|
42
52
|
.filter((p) => url.startsWith(p))
|
|
43
53
|
.sort((a, b) => b.length - a.length)[0];
|
|
44
54
|
if (hit === undefined) return json(404);
|
|
45
55
|
return routes[hit]!();
|
|
46
56
|
});
|
|
57
|
+
return calls;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/** Await `p` while fake time runs, so DBLP's pauses between requests elapse at once. */
|
|
@@ -58,7 +69,7 @@ test("no service answers: not-checked, with the reason", async () => {
|
|
|
58
69
|
vi.stubGlobal("fetch", async () => {
|
|
59
70
|
throw new Error("getaddrinfo ENOTFOUND api.crossref.org");
|
|
60
71
|
});
|
|
61
|
-
assert.deepEqual(await
|
|
72
|
+
assert.deepEqual(await checkOnly("@misc{k, title={T}}"), {
|
|
62
73
|
kind: "not-checked",
|
|
63
74
|
why: "the citation services cannot be reached (getaddrinfo ENOTFOUND api.crossref.org)",
|
|
64
75
|
});
|
|
@@ -90,7 +101,7 @@ test("each entry gets its existence and its authors: confirmed, fabricated, mism
|
|
|
90
101
|
"@inproceedings{flaky, author={Ada Lovelace}, title={Flaky Paper}, booktitle={ICSE}}",
|
|
91
102
|
"@misc{pre, author={Ada Lovelace}, title={A Preprint}, journal={arXiv preprint}}",
|
|
92
103
|
].join("\n");
|
|
93
|
-
const r = await settle(
|
|
104
|
+
const r = await settle(checkOnly(bib));
|
|
94
105
|
assert.equal(r.kind, "checked");
|
|
95
106
|
const byKey = Object.fromEntries(
|
|
96
107
|
(r.kind === "checked" ? r.entries : []).map((e) => [
|
|
@@ -112,7 +123,10 @@ test("each entry gets its existence and its authors: confirmed, fabricated, mism
|
|
|
112
123
|
fake: ["false", "skipped"],
|
|
113
124
|
drift: ["unresolvable", "mismatch"],
|
|
114
125
|
driftWhy: "missing turing (DBLP: ICSE 2024)",
|
|
115
|
-
flaky: [
|
|
126
|
+
flaky: [
|
|
127
|
+
"unchecked",
|
|
128
|
+
"DBLP lookup failed: DBLP refused earlier in this run (DBLP 500)",
|
|
129
|
+
],
|
|
116
130
|
pre: ["unresolvable", "skipped"],
|
|
117
131
|
},
|
|
118
132
|
);
|
|
@@ -128,7 +142,7 @@ test("an author finding names what is extra and what is out of order; an entry o
|
|
|
128
142
|
"https://dblp.org/search/publ/api/?q=Comment%20Paper": () =>
|
|
129
143
|
dblp("Comment Paper", ["Grace Hopper"]),
|
|
130
144
|
"https://dblp.org/": () => json(200, {}),
|
|
131
|
-
"https://api.crossref.org/": () => json(200),
|
|
145
|
+
"https://api.crossref.org/": () => json(200, { message: { items: [] } }),
|
|
132
146
|
});
|
|
133
147
|
const bib = [
|
|
134
148
|
"@inproceedings{extra, author={Ada Lovelace and Grace Hopper}, title={Extra Paper}, booktitle={ICSE}}",
|
|
@@ -136,7 +150,7 @@ test("an author finding names what is extra and what is out of order; an entry o
|
|
|
136
150
|
// verify-cites skips @comment blocks; bib-authors reads them — so only one checker sees this key.
|
|
137
151
|
"@comment{commented, author={Ada Lovelace}, title={Comment Paper}, booktitle={ICSE}}",
|
|
138
152
|
].join("\n");
|
|
139
|
-
const r = await settle(
|
|
153
|
+
const r = await settle(checkOnly(bib));
|
|
140
154
|
const why = Object.fromEntries(
|
|
141
155
|
(r.kind === "checked" ? r.entries : []).map((e) => [
|
|
142
156
|
e.key,
|
|
@@ -204,7 +218,7 @@ test(
|
|
|
204
218
|
).verdict,
|
|
205
219
|
);
|
|
206
220
|
const t0 = Date.now();
|
|
207
|
-
const r = await
|
|
221
|
+
const r = await checkOnly(bib);
|
|
208
222
|
const ms = Date.now() - t0;
|
|
209
223
|
assert.equal(r.kind, "checked");
|
|
210
224
|
assert.deepEqual(
|
|
@@ -217,7 +231,7 @@ test(
|
|
|
217
231
|
|
|
218
232
|
test("#107: a lookup whose requests time out is that entry's `unresolvable`, not the others' failure", async () => {
|
|
219
233
|
slowFetch(5, ["10.1234/p3"]);
|
|
220
|
-
const r = await
|
|
234
|
+
const r = await checkOnly(manyBib(8));
|
|
221
235
|
assert.deepEqual(
|
|
222
236
|
r.kind === "checked" ? r.entries.map((e) => [e.key, e.exists]) : [],
|
|
223
237
|
Array.from({ length: 8 }, (_, i) => [
|
|
@@ -226,3 +240,321 @@ test("#107: a lookup whose requests time out is that entry's `unresolvable`, not
|
|
|
226
240
|
]),
|
|
227
241
|
);
|
|
228
242
|
});
|
|
243
|
+
|
|
244
|
+
// ── #107: the committed lookup cache ─────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
const TODAY = "2026-09-27";
|
|
247
|
+
const cached = referencesChecker({ today: () => TODAY });
|
|
248
|
+
const PAPERS = [
|
|
249
|
+
"@inproceedings{good, author={Ada Lovelace}, title={Good Paper}, booktitle={ICSE}, doi={10.1/good}}",
|
|
250
|
+
"@inproceedings{other, author={Ada Lovelace}, title={Other Paper}, booktitle={ICSE}}",
|
|
251
|
+
"@misc{pre, author={Ada Lovelace}, title={A Preprint}, journal={arXiv preprint}}",
|
|
252
|
+
];
|
|
253
|
+
/** Every service answers; DBLP knows "Good Paper" and nothing else. */
|
|
254
|
+
const answering = () =>
|
|
255
|
+
fakeFetch({
|
|
256
|
+
"https://api.crossref.org/works/10.1%2Fgood": () =>
|
|
257
|
+
json(200, {
|
|
258
|
+
message: { title: ["Good Paper"], issued: { "date-parts": [[2024]] } },
|
|
259
|
+
}),
|
|
260
|
+
"https://doi.org/api/handles/10.1/good": () =>
|
|
261
|
+
json(200, { responseCode: 1 }),
|
|
262
|
+
"https://dblp.org/search/publ/api/?q=Good%20Paper": () =>
|
|
263
|
+
dblp("Good Paper", ["Ada Lovelace"]),
|
|
264
|
+
"https://dblp.org/": () => json(200, {}),
|
|
265
|
+
"https://api.crossref.org/": () => json(200, { message: { items: [] } }),
|
|
266
|
+
"https://api.openalex.org/": () => json(200, { results: [] }),
|
|
267
|
+
"https://api.semanticscholar.org/": () => json(200, { data: [] }),
|
|
268
|
+
"https://export.arxiv.org/": () => new Response("<feed></feed>"),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/** A cold run over `bib`, under fake timers: its answers become the cache the next run starts from. */
|
|
272
|
+
async function cold(bib: string) {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
answering();
|
|
275
|
+
return settle(cached(bib, EMPTY_LOOKUP_CACHE));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
test("cold: every answer is stored with the day it was fetched — responses, never verdicts", async () => {
|
|
279
|
+
const { check, cache } = await cold(PAPERS.join("\n"));
|
|
280
|
+
assert.equal(check.kind, "checked");
|
|
281
|
+
assert.deepEqual(
|
|
282
|
+
{
|
|
283
|
+
// The lookups run concurrently, so insertion order is not the subject.
|
|
284
|
+
citations: [...cache.citations.keys()]
|
|
285
|
+
.map((k) => k.split(":").slice(0, 2).join(":"))
|
|
286
|
+
.sort(),
|
|
287
|
+
dblp: [...cache.dblp.values()].map((d) => [d.title, d.hits.length]),
|
|
288
|
+
dates: new Set(
|
|
289
|
+
[...cache.citations.values(), ...cache.dblp.values()].map(
|
|
290
|
+
(v) => v.fetched,
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
// `good` is confirmed by Crossref, which ends its lookups; the two title-only entries are
|
|
296
|
+
// found nowhere, so each asked all four registries.
|
|
297
|
+
citations: [
|
|
298
|
+
"arxiv:title",
|
|
299
|
+
"arxiv:title",
|
|
300
|
+
"crossref:doi",
|
|
301
|
+
"crossref:title",
|
|
302
|
+
"crossref:title",
|
|
303
|
+
"openalex:title",
|
|
304
|
+
"openalex:title",
|
|
305
|
+
"semantic_scholar:title",
|
|
306
|
+
"semantic_scholar:title",
|
|
307
|
+
],
|
|
308
|
+
dblp: [
|
|
309
|
+
["Good Paper", 1],
|
|
310
|
+
["Other Paper", 0],
|
|
311
|
+
],
|
|
312
|
+
dates: new Set([TODAY]),
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
// Guards: what is stored is the resolver's RESPONSE — never a verdict field.
|
|
316
|
+
assert.deepEqual(cache.citations.get("crossref:doi:10.1/good"), {
|
|
317
|
+
fetched: TODAY,
|
|
318
|
+
response: {
|
|
319
|
+
db: "crossref",
|
|
320
|
+
transport: "ok",
|
|
321
|
+
query: "doi",
|
|
322
|
+
record: { title: "Good Paper", subtitle: "", year: 2024 },
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("🔴 warm: a fully cached bibliography asks NOTHING — no lookup, no reachability probe, no DBLP pause", async () => {
|
|
328
|
+
const bib = PAPERS.join("\n");
|
|
329
|
+
const first = await cold(bib);
|
|
330
|
+
vi.useRealTimers();
|
|
331
|
+
// Fake timers with NO advancing: a single DBLP pause would never resolve and time the test out.
|
|
332
|
+
vi.useFakeTimers();
|
|
333
|
+
const calls = fakeFetch({});
|
|
334
|
+
const second = await cached(bib, first.cache);
|
|
335
|
+
assert.deepEqual(
|
|
336
|
+
// Nothing fetched, so the cache comes back as the very object passed in — nothing to write.
|
|
337
|
+
{ calls, check: second.check, sameCache: second.cache === first.cache },
|
|
338
|
+
{ calls: [], check: first.check, sameCache: true },
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("🔴 one entry edited: only that entry is asked again, and the old answers are kept", async () => {
|
|
343
|
+
const first = await cold(PAPERS.join("\n"));
|
|
344
|
+
vi.useRealTimers();
|
|
345
|
+
vi.useFakeTimers();
|
|
346
|
+
const calls = answering();
|
|
347
|
+
const edited = PAPERS.join("\n").replace("Other Paper", "Renamed Paper");
|
|
348
|
+
const second = await settle(cached(edited, first.cache));
|
|
349
|
+
assert.deepEqual(
|
|
350
|
+
{
|
|
351
|
+
// The lookups run concurrently, so the order of requests is not the subject.
|
|
352
|
+
asked: calls.filter((u) => u !== "https://api.crossref.org/").sort(),
|
|
353
|
+
probed: calls.includes("https://api.crossref.org/"),
|
|
354
|
+
kept: [...first.cache.citations.keys()].every((k) =>
|
|
355
|
+
second.cache.citations.has(k),
|
|
356
|
+
),
|
|
357
|
+
newDblp: second.cache.dblp.has(dblpTitleKey("Renamed Paper")),
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
asked: [
|
|
361
|
+
"https://api.crossref.org/works?query.bibliographic=Renamed%20Paper&rows=5",
|
|
362
|
+
"https://api.openalex.org/works?search=Renamed%20Paper&per-page=5",
|
|
363
|
+
"https://api.semanticscholar.org/graph/v1/paper/search?query=Renamed%20Paper&fields=title,year&limit=5",
|
|
364
|
+
"https://dblp.org/search/publ/api/?q=Renamed%20Paper&format=json&h=6",
|
|
365
|
+
"https://export.arxiv.org/api/query?search_query=ti:%22Renamed%20Paper%22&max_results=5",
|
|
366
|
+
],
|
|
367
|
+
probed: true,
|
|
368
|
+
kept: true,
|
|
369
|
+
newDblp: true,
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Guards: a cached answer is good for MAX_AGE_DAYS, then asked again. A registry or DBLP that had
|
|
375
|
+
// not indexed a new work yet would otherwise answer "no record" from the committed cache forever.
|
|
376
|
+
test("an answer older than 30 days is asked again and re-dated; at 30 days it is still used", async () => {
|
|
377
|
+
const bib = PAPERS.join("\n");
|
|
378
|
+
const first = await cold(bib);
|
|
379
|
+
vi.useRealTimers();
|
|
380
|
+
vi.useFakeTimers();
|
|
381
|
+
const quiet = fakeFetch({});
|
|
382
|
+
const at30 = await referencesChecker({ today: () => "2026-10-27" })(
|
|
383
|
+
bib,
|
|
384
|
+
first.cache,
|
|
385
|
+
);
|
|
386
|
+
vi.useRealTimers();
|
|
387
|
+
vi.useFakeTimers();
|
|
388
|
+
const calls = answering();
|
|
389
|
+
const at31 = await settle(
|
|
390
|
+
referencesChecker({ today: () => "2026-10-28" })(bib, first.cache),
|
|
391
|
+
);
|
|
392
|
+
assert.deepEqual(
|
|
393
|
+
{
|
|
394
|
+
at30: { calls: quiet, same: at30.cache === first.cache },
|
|
395
|
+
at31: {
|
|
396
|
+
asked: calls.some((u) => u.startsWith("https://dblp.org/search")),
|
|
397
|
+
dates: new Set(
|
|
398
|
+
[...at31.cache.citations.values(), ...at31.cache.dblp.values()].map(
|
|
399
|
+
(v) => v.fetched,
|
|
400
|
+
),
|
|
401
|
+
),
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
at30: { calls: [], same: true },
|
|
406
|
+
at31: { asked: true, dates: new Set(["2026-10-28"]) },
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("a failed lookup is not cached: the next run asks it again", async () => {
|
|
412
|
+
vi.useFakeTimers();
|
|
413
|
+
fakeFetch({
|
|
414
|
+
"https://api.crossref.org/": () => json(200, { message: { items: [] } }),
|
|
415
|
+
"https://dblp.org/": () => json(500),
|
|
416
|
+
});
|
|
417
|
+
const bib = PAPERS[1]!;
|
|
418
|
+
const r = await settle(cached(bib, EMPTY_LOOKUP_CACHE));
|
|
419
|
+
const failed: LookupCache = r.cache;
|
|
420
|
+
assert.deepEqual(
|
|
421
|
+
{
|
|
422
|
+
dblp: failed.dblp.size,
|
|
423
|
+
authors: r.check.kind === "checked" ? r.check.entries[0]?.authors : "",
|
|
424
|
+
},
|
|
425
|
+
{ dblp: 0, authors: "unchecked" },
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("offline with an incomplete cache: not-checked, and the cache comes back unchanged", async () => {
|
|
430
|
+
vi.stubGlobal("fetch", async () => {
|
|
431
|
+
throw new Error("getaddrinfo ENOTFOUND api.crossref.org");
|
|
432
|
+
});
|
|
433
|
+
const r = await cached(PAPERS[0]!, EMPTY_LOOKUP_CACHE);
|
|
434
|
+
assert.deepEqual(r, {
|
|
435
|
+
check: {
|
|
436
|
+
kind: "not-checked",
|
|
437
|
+
why: "the citation services cannot be reached (getaddrinfo ENOTFOUND api.crossref.org)",
|
|
438
|
+
},
|
|
439
|
+
cache: EMPTY_LOOKUP_CACHE,
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ── #120: a service that refuses costs one request per run ───────────────────────────────
|
|
444
|
+
|
|
445
|
+
const PUBLISHED = [
|
|
446
|
+
"@inproceedings{a, author={Ada Lovelace}, title={First Paper}, booktitle={ICSE}}",
|
|
447
|
+
"@inproceedings{b, author={Ada Lovelace}, title={Second Paper}, booktitle={ICSE}}",
|
|
448
|
+
"@inproceedings{c, author={Ada Lovelace}, title={Third Paper}, booktitle={ICSE}}",
|
|
449
|
+
].join("\n");
|
|
450
|
+
|
|
451
|
+
/** Every registry answers "not found"; DBLP and Semantic Scholar answer with `dblp` and `s2`. */
|
|
452
|
+
function refusing(dblpAnswer: () => Response, s2: () => Response) {
|
|
453
|
+
return fakeFetch({
|
|
454
|
+
"https://api.crossref.org/": () => json(200, { message: { items: [] } }),
|
|
455
|
+
"https://api.openalex.org/": () => json(200, { results: [] }),
|
|
456
|
+
"https://api.semanticscholar.org/": s2,
|
|
457
|
+
"https://export.arxiv.org/": () => new Response("<feed></feed>"),
|
|
458
|
+
"https://dblp.org/": dblpAnswer,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const count = (calls: string[], prefix: string) =>
|
|
462
|
+
calls.filter((u) => u.startsWith(prefix)).length;
|
|
463
|
+
|
|
464
|
+
test("🔴 #120: DBLP answering HTML is asked ONCE — no retries, no pause — and every entry says why it is unchecked", async () => {
|
|
465
|
+
// Fake timers with NO advancing: a single 900 ms pause or retry backoff would hang the test.
|
|
466
|
+
vi.useFakeTimers();
|
|
467
|
+
const calls = refusing(
|
|
468
|
+
() => new Response("<!doctype html><title>429</title>"),
|
|
469
|
+
() => json(429),
|
|
470
|
+
);
|
|
471
|
+
const { check } = await cached(PUBLISHED, EMPTY_LOOKUP_CACHE);
|
|
472
|
+
const entries = check.kind === "checked" ? check.entries : [];
|
|
473
|
+
assert.deepEqual(
|
|
474
|
+
{
|
|
475
|
+
dblp: count(calls, "https://dblp.org/"),
|
|
476
|
+
s2: count(calls, "https://api.semanticscholar.org/"),
|
|
477
|
+
crossref: count(calls, "https://api.crossref.org/works"),
|
|
478
|
+
entries: entries.map((e) => [e.key, e.exists, e.authors]),
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
dblp: 1,
|
|
482
|
+
s2: 1,
|
|
483
|
+
crossref: 3,
|
|
484
|
+
entries: [
|
|
485
|
+
["a", "unresolvable", "unchecked"],
|
|
486
|
+
["b", "unresolvable", "unchecked"],
|
|
487
|
+
["c", "unresolvable", "unchecked"],
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
// The reason, as the record carries it — its last part names both refusals.
|
|
492
|
+
assert.deepEqual(
|
|
493
|
+
entries.map((e) => e.why?.split("; ").slice(1)),
|
|
494
|
+
[
|
|
495
|
+
[
|
|
496
|
+
"not asked: semantic_scholar: http 429",
|
|
497
|
+
"DBLP lookup failed: DBLP refused earlier in this run (Unexpected token '<', \"<!doctype \"... is not valid JSON)",
|
|
498
|
+
],
|
|
499
|
+
[
|
|
500
|
+
"not asked: semantic_scholar: semantic_scholar refused earlier in this run (http 429)",
|
|
501
|
+
"DBLP lookup failed: DBLP refused earlier in this run (Unexpected token '<', \"<!doctype \"... is not valid JSON)",
|
|
502
|
+
],
|
|
503
|
+
[
|
|
504
|
+
"not asked: semantic_scholar: semantic_scholar refused earlier in this run (http 429)",
|
|
505
|
+
"DBLP lookup failed: DBLP refused earlier in this run (Unexpected token '<', \"<!doctype \"... is not valid JSON)",
|
|
506
|
+
],
|
|
507
|
+
],
|
|
508
|
+
);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("#120: a DBLP 429 trips it as well; DBLP answering keeps its 900 ms pace between queries", async () => {
|
|
512
|
+
vi.useFakeTimers();
|
|
513
|
+
const refused = refusing(
|
|
514
|
+
() => json(429),
|
|
515
|
+
() => json(200, { data: [] }),
|
|
516
|
+
);
|
|
517
|
+
await cached(PUBLISHED, EMPTY_LOOKUP_CACHE);
|
|
518
|
+
vi.useRealTimers();
|
|
519
|
+
vi.useFakeTimers();
|
|
520
|
+
const answering = refusing(
|
|
521
|
+
() => dblp("First Paper", ["Ada Lovelace"]),
|
|
522
|
+
() => json(200, { data: [] }),
|
|
523
|
+
);
|
|
524
|
+
const pending = cached(PUBLISHED, EMPTY_LOOKUP_CACHE);
|
|
525
|
+
// One compared entry, then the pause: without advancing time, the run cannot finish.
|
|
526
|
+
let done = false;
|
|
527
|
+
void pending.finally(() => (done = true));
|
|
528
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
529
|
+
const beforePause = done;
|
|
530
|
+
await settle(pending);
|
|
531
|
+
assert.deepEqual(
|
|
532
|
+
{
|
|
533
|
+
refused: count(refused, "https://dblp.org/"),
|
|
534
|
+
answering: count(answering, "https://dblp.org/"),
|
|
535
|
+
beforePause,
|
|
536
|
+
},
|
|
537
|
+
{ refused: 1, answering: 3, beforePause: false },
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Guards: the DBLP key must not change with presentation markup (BibTeX braces, case, accents,
|
|
542
|
+
// spacing), or a re-cased title in the .bib re-asks DBLP with its 900 ms pause.
|
|
543
|
+
test("dblpTitleKey is the same for the same title under braces, case, accents and spacing", () => {
|
|
544
|
+
const k = dblpTitleKey("Évaluating {LLM}-based Agents");
|
|
545
|
+
assert.equal(dblpTitleKey("evaluating llm-based agents"), k);
|
|
546
|
+
assert.match(k, /^[0-9a-f]{16}$/);
|
|
547
|
+
assert.notEqual(dblpTitleKey("Evaluating LLM-based Tools"), k);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Guards: punctuation can be part of a title's identity. A shared key would serve one work's
|
|
551
|
+
// DBLP answer for another; a separate key costs one extra lookup at most.
|
|
552
|
+
const PUNCTUATION_PAIRS: readonly (readonly [string, string])[] = [
|
|
553
|
+
["Fuzzing C Compilers", "Fuzzing C++ Compilers"],
|
|
554
|
+
["Verified F Programs", "Verified F# Programs"],
|
|
555
|
+
];
|
|
556
|
+
for (const [a, b] of PUNCTUATION_PAIRS) {
|
|
557
|
+
test(`dblpTitleKey keeps ${a} and ${b} apart`, () => {
|
|
558
|
+
assert.notEqual(dblpTitleKey(a), dblpTitleKey(b));
|
|
559
|
+
});
|
|
560
|
+
}
|