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
|
@@ -2,18 +2,36 @@
|
|
|
2
2
|
* The online reference checks, as the `CheckReferences` port: `verify-cites` (the cited work
|
|
3
3
|
* exists, the title matches — Crossref, OpenAlex, Semantic Scholar, arXiv) and `bib-authors` (the
|
|
4
4
|
* authors are the published version's — DBLP). Both ship in this package's verify-citations skill;
|
|
5
|
-
* this adapter only runs them
|
|
5
|
+
* this adapter only runs them, shapes their answers, and serves them from the paper's lookup cache.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 THE CACHE HOLDS ANSWERS, AND ONLY THE CHECKERS' OWN SEAMS TOUCH IT. verify-cites takes a
|
|
8
|
+
* `cache` object keyed per resolver × identifier and stores only successful responses; it is
|
|
9
|
+
* handed the paper's cached responses and whatever it adds is new. bib-authors takes a `lookup`
|
|
10
|
+
* (title → DBLP hits) and a `pause`; the lookup is wrapped to answer from the cache, and the pause
|
|
11
|
+
* is skipped after a cached answer — DBLP's 900 ms pace and its retries run only for titles never
|
|
12
|
+
* answered. Neither checker's logic changes. Verdicts are derived from the answers on every build
|
|
13
|
+
* (`domain/lookup-cache.ts` says why).
|
|
6
14
|
*
|
|
7
15
|
* "Not checked" is decided up front, by one request: when Crossref cannot be reached at all, the
|
|
8
16
|
* checkers would degrade every entry to `unresolvable` / `unchecked`, which reads like a result.
|
|
9
|
-
* Saying `not-checked` with the reason is the honest record.
|
|
17
|
+
* Saying `not-checked` with the reason is the honest record. That request is skipped when every
|
|
18
|
+
* question this bibliography would ask is already answered in the cache: a warm build is offline.
|
|
10
19
|
*/
|
|
11
20
|
import type {
|
|
12
21
|
CheckReferences,
|
|
13
22
|
EntryVerdict,
|
|
14
23
|
} from "../../ports/check-references.ts";
|
|
24
|
+
import {
|
|
25
|
+
freshPart,
|
|
26
|
+
type CachedDblp,
|
|
27
|
+
type CachedResponse,
|
|
28
|
+
type DblpHit,
|
|
29
|
+
type LookupCache,
|
|
30
|
+
} from "../../domain/lookup-cache.ts";
|
|
31
|
+
import { sha256Hex } from "../../domain/sha256.ts";
|
|
15
32
|
import { mapLimit } from "./pool.ts";
|
|
16
33
|
import { unreachable } from "./reach.io.ts";
|
|
34
|
+
import { sleep, todayUtc } from "./clock.io.ts";
|
|
17
35
|
|
|
18
36
|
/** Citations looked up at once (#107). */
|
|
19
37
|
const LOOKUPS_IN_FLIGHT = 6;
|
|
@@ -26,6 +44,8 @@ interface CiteResult {
|
|
|
26
44
|
readonly id: string;
|
|
27
45
|
readonly verdict: "true" | "false" | "unresolvable";
|
|
28
46
|
readonly reason?: string;
|
|
47
|
+
/** "<service>: <reason>" for each question a refusing service did not answer (#120). */
|
|
48
|
+
readonly refused?: readonly string[];
|
|
29
49
|
}
|
|
30
50
|
interface AuthorFinding {
|
|
31
51
|
readonly key: string;
|
|
@@ -64,6 +84,18 @@ const authorsOf = (key: string, a: AuthorBuckets): EntryVerdict["authors"] =>
|
|
|
64
84
|
? "match"
|
|
65
85
|
: "skipped";
|
|
66
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Why a work was not confirmed: verify-cites' reason, then — when some registry refused — which
|
|
89
|
+
* one, so "unresolvable" is not read as "every registry looked and found nothing". Nothing for a
|
|
90
|
+
* confirmed work: a refusal after a confirmation changed nothing.
|
|
91
|
+
*/
|
|
92
|
+
const unconfirmed = (
|
|
93
|
+
c: CiteResult | undefined,
|
|
94
|
+
): readonly (string | undefined)[] =>
|
|
95
|
+
c && c.verdict !== "true"
|
|
96
|
+
? [c.reason, ...(c.refused ?? []).map((r) => `not asked: ${r}`)]
|
|
97
|
+
: [];
|
|
98
|
+
|
|
67
99
|
/** One entry's verdict from the two checkers' answers. */
|
|
68
100
|
function entryVerdict(
|
|
69
101
|
key: string,
|
|
@@ -73,7 +105,7 @@ function entryVerdict(
|
|
|
73
105
|
const c = found.find((x) => x.id === key);
|
|
74
106
|
const mismatch = a.findings.find((f) => f.key === key);
|
|
75
107
|
const why = [
|
|
76
|
-
c
|
|
108
|
+
...unconfirmed(c),
|
|
77
109
|
mismatch ? describeAuthors(mismatch) : undefined,
|
|
78
110
|
a.unchecked.find((u) => u.key === key)?.why,
|
|
79
111
|
]
|
|
@@ -87,31 +119,168 @@ function entryVerdict(
|
|
|
87
119
|
};
|
|
88
120
|
}
|
|
89
121
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
122
|
+
interface BibAuthorsEntry {
|
|
123
|
+
readonly key: string;
|
|
124
|
+
readonly title: string;
|
|
125
|
+
readonly author: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether bib-authors will ask DBLP about this entry — the filter its `checkAuthors` applies. */
|
|
129
|
+
const asksDblp = (e: BibAuthorsEntry): boolean =>
|
|
130
|
+
Boolean(e.title && e.author) &&
|
|
131
|
+
(authors.claimsPublished(e) as boolean) &&
|
|
132
|
+
!(authors.truncated(e.author) as boolean);
|
|
133
|
+
|
|
134
|
+
/** Is every question this bibliography would ask already answered in `cache`? */
|
|
135
|
+
async function fullyCached(
|
|
136
|
+
citations: readonly object[],
|
|
137
|
+
entries: readonly BibAuthorsEntry[],
|
|
138
|
+
cache: LookupCache,
|
|
139
|
+
store: Readonly<Record<string, unknown>>,
|
|
140
|
+
): Promise<boolean> {
|
|
141
|
+
const asks = await Promise.all(
|
|
142
|
+
citations.map((c) => cites.wouldAsk(c, store) as Promise<boolean>),
|
|
102
143
|
);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
return (
|
|
145
|
+
!asks.includes(true) &&
|
|
146
|
+
entries.filter(asksDblp).every((e) => cache.dblp.has(dblpTitleKey(e.title)))
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The key a DBLP answer is stored under in the lookup cache: the title's identity (verify-cites'
|
|
152
|
+
* `titleIdentity` — the same rule its own title keys use, so the two caches cannot drift), hashed.
|
|
153
|
+
* A re-cased or re-braced title keeps its answer; `C` and `C++` do not share one.
|
|
154
|
+
*/
|
|
155
|
+
export const dblpTitleKey = (title: string): string =>
|
|
156
|
+
sha256Hex(new TextEncoder().encode(cites.titleIdentity(title))).slice(0, 16);
|
|
157
|
+
|
|
158
|
+
export interface ReferencesCheckerOptions {
|
|
159
|
+
/** The day a new answer is stamped with, YYYY-MM-DD. */
|
|
160
|
+
readonly today: () => string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* bib-authors' `lookup` and `pause`, answering from the cached DBLP hits first.
|
|
165
|
+
*
|
|
166
|
+
* 🔴 DBLP IS ASKED UNTIL IT REFUSES ONCE, THEN NOT AT ALL FOR THE REST OF THE RUN (#120). A 429, an
|
|
167
|
+
* HTML page where JSON was asked, a timeout: bib-authors would retry each title three times with
|
|
168
|
+
* backoff, and move on to the next title to do it again — 51 titles × 3 × up to 15 s. After the
|
|
169
|
+
* first refusal every lookup fails at once with the reason, and no pause is waited, so each entry
|
|
170
|
+
* lands in bib-authors' `unchecked` bucket ("never a pass") for the price of ONE request.
|
|
171
|
+
*
|
|
172
|
+
* The 900 ms pace protects DBLP, so it is kept only after an answer DBLP actually gave: not after a
|
|
173
|
+
* cached answer, and not once DBLP is off.
|
|
174
|
+
*/
|
|
175
|
+
function cachedDblp(cache: LookupCache, today: () => string) {
|
|
176
|
+
const dblp = new Map<string, CachedDblp>(cache.dblp);
|
|
177
|
+
let lastWasCached = false;
|
|
178
|
+
let refused: string | null = null;
|
|
179
|
+
return {
|
|
180
|
+
dblp,
|
|
181
|
+
lookup: async (title: string): Promise<readonly DblpHit[]> => {
|
|
182
|
+
const hit = dblp.get(dblpTitleKey(title));
|
|
183
|
+
lastWasCached = hit !== undefined;
|
|
184
|
+
if (hit) return hit.hits;
|
|
185
|
+
if (refused !== null)
|
|
186
|
+
throw new Error(`DBLP refused earlier in this run (${refused})`);
|
|
187
|
+
try {
|
|
188
|
+
const hits = (await authors.dblpHits(title)) as DblpHit[];
|
|
189
|
+
dblp.set(dblpTitleKey(title), { fetched: today(), title, hits });
|
|
190
|
+
return hits;
|
|
191
|
+
} catch (e) {
|
|
192
|
+
refused = (e as Error).message;
|
|
193
|
+
throw new Error(`DBLP refused earlier in this run (${refused})`);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
pause: (ms: number): Promise<void> =>
|
|
197
|
+
lastWasCached || refused !== null ? Promise.resolve() : sleep(ms),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The cache after a run: the old answers with every new one laid over them, dated today — or the
|
|
203
|
+
* very object passed in when nothing was fetched, so the caller can tell there is nothing to write.
|
|
204
|
+
* "New" is measured against `usable`, the part the run answered from: an answer too old to use was
|
|
205
|
+
* asked again, and its fresh copy replaces the old one. An old answer the run could not refresh
|
|
206
|
+
* (the service refused) stays, and is asked again next build.
|
|
207
|
+
*/
|
|
208
|
+
function grown(
|
|
209
|
+
loaded: { readonly cache: LookupCache; readonly usable: LookupCache },
|
|
210
|
+
run: {
|
|
211
|
+
readonly store: Readonly<Record<string, unknown>>;
|
|
212
|
+
readonly dblp: ReadonlyMap<string, CachedDblp>;
|
|
213
|
+
},
|
|
214
|
+
today: () => string,
|
|
215
|
+
): LookupCache {
|
|
216
|
+
const { cache, usable } = loaded;
|
|
217
|
+
const { store, dblp } = run;
|
|
218
|
+
const fresh = Object.entries(store).filter(([k]) => !usable.citations.has(k));
|
|
219
|
+
const freshDblp = [...dblp].filter(([k]) => !usable.dblp.has(k));
|
|
220
|
+
if (fresh.length === 0 && freshDblp.length === 0) return cache;
|
|
221
|
+
const dated = fresh.map(([k, response]): [string, CachedResponse] => [
|
|
222
|
+
k,
|
|
223
|
+
{ fetched: today(), response: response as CachedResponse["response"] },
|
|
114
224
|
]);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
225
|
+
return {
|
|
226
|
+
citations: new Map([...cache.citations, ...dated]),
|
|
227
|
+
dblp: new Map([...cache.dblp, ...freshDblp]),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The checker, with its clock injected; `onlineReferences` is the one the CLI wires. */
|
|
232
|
+
export const referencesChecker =
|
|
233
|
+
({ today }: ReferencesCheckerOptions): CheckReferences =>
|
|
234
|
+
async (bib, cache) => {
|
|
235
|
+
// Answers past MAX_AGE_DAYS are left out, so the run asks them again (and `grown` re-dates them).
|
|
236
|
+
const usable = freshPart(cache, today());
|
|
237
|
+
const citations = (cites.parseBib(bib) as { id?: string }[]).filter(
|
|
238
|
+
(c) => c.id,
|
|
239
|
+
);
|
|
240
|
+
const parsed = authors.parseBib(bib) as BibAuthorsEntry[];
|
|
241
|
+
const store: Record<string, unknown> = Object.fromEntries(
|
|
242
|
+
[...usable.citations].map(([k, v]) => [k, v.response]),
|
|
243
|
+
);
|
|
244
|
+
if (!(await fullyCached(citations, parsed, usable, store))) {
|
|
245
|
+
const why = await unreachable();
|
|
246
|
+
if (why !== null) return { check: { kind: "not-checked", why }, cache };
|
|
247
|
+
}
|
|
248
|
+
// 🔴 #107: the lookups run LOOKUPS_IN_FLIGHT at a time, and the DBLP author pass runs beside
|
|
249
|
+
// them. Each citation's own requests go to four different services one after another, so a
|
|
250
|
+
// pool of 6 is at most 6 requests to any one of them. The store is shared: two citations with
|
|
251
|
+
// the same identifier may both miss it and ask twice — an extra request, never a different
|
|
252
|
+
// answer.
|
|
253
|
+
// One breaker for the run: a service that refuses is not asked again, not once per citation
|
|
254
|
+
// (#120). Requests already in flight when it starts refusing still land — at most six.
|
|
255
|
+
const breaker = cites.createBreaker() as unknown;
|
|
256
|
+
const d = cachedDblp(usable, today);
|
|
257
|
+
const [found, a] = await Promise.all([
|
|
258
|
+
mapLimit(
|
|
259
|
+
citations,
|
|
260
|
+
LOOKUPS_IN_FLIGHT,
|
|
261
|
+
(c) =>
|
|
262
|
+
cites.verifyCitationLive(c, {
|
|
263
|
+
cache: store,
|
|
264
|
+
breaker,
|
|
265
|
+
}) as Promise<CiteResult>,
|
|
266
|
+
),
|
|
267
|
+
authors.checkAuthors(parsed, {
|
|
268
|
+
lookup: d.lookup,
|
|
269
|
+
pause: d.pause,
|
|
270
|
+
}) as Promise<AuthorBuckets>,
|
|
271
|
+
]);
|
|
272
|
+
const keys = new Set([
|
|
273
|
+
...found.map((c) => c.id),
|
|
274
|
+
...a.findings.map((f) => f.key),
|
|
275
|
+
]);
|
|
276
|
+
const entries = [...keys].map((key) => entryVerdict(key, found, a));
|
|
277
|
+
return {
|
|
278
|
+
check: { kind: "checked", entries },
|
|
279
|
+
cache: grown({ cache, usable }, { store, dblp: d.dblp }, today),
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** The checker the CLI wires: the real clock. */
|
|
284
|
+
export const onlineReferences: CheckReferences = referencesChecker({
|
|
285
|
+
today: todayUtc,
|
|
286
|
+
});
|
package/src/build.ts
CHANGED
|
@@ -562,6 +562,10 @@ export const measureStep: BuildStep = {
|
|
|
562
562
|
* authors are the published version's (bib-authors) — and record the verdicts in
|
|
563
563
|
* `_build/references.json` for the offline lint rules (`reference-rules.ts`). OPTIONAL and NEVER
|
|
564
564
|
* FAILING: without network the PDF is still built, and the record says `not-checked`.
|
|
565
|
+
*
|
|
566
|
+
* What the services answered is read from and added to `<paper>/repro/references-cache.json`
|
|
567
|
+
* (`references.ts`, `domain/lookup-cache.ts`), committed with the paper, so only a new or edited
|
|
568
|
+
* entry is asked, and an unchanged bibliography is checked with no network at all (#107).
|
|
565
569
|
*/
|
|
566
570
|
export const referencesStep: BuildStep = {
|
|
567
571
|
name: "references",
|
|
@@ -665,9 +669,12 @@ function baseDefaults({
|
|
|
665
669
|
}
|
|
666
670
|
|
|
667
671
|
/** Without a checker the record says so — never a pass. The CLI wires the real one. */
|
|
668
|
-
const notWired: CheckReferences = async () => ({
|
|
669
|
-
|
|
670
|
-
|
|
672
|
+
const notWired: CheckReferences = async (_bib, cache) => ({
|
|
673
|
+
check: {
|
|
674
|
+
kind: "not-checked",
|
|
675
|
+
why: "no reference checker was wired into this build",
|
|
676
|
+
},
|
|
677
|
+
cache,
|
|
671
678
|
});
|
|
672
679
|
|
|
673
680
|
/** banal as the measurer, wired from the build's environment: the one piece of root work left here (#76). */
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
freshPart,
|
|
4
|
+
EMPTY_LOOKUP_CACHE,
|
|
5
|
+
parseLookupCache,
|
|
6
|
+
serializeLookupCache,
|
|
7
|
+
type LookupCache,
|
|
8
|
+
} from "./lookup-cache.ts";
|
|
9
|
+
import { err, ok } from "./result.ts";
|
|
10
|
+
|
|
11
|
+
const HIT = {
|
|
12
|
+
venue: "ICSE",
|
|
13
|
+
year: "2024",
|
|
14
|
+
type: "Conference and Workshop Papers",
|
|
15
|
+
title: "Good Paper.",
|
|
16
|
+
authors: ["Ada Lovelace"],
|
|
17
|
+
};
|
|
18
|
+
const CACHE: LookupCache = {
|
|
19
|
+
citations: new Map([
|
|
20
|
+
[
|
|
21
|
+
"crossref:doi:10.1/good",
|
|
22
|
+
{
|
|
23
|
+
fetched: "2026-09-27",
|
|
24
|
+
response: { db: "crossref", transport: "ok", query: "doi" },
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
]),
|
|
28
|
+
dblp: new Map([
|
|
29
|
+
[
|
|
30
|
+
"0123456789abcdef",
|
|
31
|
+
{ fetched: "2026-09-26", title: "Good Paper", hits: [HIT] },
|
|
32
|
+
],
|
|
33
|
+
]),
|
|
34
|
+
};
|
|
35
|
+
const doc = (o: object): string => JSON.stringify({ schema: 1, ...o });
|
|
36
|
+
|
|
37
|
+
describe("serializeLookupCache / parseLookupCache", () => {
|
|
38
|
+
it("round-trips, keys sorted, one trailing newline", () => {
|
|
39
|
+
const text = serializeLookupCache(CACHE);
|
|
40
|
+
expect(text).toBe(
|
|
41
|
+
`${JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
schema: 1,
|
|
44
|
+
citations: {
|
|
45
|
+
"crossref:doi:10.1/good": {
|
|
46
|
+
fetched: "2026-09-27",
|
|
47
|
+
response: { db: "crossref", transport: "ok", query: "doi" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
dblp: {
|
|
51
|
+
["0123456789abcdef"]: {
|
|
52
|
+
fetched: "2026-09-26",
|
|
53
|
+
title: "Good Paper",
|
|
54
|
+
hits: [HIT],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
null,
|
|
59
|
+
2,
|
|
60
|
+
)}\n`,
|
|
61
|
+
);
|
|
62
|
+
expect(parseLookupCache(text)).toEqual(ok(CACHE));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("the empty cache writes both sections, and reads back as empty", () => {
|
|
66
|
+
expect(parseLookupCache(serializeLookupCache(EMPTY_LOOKUP_CACHE))).toEqual(
|
|
67
|
+
ok(EMPTY_LOOKUP_CACHE),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Guards: a committed file that is wrong in any way is refused with a NAMED reason. Read as
|
|
73
|
+
// empty, it would silently turn every build into a full online run — the defect this file fixes.
|
|
74
|
+
const REFUSALS: [string, string][] = [
|
|
75
|
+
["[]", "expected an object with `schema`, `citations` and `dblp`"],
|
|
76
|
+
[doc({ schema: 2 }), "schema 2: this paperlint reads schema 1"],
|
|
77
|
+
[doc({ citations: {}, dblp: {}, x: 1 }), "unknown field `x`"],
|
|
78
|
+
[doc({ dblp: {} }), "`citations`: expected an object"],
|
|
79
|
+
[doc({ citations: {} }), "`dblp`: expected an object"],
|
|
80
|
+
[doc({ citations: { k: 1 }, dblp: {} }), "`citations.k`: expected an object"],
|
|
81
|
+
[
|
|
82
|
+
doc({
|
|
83
|
+
citations: { k: { fetched: "2026-9-1", response: {} } },
|
|
84
|
+
dblp: {},
|
|
85
|
+
}),
|
|
86
|
+
"`citations.k.fetched`: expected a date, YYYY-MM-DD",
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
doc({
|
|
90
|
+
citations: {
|
|
91
|
+
k: { fetched: "2026-09-01", response: { transport: "error" } },
|
|
92
|
+
},
|
|
93
|
+
dblp: {},
|
|
94
|
+
}),
|
|
95
|
+
'`citations.k.response`: expected a successful response ({"transport": "ok", …})',
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
doc({
|
|
99
|
+
citations: {
|
|
100
|
+
k: { fetched: "2026-09-01", response: { transport: "ok" }, y: 1 },
|
|
101
|
+
},
|
|
102
|
+
dblp: {},
|
|
103
|
+
}),
|
|
104
|
+
"`citations.k`: unknown field `y`",
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
doc({
|
|
108
|
+
citations: {},
|
|
109
|
+
dblp: { t: { fetched: "2026-09-01", title: 3, hits: [] } },
|
|
110
|
+
}),
|
|
111
|
+
"`dblp.t.title`: expected a string",
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
doc({
|
|
115
|
+
citations: {},
|
|
116
|
+
dblp: { t: { fetched: "2026-09-01", title: "T", hits: {} } },
|
|
117
|
+
}),
|
|
118
|
+
"`dblp.t.hits`: expected an array",
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
doc({
|
|
122
|
+
citations: {},
|
|
123
|
+
dblp: {
|
|
124
|
+
t: {
|
|
125
|
+
fetched: "2026-09-01",
|
|
126
|
+
title: "T",
|
|
127
|
+
hits: [{ ...HIT, authors: [1] }],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
"`dblp.t.hits[0]`: expected {venue, year, type, title: string; authors: string[]}",
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
doc({
|
|
135
|
+
citations: {},
|
|
136
|
+
dblp: { t: { fetched: "2026-09-01", title: "T", hits: [3] } },
|
|
137
|
+
}),
|
|
138
|
+
"`dblp.t.hits[0]`: expected {venue, year, type, title: string; authors: string[]}",
|
|
139
|
+
],
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
describe("parseLookupCache refusals", () => {
|
|
143
|
+
it("refuses text that is not JSON, saying so", () => {
|
|
144
|
+
const r = parseLookupCache("{");
|
|
145
|
+
// The rest of the message is Node's parser; its wording is not the subject.
|
|
146
|
+
expect(r.ok ? "" : r.error).toMatch(/^not JSON: /);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Guards: see REFUSALS.
|
|
150
|
+
it.each(REFUSALS)("refuses %s with a named reason", (text, why) => {
|
|
151
|
+
expect(parseLookupCache(text)).toEqual(err(why));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("freshPart", () => {
|
|
156
|
+
const cache = (fetched: string): LookupCache => ({
|
|
157
|
+
citations: new Map([
|
|
158
|
+
[
|
|
159
|
+
"k",
|
|
160
|
+
{
|
|
161
|
+
fetched,
|
|
162
|
+
response: { db: "crossref", transport: "ok", query: "doi" },
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
]),
|
|
166
|
+
dblp: new Map(),
|
|
167
|
+
});
|
|
168
|
+
// Guards: the age window has two ends. A date in the future (a clock ahead, a typo) has a
|
|
169
|
+
// negative age; read as fresh, a miss would be trusted until that date plus 30 days.
|
|
170
|
+
it.each([
|
|
171
|
+
["2026-09-27", 1],
|
|
172
|
+
["2026-08-28", 1],
|
|
173
|
+
["2026-08-27", 0],
|
|
174
|
+
["2026-09-28", 0],
|
|
175
|
+
])("on 2026-09-27, an answer fetched %s is kept %i time(s)", (fetched, n) => {
|
|
176
|
+
expect(freshPart(cache(fetched), "2026-09-27").citations.size).toBe(n);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LOOKUP CACHE — `<paper>/repro/references-cache.json`: what the citation services ANSWERED,
|
|
3
|
+
* kept so that a build asks each question at most once every MAX_AGE_DAYS, not once per build (#107).
|
|
4
|
+
*
|
|
5
|
+
* { "schema": 1,
|
|
6
|
+
* "citations": { "<resolver>:<identifier>": { "fetched": "YYYY-MM-DD", "response": {…} } },
|
|
7
|
+
* "dblp": { "<title key>": { "fetched": "YYYY-MM-DD", "title": "…", "hits": […] } } }
|
|
8
|
+
*
|
|
9
|
+
* `citations` holds verify-cites' per-resolver responses (Crossref, OpenAlex, Semantic Scholar,
|
|
10
|
+
* arXiv, doi.org, NVD), under verify-cites' own cache keys. `dblp` holds the hits DBLP returned for
|
|
11
|
+
* a title, under the references adapter's `dblpTitleKey`. Only successful answers are ever stored: a failed request is not an
|
|
12
|
+
* answer, so the next build asks again.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 RESPONSES, NEVER VERDICTS. The verdicts (`_build/references.json`) are DERIVED from these
|
|
15
|
+
* responses on every build, by the current version of the checkers. A cached verdict would freeze
|
|
16
|
+
* whatever the checker believed the day it ran — a bug fixed in the comparison would never reach a
|
|
17
|
+
* paper whose entries are cached. A cached response is evidence: it cannot be made up, only
|
|
18
|
+
* refetched. The precedent is `extract-ref-facts.mjs`'s `repro/refs-cache.json` ("the cache is
|
|
19
|
+
* evidence, not a second declaration").
|
|
20
|
+
*
|
|
21
|
+
* 🔴 COMMITTED, so CI and every other clone start warm: a build whose entries are all here makes no
|
|
22
|
+
* network request at all, not even the reachability probe. It lives in `repro/`, beside the paper,
|
|
23
|
+
* not in `_build/`, which is build output and gitignored.
|
|
24
|
+
*
|
|
25
|
+
* WHAT INVALIDATES AN ENTRY: its key, or its age. Editing a bib entry's DOI, arXiv id or title
|
|
26
|
+
* changes the keys it is looked up under, so only that entry is asked again. An answer older than
|
|
27
|
+
* MAX_AGE_DAYS (by `fetched`) is asked again too, so a work a service had not indexed yet is found
|
|
28
|
+
* once it is. To refresh an answer sooner, delete its entry, or the file.
|
|
29
|
+
*
|
|
30
|
+
* Parsed here, once, at the boundary: a file that does not match is refused with a named reason,
|
|
31
|
+
* never read as empty.
|
|
32
|
+
*/
|
|
33
|
+
import { err, ok, type Result } from "./result.ts";
|
|
34
|
+
|
|
35
|
+
export const LOOKUP_CACHE_SCHEMA = 1;
|
|
36
|
+
|
|
37
|
+
type Json = Readonly<Record<string, unknown>>;
|
|
38
|
+
|
|
39
|
+
/** One resolver's successful answer, as verify-cites shapes it (`{ transport: "ok", … }`). */
|
|
40
|
+
export interface CachedResponse {
|
|
41
|
+
/** When it was fetched, YYYY-MM-DD. */
|
|
42
|
+
readonly fetched: string;
|
|
43
|
+
readonly response: Json;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One DBLP hit, as bib-authors' `dblpHits` shapes it. */
|
|
47
|
+
export interface DblpHit {
|
|
48
|
+
readonly venue: string;
|
|
49
|
+
readonly year: string;
|
|
50
|
+
readonly type: string;
|
|
51
|
+
readonly title: string;
|
|
52
|
+
readonly authors: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CachedDblp {
|
|
56
|
+
readonly fetched: string;
|
|
57
|
+
/** The title as it was asked — for a human reading the file; the key is the adapter's `dblpTitleKey(title)`. */
|
|
58
|
+
readonly title: string;
|
|
59
|
+
readonly hits: readonly DblpHit[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LookupCache {
|
|
63
|
+
readonly citations: ReadonlyMap<string, CachedResponse>;
|
|
64
|
+
readonly dblp: ReadonlyMap<string, CachedDblp>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const EMPTY_LOOKUP_CACHE: LookupCache = {
|
|
68
|
+
citations: new Map(),
|
|
69
|
+
dblp: new Map(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How long a cached answer is used before it is asked again. A registry, doi.org or DBLP that had
|
|
74
|
+
* not indexed a work yet answers "no record"; kept forever, that answer would outlive the work's
|
|
75
|
+
* indexing. The age is judged against `fetched`, so every answer — a hit or a miss — gets the
|
|
76
|
+
* same rule, and nothing has to decide which answers are "negative".
|
|
77
|
+
*/
|
|
78
|
+
export const MAX_AGE_DAYS = 30;
|
|
79
|
+
|
|
80
|
+
const dayNumber = (date: string): number =>
|
|
81
|
+
Date.parse(`${date}T00:00:00Z`) / 86_400_000;
|
|
82
|
+
|
|
83
|
+
/** The part of `cache` still young enough to answer from on `today` (YYYY-MM-DD). */
|
|
84
|
+
export function freshPart(cache: LookupCache, today: string): LookupCache {
|
|
85
|
+
// An age below zero is a date in the future (a clock ahead, a typo): not trusted, asked again.
|
|
86
|
+
const young = ({ fetched }: { fetched: string }): boolean => {
|
|
87
|
+
const age = dayNumber(today) - dayNumber(fetched);
|
|
88
|
+
return age >= 0 && age <= MAX_AGE_DAYS;
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
citations: new Map([...cache.citations].filter(([, v]) => young(v))),
|
|
92
|
+
dblp: new Map([...cache.dblp].filter(([, v]) => young(v))),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isObject = (v: unknown): v is Json =>
|
|
97
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
98
|
+
const unknownField = (o: Json, known: readonly string[]): string | undefined =>
|
|
99
|
+
Object.keys(o).find((k) => !known.includes(k));
|
|
100
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
101
|
+
const isDate = (v: unknown): v is string =>
|
|
102
|
+
typeof v === "string" && DATE.test(v);
|
|
103
|
+
|
|
104
|
+
/** Parse every value of `section` with `one`, or the first named reason. */
|
|
105
|
+
function sectionOf<T>(
|
|
106
|
+
name: string,
|
|
107
|
+
section: unknown,
|
|
108
|
+
known: readonly string[],
|
|
109
|
+
one: (o: Json, at: string) => Result<T, string>,
|
|
110
|
+
): Result<Map<string, T>, string> {
|
|
111
|
+
if (!isObject(section)) return err(`\`${name}\`: expected an object`);
|
|
112
|
+
const out = new Map<string, T>();
|
|
113
|
+
for (const [key, v] of Object.entries(section)) {
|
|
114
|
+
const at = `\`${name}.${key}`;
|
|
115
|
+
if (!isObject(v)) return err(`${at}\`: expected an object`);
|
|
116
|
+
const extra = unknownField(v, known);
|
|
117
|
+
if (extra !== undefined) return err(`${at}\`: unknown field \`${extra}\``);
|
|
118
|
+
if (!isDate(v["fetched"]))
|
|
119
|
+
return err(`${at}.fetched\`: expected a date, YYYY-MM-DD`);
|
|
120
|
+
const r = one(v, at);
|
|
121
|
+
if (!r.ok) return r;
|
|
122
|
+
out.set(key, r.value);
|
|
123
|
+
}
|
|
124
|
+
return ok(out);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cachedResponse = (o: Json, at: string): Result<CachedResponse, string> =>
|
|
128
|
+
isObject(o["response"]) && o["response"]["transport"] === "ok"
|
|
129
|
+
? ok({ fetched: o["fetched"] as string, response: o["response"] })
|
|
130
|
+
: err(
|
|
131
|
+
`${at}.response\`: expected a successful response ({"transport": "ok", …})`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const isHit = (h: unknown): h is DblpHit =>
|
|
135
|
+
isObject(h) &&
|
|
136
|
+
["venue", "year", "type", "title"].every((k) => typeof h[k] === "string") &&
|
|
137
|
+
Array.isArray(h["authors"]) &&
|
|
138
|
+
h["authors"].every((a) => typeof a === "string");
|
|
139
|
+
|
|
140
|
+
function cachedDblp(o: Json, at: string): Result<CachedDblp, string> {
|
|
141
|
+
const { title, hits } = o;
|
|
142
|
+
if (typeof title !== "string") return err(`${at}.title\`: expected a string`);
|
|
143
|
+
if (!Array.isArray(hits)) return err(`${at}.hits\`: expected an array`);
|
|
144
|
+
const bad = hits.findIndex((h) => !isHit(h));
|
|
145
|
+
if (bad !== -1)
|
|
146
|
+
return err(
|
|
147
|
+
`${at}.hits[${String(bad)}]\`: expected {venue, year, type, title: string; authors: string[]}`,
|
|
148
|
+
);
|
|
149
|
+
return ok({
|
|
150
|
+
fetched: o["fetched"] as string,
|
|
151
|
+
title,
|
|
152
|
+
hits: hits as DblpHit[],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The cache in `text`, or why it cannot be read. */
|
|
157
|
+
export function parseLookupCache(text: string): Result<LookupCache, string> {
|
|
158
|
+
let json: unknown;
|
|
159
|
+
try {
|
|
160
|
+
json = JSON.parse(text);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return err(`not JSON: ${(e as Error).message}`);
|
|
163
|
+
}
|
|
164
|
+
if (!isObject(json))
|
|
165
|
+
return err("expected an object with `schema`, `citations` and `dblp`");
|
|
166
|
+
if (json["schema"] !== LOOKUP_CACHE_SCHEMA)
|
|
167
|
+
return err(
|
|
168
|
+
`schema ${String(json["schema"])}: this paperlint reads schema ${String(LOOKUP_CACHE_SCHEMA)}`,
|
|
169
|
+
);
|
|
170
|
+
const extra = unknownField(json, ["schema", "citations", "dblp"]);
|
|
171
|
+
if (extra !== undefined) return err(`unknown field \`${extra}\``);
|
|
172
|
+
const citations = sectionOf(
|
|
173
|
+
"citations",
|
|
174
|
+
json["citations"],
|
|
175
|
+
["fetched", "response"],
|
|
176
|
+
cachedResponse,
|
|
177
|
+
);
|
|
178
|
+
if (!citations.ok) return citations;
|
|
179
|
+
const dblp = sectionOf(
|
|
180
|
+
"dblp",
|
|
181
|
+
json["dblp"],
|
|
182
|
+
["fetched", "title", "hits"],
|
|
183
|
+
cachedDblp,
|
|
184
|
+
);
|
|
185
|
+
if (!dblp.ok) return dblp;
|
|
186
|
+
return ok({ citations: citations.value, dblp: dblp.value });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sorted = <T>(m: ReadonlyMap<string, T>): Record<string, T> =>
|
|
190
|
+
Object.fromEntries([...m.keys()].sort().map((k) => [k, m.get(k) as T]));
|
|
191
|
+
|
|
192
|
+
/** The cache as the file holds it: keys sorted, so one new answer is a small diff. */
|
|
193
|
+
export const serializeLookupCache = (c: LookupCache): string =>
|
|
194
|
+
`${JSON.stringify(
|
|
195
|
+
{
|
|
196
|
+
schema: LOOKUP_CACHE_SCHEMA,
|
|
197
|
+
citations: sorted(c.citations),
|
|
198
|
+
dblp: sorted(c.dblp),
|
|
199
|
+
},
|
|
200
|
+
null,
|
|
201
|
+
2,
|
|
202
|
+
)}\n`;
|