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
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
* + a summary line on stderr. Exit 1 iff any verdict is `false`.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
49
|
-
import {
|
|
48
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
49
|
+
import { homedir } from "node:os";
|
|
50
50
|
import { dirname, join, extname } from "node:path";
|
|
51
51
|
import { createHash } from "node:crypto";
|
|
52
52
|
import {
|
|
@@ -54,11 +54,13 @@ import {
|
|
|
54
54
|
isMain,
|
|
55
55
|
} from "../../paper-pipeline/scripts/consumer.mjs";
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
// points it elsewhere — how a test keeps the
|
|
57
|
+
// The command line's cache (`paperlint build` passes its own, per paper, and never reads this file).
|
|
58
|
+
// In the user's cache directory, NOT beside this script: installed, this script sits inside
|
|
59
|
+
// node_modules, which `npm ci` wipes. VERIFY_CITES_CACHE points it elsewhere — how a test keeps the
|
|
60
|
+
// run from writing into the home directory.
|
|
60
61
|
const CACHE_PATH =
|
|
61
|
-
process.env.VERIFY_CITES_CACHE ||
|
|
62
|
+
process.env.VERIFY_CITES_CACHE ||
|
|
63
|
+
join(homedir(), ".cache", "paperlint", "cite-cache.json");
|
|
62
64
|
// 🔴 THE ADDRESS IS THE CONSUMER'S, NOT OURS. Crossref's "polite pool" keys off this `mailto:`:
|
|
63
65
|
// it decides who gets the faster tier and, more to the point, WHOM THEY WARN before blocking.
|
|
64
66
|
// Hard-coded, it pointed at one person for every user of this package — so the warnings would
|
|
@@ -154,13 +156,23 @@ export function normalizeArxiv(arxiv) {
|
|
|
154
156
|
.trim();
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/** arXiv registers a DataCite DOI for every paper: `10.48550/arXiv.<id>`. */
|
|
160
|
+
const ARXIV_DOI = /^10\.48550\/arxiv\.(.+)$/i;
|
|
161
|
+
|
|
157
162
|
/** Shallow copy of a citation with doi/arxiv identifiers normalized. Applied at the
|
|
158
163
|
* verify boundary so BOTH JSON input and .bib-parsed input are clean before any
|
|
159
|
-
* network / authority lookup.
|
|
164
|
+
* network / authority lookup.
|
|
165
|
+
*
|
|
166
|
+
* An arXiv DOI also yields the arXiv id it names, when the entry has none of its own: without
|
|
167
|
+
* it arXiv is asked only by title, and only for an entry with no DOI, so a registry that maps
|
|
168
|
+
* the DOI to the wrong work goes uncontradicted (OpenAlex did, for SWE-bench, on 2026-09-27). */
|
|
160
169
|
export function normalizeIdentifiers(citation) {
|
|
161
170
|
const c = { ...citation };
|
|
162
171
|
if (c.doi) c.doi = normalizeDoi(c.doi);
|
|
163
172
|
if (c.arxiv) c.arxiv = normalizeArxiv(c.arxiv);
|
|
173
|
+
// Decided on the NORMALIZED id: a blank field normalizes to "" and must not block the DOI's id.
|
|
174
|
+
const fromDoi = c.doi ? ARXIV_DOI.exec(c.doi)?.[1] : undefined;
|
|
175
|
+
if (!c.arxiv && fromDoi) c.arxiv = fromDoi;
|
|
164
176
|
return c;
|
|
165
177
|
}
|
|
166
178
|
|
|
@@ -686,17 +698,32 @@ function loadCache() {
|
|
|
686
698
|
}
|
|
687
699
|
function saveCache(cache) {
|
|
688
700
|
try {
|
|
701
|
+
mkdirSync(dirname(CACHE_PATH), { recursive: true });
|
|
689
702
|
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
|
|
690
703
|
} catch {
|
|
691
704
|
/* best-effort; a cache write failure must never fail the run */
|
|
692
705
|
}
|
|
693
706
|
}
|
|
694
707
|
|
|
708
|
+
/**
|
|
709
|
+
* A title's IDENTITY, for cache keys: case, accents, BibTeX braces and runs of whitespace
|
|
710
|
+
* normalized, punctuation kept. Not `normalizeTitle`, which drops punctuation so that `Title: Sub`
|
|
711
|
+
* and `Title - Sub` still COMPARE equal — as a key that would make `C` and `C++` one entry, and
|
|
712
|
+
* the second would be served the first's answers. A punctuation-only variant costs one extra
|
|
713
|
+
* lookup instead. The one owner of this rule: DBLP's cache key (the references adapter) uses it too.
|
|
714
|
+
*/
|
|
715
|
+
export function titleIdentity(t) {
|
|
716
|
+
return (t ?? "")
|
|
717
|
+
.toLowerCase()
|
|
718
|
+
.normalize("NFKD")
|
|
719
|
+
.replace(/\p{M}+/gu, "")
|
|
720
|
+
.replace(/[{}]/g, "")
|
|
721
|
+
.replace(/\s+/g, " ")
|
|
722
|
+
.trim();
|
|
723
|
+
}
|
|
724
|
+
|
|
695
725
|
function titleHash(t) {
|
|
696
|
-
return createHash("sha1")
|
|
697
|
-
.update(normalizeTitle(t))
|
|
698
|
-
.digest("hex")
|
|
699
|
-
.slice(0, 16);
|
|
726
|
+
return createHash("sha1").update(titleIdentity(t)).digest("hex").slice(0, 16);
|
|
700
727
|
}
|
|
701
728
|
function cacheKey(kind, val) {
|
|
702
729
|
return `${kind}:${val}`;
|
|
@@ -716,8 +743,20 @@ async function request(url, json, signal) {
|
|
|
716
743
|
}
|
|
717
744
|
if (res.status === 404) return { ok: true, notFound: true };
|
|
718
745
|
if (!res.ok) return { ok: false, reason: `http ${res.status}` };
|
|
719
|
-
const
|
|
720
|
-
return { ok: true, data };
|
|
746
|
+
const text = await res.text();
|
|
747
|
+
if (!json) return { ok: true, data: text };
|
|
748
|
+
// 🔴 A service that is rate-limiting some clients answers 200 with an HTML page instead of JSON
|
|
749
|
+
// (DBLP does, #110). That is a refusal, named as one — not a parse error to puzzle over.
|
|
750
|
+
try {
|
|
751
|
+
return { ok: true, data: JSON.parse(text) };
|
|
752
|
+
} catch {
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
reason: text.trimStart().startsWith("<")
|
|
756
|
+
? "answered HTML, not JSON"
|
|
757
|
+
: "answered something that is not JSON",
|
|
758
|
+
};
|
|
759
|
+
}
|
|
721
760
|
}
|
|
722
761
|
|
|
723
762
|
async function httpGet(url, { json = true } = {}) {
|
|
@@ -740,7 +779,7 @@ async function crossrefResolve(citation) {
|
|
|
740
779
|
const r = await httpGet(
|
|
741
780
|
`https://api.crossref.org/works/${encodeURIComponent(citation.doi)}`,
|
|
742
781
|
);
|
|
743
|
-
if (!r.ok) return { db, transport: "error" };
|
|
782
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
744
783
|
if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
|
|
745
784
|
const w = r.data?.message;
|
|
746
785
|
return {
|
|
@@ -758,24 +797,22 @@ async function crossrefResolve(citation) {
|
|
|
758
797
|
: null,
|
|
759
798
|
};
|
|
760
799
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
778
|
-
return null;
|
|
800
|
+
// Asked only with a DOI or a title (`PAPER_RESOLVERS`), so without a DOI there is a title.
|
|
801
|
+
const url = `https://api.crossref.org/works?query.bibliographic=${encodeURIComponent(
|
|
802
|
+
citation.title,
|
|
803
|
+
)}&rows=5`;
|
|
804
|
+
const r = await httpGet(url);
|
|
805
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
806
|
+
const items = r.data?.message?.items || [];
|
|
807
|
+
return {
|
|
808
|
+
db,
|
|
809
|
+
transport: "ok",
|
|
810
|
+
query: "title",
|
|
811
|
+
records: items.map((w) => ({
|
|
812
|
+
title: (w.title || [])[0] || "",
|
|
813
|
+
year: crYear(w),
|
|
814
|
+
})),
|
|
815
|
+
};
|
|
779
816
|
}
|
|
780
817
|
function crYear(w) {
|
|
781
818
|
return (
|
|
@@ -791,7 +828,7 @@ async function openalexResolve(citation) {
|
|
|
791
828
|
const r = await httpGet(
|
|
792
829
|
`https://api.openalex.org/works/doi:${encodeURIComponent(citation.doi)}`,
|
|
793
830
|
);
|
|
794
|
-
if (!r.ok) return { db, transport: "error" };
|
|
831
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
795
832
|
if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
|
|
796
833
|
const w = r.data;
|
|
797
834
|
return {
|
|
@@ -803,23 +840,21 @@ async function openalexResolve(citation) {
|
|
|
803
840
|
: null,
|
|
804
841
|
};
|
|
805
842
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
822
|
-
return null;
|
|
843
|
+
// Asked only with a DOI or a title (`PAPER_RESOLVERS`), so without a DOI there is a title.
|
|
844
|
+
const r = await httpGet(
|
|
845
|
+
`https://api.openalex.org/works?search=${encodeURIComponent(citation.title)}&per-page=5`,
|
|
846
|
+
);
|
|
847
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
848
|
+
const items = r.data?.results || [];
|
|
849
|
+
return {
|
|
850
|
+
db,
|
|
851
|
+
transport: "ok",
|
|
852
|
+
query: "title",
|
|
853
|
+
records: items.map((w) => ({
|
|
854
|
+
title: w.display_name || "",
|
|
855
|
+
year: w.publication_year,
|
|
856
|
+
})),
|
|
857
|
+
};
|
|
823
858
|
}
|
|
824
859
|
|
|
825
860
|
const S2 = "https://api.semanticscholar.org/graph/v1/paper";
|
|
@@ -836,7 +871,7 @@ async function semanticScholarById(citation) {
|
|
|
836
871
|
const query = citation.doi ? "doi" : "arxiv";
|
|
837
872
|
const id = citation.doi ? `DOI:${citation.doi}` : `arXiv:${citation.arxiv}`;
|
|
838
873
|
const r = await httpGet(`${S2}/${encodeURIComponent(id)}?fields=title,year`);
|
|
839
|
-
if (!r.ok) return { db, transport: "error" };
|
|
874
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
840
875
|
if (r.notFound) return { db, transport: "ok", query, record: null };
|
|
841
876
|
const w = r.data;
|
|
842
877
|
return {
|
|
@@ -852,7 +887,7 @@ async function semanticScholarByTitle(title) {
|
|
|
852
887
|
const r = await httpGet(
|
|
853
888
|
`${S2}/search?query=${encodeURIComponent(title)}&fields=title,year&limit=5`,
|
|
854
889
|
);
|
|
855
|
-
if (!r.ok) return { db, transport: "error" };
|
|
890
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
856
891
|
const items = r.data?.data || [];
|
|
857
892
|
return {
|
|
858
893
|
db,
|
|
@@ -870,26 +905,24 @@ async function arxivResolve(citation) {
|
|
|
870
905
|
`https://export.arxiv.org/api/query?id_list=${encodeURIComponent(citation.arxiv)}&max_results=1`,
|
|
871
906
|
{ json: false },
|
|
872
907
|
);
|
|
873
|
-
if (!r.ok) return { db, transport: "error" };
|
|
908
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
874
909
|
const entry = parseArxivFeed(r.data);
|
|
875
910
|
return { db, transport: "ok", query: "arxiv", record: entry[0] ?? null };
|
|
876
911
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
}
|
|
892
|
-
return null;
|
|
912
|
+
// Asked only with an arXiv id or a title and no DOI (`PAPER_RESOLVERS`).
|
|
913
|
+
const r = await httpGet(
|
|
914
|
+
`https://export.arxiv.org/api/query?search_query=ti:${encodeURIComponent(
|
|
915
|
+
'"' + citation.title + '"',
|
|
916
|
+
)}&max_results=5`,
|
|
917
|
+
{ json: false },
|
|
918
|
+
);
|
|
919
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
920
|
+
return {
|
|
921
|
+
db,
|
|
922
|
+
transport: "ok",
|
|
923
|
+
query: "title",
|
|
924
|
+
records: parseArxivFeed(r.data),
|
|
925
|
+
};
|
|
893
926
|
}
|
|
894
927
|
export function parseArxivFeed(xml) {
|
|
895
928
|
const out = [];
|
|
@@ -923,7 +956,7 @@ async function doiAuthorityCheck(doi) {
|
|
|
923
956
|
const path = encodeURIComponent(doi.trim()).replace(/%2F/gi, "/");
|
|
924
957
|
const r = await httpGet(`https://doi.org/api/handles/${path}`);
|
|
925
958
|
if (r.notFound) return { transport: "ok", responseCode: 100 }; // 404 = not found
|
|
926
|
-
if (!r.ok) return { transport: "error" };
|
|
959
|
+
if (!r.ok) return { transport: "error", reason: r.reason };
|
|
927
960
|
const code = r.data?.responseCode;
|
|
928
961
|
return {
|
|
929
962
|
transport: "ok",
|
|
@@ -941,31 +974,190 @@ async function nvdCheck(cve) {
|
|
|
941
974
|
cve.toUpperCase(),
|
|
942
975
|
)}`,
|
|
943
976
|
);
|
|
944
|
-
if (!r.ok) return { transport: "error" };
|
|
977
|
+
if (!r.ok) return { transport: "error", reason: r.reason };
|
|
945
978
|
if (r.notFound) return { transport: "ok", found: false };
|
|
946
979
|
const total = r.data?.totalResults ?? (r.data?.vulnerabilities || []).length;
|
|
947
980
|
return { transport: "ok", found: total > 0 };
|
|
948
981
|
}
|
|
949
982
|
|
|
950
983
|
/**
|
|
951
|
-
*
|
|
984
|
+
* The paper registries, each with the ONE question it asks of a citation: `ask` picks the
|
|
985
|
+
* identifier (`{ doi }`, `{ arxiv }` or `{ title }`), or `null` when the registry cannot answer this
|
|
986
|
+
* citation — then it is not called and stores nothing. The resolver is handed ONLY that question,
|
|
987
|
+
* and the cache key is built from the same question, so the key always names what was asked: arXiv
|
|
988
|
+
* asked by arXiv id is keyed by it even when the entry also has a DOI, and correcting that id asks
|
|
989
|
+
* again.
|
|
990
|
+
*/
|
|
991
|
+
const PAPER_RESOLVERS = [
|
|
992
|
+
{
|
|
993
|
+
name: "crossref",
|
|
994
|
+
fn: crossrefResolve,
|
|
995
|
+
ask: (c) => (c.doi ? { doi: c.doi } : c.title ? { title: c.title } : null),
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
name: "openalex",
|
|
999
|
+
fn: openalexResolve,
|
|
1000
|
+
ask: (c) => (c.doi ? { doi: c.doi } : c.title ? { title: c.title } : null),
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
name: "semantic_scholar",
|
|
1004
|
+
fn: semanticScholarResolve,
|
|
1005
|
+
ask: (c) =>
|
|
1006
|
+
c.doi
|
|
1007
|
+
? { doi: c.doi }
|
|
1008
|
+
: c.arxiv
|
|
1009
|
+
? { arxiv: c.arxiv }
|
|
1010
|
+
: { title: c.title },
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
name: "arxiv",
|
|
1014
|
+
fn: arxivResolve,
|
|
1015
|
+
// No DOI endpoint: by arXiv id, else a title search — but not for a DOI-only entry.
|
|
1016
|
+
ask: (c) =>
|
|
1017
|
+
c.arxiv
|
|
1018
|
+
? { arxiv: c.arxiv }
|
|
1019
|
+
: c.title && !c.doi
|
|
1020
|
+
? { title: c.title }
|
|
1021
|
+
: null,
|
|
1022
|
+
},
|
|
1023
|
+
];
|
|
1024
|
+
|
|
1025
|
+
/** The cache key of one registry's question: the identifier it was asked by. */
|
|
1026
|
+
const paperKey = (name, q) =>
|
|
1027
|
+
cacheKey(
|
|
1028
|
+
name,
|
|
1029
|
+
q.doi
|
|
1030
|
+
? `doi:${q.doi}`
|
|
1031
|
+
: q.arxiv
|
|
1032
|
+
? `arxiv:${q.arxiv}`
|
|
1033
|
+
: `title:${titleHash(q.title)}`,
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The evidence for one citation, asked in order through `lookup(key, service, fetch, keep)`, which
|
|
1038
|
+
* decides where each answer comes from (the cache, the network, or nowhere). ONE code path for the
|
|
1039
|
+
* live run and for `wouldAsk`, so the two cannot disagree about which questions a citation asks.
|
|
1040
|
+
*
|
|
1041
|
+
* 🔴 IT STOPS AT THE FIRST MATCH. `reduceVerdict` lets a match win over everything else, so once one
|
|
1042
|
+
* registry has confirmed the work, no further answer can change the verdict — asking Semantic
|
|
1043
|
+
* Scholar after Crossref confirmed a DOI only costs a request, and a rate-limited one at that.
|
|
1044
|
+
*/
|
|
1045
|
+
async function consult(citation, lookup) {
|
|
1046
|
+
const evidence = [];
|
|
1047
|
+
const matched = () => evidence.at(-1)?.status === "matched";
|
|
1048
|
+
if (citation.doi || citation.arxiv || citation.title) {
|
|
1049
|
+
for (const { name, fn, ask } of PAPER_RESOLVERS) {
|
|
1050
|
+
const question = ask(citation);
|
|
1051
|
+
if (question === null) continue;
|
|
1052
|
+
const resp = await lookup(
|
|
1053
|
+
paperKey(name, question),
|
|
1054
|
+
name,
|
|
1055
|
+
() => fn(question),
|
|
1056
|
+
() => true,
|
|
1057
|
+
);
|
|
1058
|
+
evidence.push(classifyResolver(citation, resp));
|
|
1059
|
+
if (matched()) return evidence;
|
|
1060
|
+
}
|
|
1061
|
+
// DOI authority (doi.org) — the ONLY source that can disprove a DOI (M1). Consulted whenever no
|
|
1062
|
+
// registry confirmed the DOI, so a registry-404 never becomes `false` without doi.org.
|
|
1063
|
+
if (citation.doi) {
|
|
1064
|
+
const auth = await lookup(
|
|
1065
|
+
cacheKey("doi_authority", citation.doi),
|
|
1066
|
+
"doi_authority",
|
|
1067
|
+
() => doiAuthorityCheck(citation.doi),
|
|
1068
|
+
// Cache only POSITIVE existence; never authority-absent (100), so a freshly-minted DOI
|
|
1069
|
+
// checked pre-propagation isn't pinned to `false` on re-run.
|
|
1070
|
+
(a) => a.responseCode !== 100,
|
|
1071
|
+
);
|
|
1072
|
+
evidence.push(classifyDoiAuthority(auth));
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (citation.cve) {
|
|
1076
|
+
const nvd = await lookup(
|
|
1077
|
+
cacheKey("nvd", citation.cve.toUpperCase()),
|
|
1078
|
+
"nvd",
|
|
1079
|
+
() => nvdCheck(citation.cve),
|
|
1080
|
+
// Cache only POSITIVE existence, as for doi.org: a CVE cited before NVD indexes it must not
|
|
1081
|
+
// be pinned to "not found" in the committed cache. A missing CVE is asked again each build.
|
|
1082
|
+
(a) => a.found,
|
|
1083
|
+
);
|
|
1084
|
+
const e = checkNvd(citation, nvd);
|
|
1085
|
+
if (e) evidence.push(e);
|
|
1086
|
+
}
|
|
1087
|
+
return evidence;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Would verifying `citation` make a request, given `cache`? `paperlint build` asks this BEFORE any
|
|
1092
|
+
* request: when no citation would, the build makes no network request at all, not even its
|
|
1093
|
+
* reachability probe. It walks the same `consult` as the live run, answering from the cache only.
|
|
1094
|
+
*/
|
|
1095
|
+
export async function wouldAsk(citation, cache) {
|
|
1096
|
+
let missed = false;
|
|
1097
|
+
await consult(normalizeIdentifiers(citation), async (key) => {
|
|
1098
|
+
if (cache[key]) return cache[key];
|
|
1099
|
+
missed = true;
|
|
1100
|
+
return { transport: "error" };
|
|
1101
|
+
});
|
|
1102
|
+
return missed;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* A circuit breaker for ONE run, per service. The first request to a service goes alone; until it
|
|
1107
|
+
* has answered, every other request to that service waits for it. Once a service REFUSES — a
|
|
1108
|
+
* transport error, a 429 or 5xx, a timeout, HTML where JSON was asked — it is not asked again for
|
|
1109
|
+
* the rest of the run: every later question to it is answered "refused earlier in this run" at no
|
|
1110
|
+
* cost. A service that refuses from its first request costs ONE request per run, not one per
|
|
1111
|
+
* citation, and not one per retry (#120). One that answers first and starts refusing later costs
|
|
1112
|
+
* at most the requests already in flight when the refusal lands — the caller's concurrency (six in
|
|
1113
|
+
* `paperlint build`). Queueing every request to a service behind the previous one would remove that
|
|
1114
|
+
* margin, at the price of making every cold build sequential per service.
|
|
1115
|
+
*
|
|
1116
|
+
* Nothing about a refusal is remembered past the run: the next run asks again, once.
|
|
1117
|
+
*
|
|
1118
|
+
* `attempt` returns `{ ok: true, value }` or `{ ok: false, reason }`; `call` returns the same shape,
|
|
1119
|
+
* with `reason` saying "refused earlier" when the service was not asked.
|
|
1120
|
+
*/
|
|
1121
|
+
export function createBreaker() {
|
|
1122
|
+
const state = new Map();
|
|
1123
|
+
const call = async (service, attempt) => {
|
|
1124
|
+
const s = state.get(service);
|
|
1125
|
+
if (s?.refused)
|
|
1126
|
+
return {
|
|
1127
|
+
ok: false,
|
|
1128
|
+
reason: `${service} refused earlier in this run (${s.refused})`,
|
|
1129
|
+
};
|
|
1130
|
+
if (s?.first) {
|
|
1131
|
+
await s.first;
|
|
1132
|
+
return call(service, attempt);
|
|
1133
|
+
}
|
|
1134
|
+
const pending = attempt();
|
|
1135
|
+
if (!s) state.set(service, { first: pending });
|
|
1136
|
+
const r = await pending;
|
|
1137
|
+
if (!r.ok) state.set(service, { refused: r.reason });
|
|
1138
|
+
else if (!s) state.set(service, { answered: true });
|
|
1139
|
+
return r;
|
|
1140
|
+
};
|
|
1141
|
+
return { call };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Verify ONE citation live (with caching). Returns the reduceVerdict result, plus `refused` —
|
|
1146
|
+
* "<service>: <reason>" for every question that went unanswered — when there were any.
|
|
952
1147
|
*
|
|
953
1148
|
* ⚠️ CONCURRENT CALLS SHARING ONE `cache` MAY ASK TWICE. The cache is read BEFORE the `await` and
|
|
954
|
-
* written AFTER it
|
|
955
|
-
*
|
|
956
|
-
*
|
|
1149
|
+
* written AFTER it, so two concurrent calls for the SAME identifier both miss and both go to the
|
|
1150
|
+
* network. The data does not suffer — the resolvers are deterministic and the second write is
|
|
1151
|
+
* equivalent — the cost is an extra request per duplicate identifier.
|
|
957
1152
|
*
|
|
958
1153
|
* Callers: `main()` here calls it one citation at a time; `paperlint build`'s references step
|
|
959
|
-
* (`src/adapters/references/index.ts`) calls it six at a time (#107
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
* because the interleaving it describes now exists, and is accepted for the reason above. In-flight
|
|
963
|
-
* deduplication (caching the promise, not the value) would remove the duplicate request; it does
|
|
964
|
-
* not fit `saveCache()`'s JSON file and has not been done.
|
|
1154
|
+
* (`src/adapters/references/index.ts`) calls it six at a time (#107), sharing one `breaker` for the
|
|
1155
|
+
* run. `require-atomic-updates` stays at `warn` because the interleaving it describes exists, and is
|
|
1156
|
+
* accepted for the reason above.
|
|
965
1157
|
*/
|
|
966
1158
|
export async function verifyCitationLive(
|
|
967
1159
|
citation,
|
|
968
|
-
{ cache = {}, offline = false } = {},
|
|
1160
|
+
{ cache = {}, offline = false, breaker = createBreaker() } = {},
|
|
969
1161
|
) {
|
|
970
1162
|
citation = normalizeIdentifiers(citation); // clean doi:/arXiv: prefixes + trailing punct first
|
|
971
1163
|
const commitFlags = checkCommit(citation);
|
|
@@ -975,65 +1167,27 @@ export async function verifyCitationLive(
|
|
|
975
1167
|
return reduceVerdict(citation, [], commitFlags);
|
|
976
1168
|
}
|
|
977
1169
|
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
? `doi:${citation.doi}`
|
|
993
|
-
: citation.arxiv
|
|
994
|
-
? `arxiv:${citation.arxiv}`
|
|
995
|
-
: `title:${titleHash(citation.title)}`,
|
|
996
|
-
);
|
|
997
|
-
let resp;
|
|
998
|
-
if (cache[ck]) {
|
|
999
|
-
resp = cache[ck];
|
|
1000
|
-
} else {
|
|
1001
|
-
resp = await fn(citation);
|
|
1002
|
-
if (resp && resp.transport === "ok") cache[ck] = resp; // only cache successes
|
|
1003
|
-
}
|
|
1004
|
-
if (resp) evidence.push(classifyResolver(citation, resp));
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// DOI authority (doi.org) — the ONLY source that can disprove a DOI (M1).
|
|
1008
|
-
// Always consulted when a DOI is present so a registry-404 never becomes
|
|
1009
|
-
// `false` without doi.org confirming responseCode 100.
|
|
1010
|
-
if (citation.doi) {
|
|
1011
|
-
const ck = cacheKey("doi_authority", citation.doi);
|
|
1012
|
-
let auth = cache[ck];
|
|
1013
|
-
if (!auth) {
|
|
1014
|
-
auth = await doiAuthorityCheck(citation.doi);
|
|
1015
|
-
// Cache only POSITIVE existence; never cache authority-absent (100), so a
|
|
1016
|
-
// freshly-minted DOI checked pre-propagation isn't pinned to `false` on re-run.
|
|
1017
|
-
if (auth && auth.transport === "ok" && auth.responseCode !== 100)
|
|
1018
|
-
cache[ck] = auth;
|
|
1170
|
+
const refused = [];
|
|
1171
|
+
const evidence = await consult(
|
|
1172
|
+
citation,
|
|
1173
|
+
async (key, service, fetch, keep) => {
|
|
1174
|
+
if (cache[key]) return cache[key];
|
|
1175
|
+
const r = await breaker.call(service, async () => {
|
|
1176
|
+
const resp = await fetch();
|
|
1177
|
+
return resp.transport === "ok"
|
|
1178
|
+
? { ok: true, value: resp }
|
|
1179
|
+
: { ok: false, reason: resp.reason };
|
|
1180
|
+
});
|
|
1181
|
+
if (!r.ok) {
|
|
1182
|
+
refused.push(`${service}: ${r.reason}`);
|
|
1183
|
+
return { db: service, transport: "error" };
|
|
1019
1184
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const ck = cacheKey("nvd", citation.cve.toUpperCase());
|
|
1027
|
-
let nvd = cache[ck];
|
|
1028
|
-
if (!nvd) {
|
|
1029
|
-
nvd = await nvdCheck(citation.cve);
|
|
1030
|
-
if (nvd.transport === "ok") cache[ck] = nvd;
|
|
1031
|
-
}
|
|
1032
|
-
const e = checkNvd(citation, nvd);
|
|
1033
|
-
if (e) evidence.push(e);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
return reduceVerdict(citation, evidence, commitFlags);
|
|
1185
|
+
if (keep(r.value)) cache[key] = r.value; // only successes are ever cached
|
|
1186
|
+
return r.value;
|
|
1187
|
+
},
|
|
1188
|
+
);
|
|
1189
|
+
const verdict = reduceVerdict(citation, evidence, commitFlags);
|
|
1190
|
+
return refused.length ? { ...verdict, refused } : verdict;
|
|
1037
1191
|
}
|
|
1038
1192
|
|
|
1039
1193
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1068,6 +1222,7 @@ async function main() {
|
|
|
1068
1222
|
}
|
|
1069
1223
|
|
|
1070
1224
|
const cache = loadCache();
|
|
1225
|
+
const breaker = createBreaker();
|
|
1071
1226
|
const results = [];
|
|
1072
1227
|
for (const c of cites) {
|
|
1073
1228
|
if (!c || !c.id) {
|
|
@@ -1079,7 +1234,7 @@ async function main() {
|
|
|
1079
1234
|
});
|
|
1080
1235
|
continue;
|
|
1081
1236
|
}
|
|
1082
|
-
results.push(await verifyCitationLive(c, { cache, offline }));
|
|
1237
|
+
results.push(await verifyCitationLive(c, { cache, offline, breaker }));
|
|
1083
1238
|
}
|
|
1084
1239
|
saveCache(cache);
|
|
1085
1240
|
|