paperlint 3.1.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CLAUDE.md +18 -97
  2. package/CONTRIBUTING.md +7 -21
  3. package/README.md +11 -7
  4. package/dist/adapters/references/clock.io.d.ts +5 -0
  5. package/dist/adapters/references/clock.io.d.ts.map +1 -0
  6. package/dist/adapters/references/clock.io.js +7 -0
  7. package/dist/adapters/references/clock.io.js.map +1 -0
  8. package/dist/adapters/references/index.d.ts +24 -2
  9. package/dist/adapters/references/index.d.ts.map +1 -1
  10. package/dist/adapters/references/index.js +125 -15
  11. package/dist/adapters/references/index.js.map +1 -1
  12. package/dist/build.d.ts +4 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +10 -3
  15. package/dist/build.js.map +1 -1
  16. package/dist/domain/lookup-cache.d.ts +75 -0
  17. package/dist/domain/lookup-cache.d.ts.map +1 -0
  18. package/dist/domain/lookup-cache.js +137 -0
  19. package/dist/domain/lookup-cache.js.map +1 -0
  20. package/dist/ports/check-references.d.ts +12 -2
  21. package/dist/ports/check-references.d.ts.map +1 -1
  22. package/dist/ports/check-references.js +0 -5
  23. package/dist/ports/check-references.js.map +1 -1
  24. package/dist/references.d.ts +11 -3
  25. package/dist/references.d.ts.map +1 -1
  26. package/dist/references.js +63 -15
  27. package/dist/references.js.map +1 -1
  28. package/docs/prior-art/README.md +11 -10
  29. package/docs/prior-art/citation-checkers.md +174 -0
  30. package/docs/references.md +126 -0
  31. package/docs/testing.md +118 -0
  32. package/docs/toolchain.md +6 -4
  33. package/package.json +1 -1
  34. package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
  35. package/skills/render-paper/checkers-requirements.txt +30 -19
  36. package/skills/render-paper/ensure-checkers.sh +20 -4
  37. package/skills/verify-citations/SKILL.md +26 -10
  38. package/skills/verify-citations/SKILL.md.spec.ts +25 -9
  39. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +28 -10
  40. package/skills/verify-citations/scripts/bib-authors.mjs +5 -6
  41. package/skills/verify-citations/scripts/verify-cites.mjs +285 -140
  42. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +321 -20
  43. package/skills/verify-citations/scripts/verify-cites.test.mjs +4 -2
  44. package/src/adapters/references/clock.io.ts +8 -0
  45. package/src/adapters/references/index.test.ts +341 -9
  46. package/src/adapters/references/index.ts +198 -29
  47. package/src/build.ts +10 -3
  48. package/src/domain/lookup-cache.test.ts +178 -0
  49. package/src/domain/lookup-cache.ts +202 -0
  50. package/src/ports/check-references.ts +16 -2
  51. package/src/references.test.ts +160 -10
  52. package/src/references.ts +93 -17
@@ -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 { fileURLToPath } from "node:url";
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
- const __dirname = dirname(fileURLToPath(import.meta.url));
58
- // Beside this script by default (the skill says so, and `.gitignore` covers it). VERIFY_CITES_CACHE
59
- // points it elsewhere — how a test keeps the run from writing into the package it is testing.
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 || join(__dirname, ".cite-cache.json");
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
@@ -686,17 +688,32 @@ function loadCache() {
686
688
  }
687
689
  function saveCache(cache) {
688
690
  try {
691
+ mkdirSync(dirname(CACHE_PATH), { recursive: true });
689
692
  writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
690
693
  } catch {
691
694
  /* best-effort; a cache write failure must never fail the run */
692
695
  }
693
696
  }
694
697
 
698
+ /**
699
+ * A title's IDENTITY, for cache keys: case, accents, BibTeX braces and runs of whitespace
700
+ * normalized, punctuation kept. Not `normalizeTitle`, which drops punctuation so that `Title: Sub`
701
+ * and `Title - Sub` still COMPARE equal — as a key that would make `C` and `C++` one entry, and
702
+ * the second would be served the first's answers. A punctuation-only variant costs one extra
703
+ * lookup instead. The one owner of this rule: DBLP's cache key (the references adapter) uses it too.
704
+ */
705
+ export function titleIdentity(t) {
706
+ return (t ?? "")
707
+ .toLowerCase()
708
+ .normalize("NFKD")
709
+ .replace(/\p{M}+/gu, "")
710
+ .replace(/[{}]/g, "")
711
+ .replace(/\s+/g, " ")
712
+ .trim();
713
+ }
714
+
695
715
  function titleHash(t) {
696
- return createHash("sha1")
697
- .update(normalizeTitle(t))
698
- .digest("hex")
699
- .slice(0, 16);
716
+ return createHash("sha1").update(titleIdentity(t)).digest("hex").slice(0, 16);
700
717
  }
701
718
  function cacheKey(kind, val) {
702
719
  return `${kind}:${val}`;
@@ -716,8 +733,20 @@ async function request(url, json, signal) {
716
733
  }
717
734
  if (res.status === 404) return { ok: true, notFound: true };
718
735
  if (!res.ok) return { ok: false, reason: `http ${res.status}` };
719
- const data = json ? await res.json() : await res.text();
720
- return { ok: true, data };
736
+ const text = await res.text();
737
+ if (!json) return { ok: true, data: text };
738
+ // 🔴 A service that is rate-limiting some clients answers 200 with an HTML page instead of JSON
739
+ // (DBLP does, #110). That is a refusal, named as one — not a parse error to puzzle over.
740
+ try {
741
+ return { ok: true, data: JSON.parse(text) };
742
+ } catch {
743
+ return {
744
+ ok: false,
745
+ reason: text.trimStart().startsWith("<")
746
+ ? "answered HTML, not JSON"
747
+ : "answered something that is not JSON",
748
+ };
749
+ }
721
750
  }
722
751
 
723
752
  async function httpGet(url, { json = true } = {}) {
@@ -740,7 +769,7 @@ async function crossrefResolve(citation) {
740
769
  const r = await httpGet(
741
770
  `https://api.crossref.org/works/${encodeURIComponent(citation.doi)}`,
742
771
  );
743
- if (!r.ok) return { db, transport: "error" };
772
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
744
773
  if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
745
774
  const w = r.data?.message;
746
775
  return {
@@ -758,24 +787,22 @@ async function crossrefResolve(citation) {
758
787
  : null,
759
788
  };
760
789
  }
761
- if (citation.title) {
762
- const url = `https://api.crossref.org/works?query.bibliographic=${encodeURIComponent(
763
- citation.title,
764
- )}&rows=5`;
765
- const r = await httpGet(url);
766
- if (!r.ok) return { db, transport: "error" };
767
- const items = r.data?.message?.items || [];
768
- return {
769
- db,
770
- transport: "ok",
771
- query: "title",
772
- records: items.map((w) => ({
773
- title: (w.title || [])[0] || "",
774
- year: crYear(w),
775
- })),
776
- };
777
- }
778
- return null;
790
+ // Asked only with a DOI or a title (`PAPER_RESOLVERS`), so without a DOI there is a title.
791
+ const url = `https://api.crossref.org/works?query.bibliographic=${encodeURIComponent(
792
+ citation.title,
793
+ )}&rows=5`;
794
+ const r = await httpGet(url);
795
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
796
+ const items = r.data?.message?.items || [];
797
+ return {
798
+ db,
799
+ transport: "ok",
800
+ query: "title",
801
+ records: items.map((w) => ({
802
+ title: (w.title || [])[0] || "",
803
+ year: crYear(w),
804
+ })),
805
+ };
779
806
  }
780
807
  function crYear(w) {
781
808
  return (
@@ -791,7 +818,7 @@ async function openalexResolve(citation) {
791
818
  const r = await httpGet(
792
819
  `https://api.openalex.org/works/doi:${encodeURIComponent(citation.doi)}`,
793
820
  );
794
- if (!r.ok) return { db, transport: "error" };
821
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
795
822
  if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
796
823
  const w = r.data;
797
824
  return {
@@ -803,23 +830,21 @@ async function openalexResolve(citation) {
803
830
  : null,
804
831
  };
805
832
  }
806
- if (citation.title) {
807
- const r = await httpGet(
808
- `https://api.openalex.org/works?search=${encodeURIComponent(citation.title)}&per-page=5`,
809
- );
810
- if (!r.ok) return { db, transport: "error" };
811
- const items = r.data?.results || [];
812
- return {
813
- db,
814
- transport: "ok",
815
- query: "title",
816
- records: items.map((w) => ({
817
- title: w.display_name || "",
818
- year: w.publication_year,
819
- })),
820
- };
821
- }
822
- return null;
833
+ // Asked only with a DOI or a title (`PAPER_RESOLVERS`), so without a DOI there is a title.
834
+ const r = await httpGet(
835
+ `https://api.openalex.org/works?search=${encodeURIComponent(citation.title)}&per-page=5`,
836
+ );
837
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
838
+ const items = r.data?.results || [];
839
+ return {
840
+ db,
841
+ transport: "ok",
842
+ query: "title",
843
+ records: items.map((w) => ({
844
+ title: w.display_name || "",
845
+ year: w.publication_year,
846
+ })),
847
+ };
823
848
  }
824
849
 
825
850
  const S2 = "https://api.semanticscholar.org/graph/v1/paper";
@@ -836,7 +861,7 @@ async function semanticScholarById(citation) {
836
861
  const query = citation.doi ? "doi" : "arxiv";
837
862
  const id = citation.doi ? `DOI:${citation.doi}` : `arXiv:${citation.arxiv}`;
838
863
  const r = await httpGet(`${S2}/${encodeURIComponent(id)}?fields=title,year`);
839
- if (!r.ok) return { db, transport: "error" };
864
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
840
865
  if (r.notFound) return { db, transport: "ok", query, record: null };
841
866
  const w = r.data;
842
867
  return {
@@ -852,7 +877,7 @@ async function semanticScholarByTitle(title) {
852
877
  const r = await httpGet(
853
878
  `${S2}/search?query=${encodeURIComponent(title)}&fields=title,year&limit=5`,
854
879
  );
855
- if (!r.ok) return { db, transport: "error" };
880
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
856
881
  const items = r.data?.data || [];
857
882
  return {
858
883
  db,
@@ -870,26 +895,24 @@ async function arxivResolve(citation) {
870
895
  `https://export.arxiv.org/api/query?id_list=${encodeURIComponent(citation.arxiv)}&max_results=1`,
871
896
  { json: false },
872
897
  );
873
- if (!r.ok) return { db, transport: "error" };
898
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
874
899
  const entry = parseArxivFeed(r.data);
875
900
  return { db, transport: "ok", query: "arxiv", record: entry[0] ?? null };
876
901
  }
877
- if (citation.title && !citation.doi) {
878
- const r = await httpGet(
879
- `https://export.arxiv.org/api/query?search_query=ti:${encodeURIComponent(
880
- '"' + citation.title + '"',
881
- )}&max_results=5`,
882
- { json: false },
883
- );
884
- if (!r.ok) return { db, transport: "error" };
885
- return {
886
- db,
887
- transport: "ok",
888
- query: "title",
889
- records: parseArxivFeed(r.data),
890
- };
891
- }
892
- return null;
902
+ // Asked only with an arXiv id or a title and no DOI (`PAPER_RESOLVERS`).
903
+ const r = await httpGet(
904
+ `https://export.arxiv.org/api/query?search_query=ti:${encodeURIComponent(
905
+ '"' + citation.title + '"',
906
+ )}&max_results=5`,
907
+ { json: false },
908
+ );
909
+ if (!r.ok) return { db, transport: "error", reason: r.reason };
910
+ return {
911
+ db,
912
+ transport: "ok",
913
+ query: "title",
914
+ records: parseArxivFeed(r.data),
915
+ };
893
916
  }
894
917
  export function parseArxivFeed(xml) {
895
918
  const out = [];
@@ -923,7 +946,7 @@ async function doiAuthorityCheck(doi) {
923
946
  const path = encodeURIComponent(doi.trim()).replace(/%2F/gi, "/");
924
947
  const r = await httpGet(`https://doi.org/api/handles/${path}`);
925
948
  if (r.notFound) return { transport: "ok", responseCode: 100 }; // 404 = not found
926
- if (!r.ok) return { transport: "error" };
949
+ if (!r.ok) return { transport: "error", reason: r.reason };
927
950
  const code = r.data?.responseCode;
928
951
  return {
929
952
  transport: "ok",
@@ -941,31 +964,190 @@ async function nvdCheck(cve) {
941
964
  cve.toUpperCase(),
942
965
  )}`,
943
966
  );
944
- if (!r.ok) return { transport: "error" };
967
+ if (!r.ok) return { transport: "error", reason: r.reason };
945
968
  if (r.notFound) return { transport: "ok", found: false };
946
969
  const total = r.data?.totalResults ?? (r.data?.vulnerabilities || []).length;
947
970
  return { transport: "ok", found: total > 0 };
948
971
  }
949
972
 
950
973
  /**
951
- * Verify ONE citation live (with caching). Returns the reduceVerdict result.
974
+ * The paper registries, each with the ONE question it asks of a citation: `ask` picks the
975
+ * identifier (`{ doi }`, `{ arxiv }` or `{ title }`), or `null` when the registry cannot answer this
976
+ * citation — then it is not called and stores nothing. The resolver is handed ONLY that question,
977
+ * and the cache key is built from the same question, so the key always names what was asked: arXiv
978
+ * asked by arXiv id is keyed by it even when the entry also has a DOI, and correcting that id asks
979
+ * again.
980
+ */
981
+ const PAPER_RESOLVERS = [
982
+ {
983
+ name: "crossref",
984
+ fn: crossrefResolve,
985
+ ask: (c) => (c.doi ? { doi: c.doi } : c.title ? { title: c.title } : null),
986
+ },
987
+ {
988
+ name: "openalex",
989
+ fn: openalexResolve,
990
+ ask: (c) => (c.doi ? { doi: c.doi } : c.title ? { title: c.title } : null),
991
+ },
992
+ {
993
+ name: "semantic_scholar",
994
+ fn: semanticScholarResolve,
995
+ ask: (c) =>
996
+ c.doi
997
+ ? { doi: c.doi }
998
+ : c.arxiv
999
+ ? { arxiv: c.arxiv }
1000
+ : { title: c.title },
1001
+ },
1002
+ {
1003
+ name: "arxiv",
1004
+ fn: arxivResolve,
1005
+ // No DOI endpoint: by arXiv id, else a title search — but not for a DOI-only entry.
1006
+ ask: (c) =>
1007
+ c.arxiv
1008
+ ? { arxiv: c.arxiv }
1009
+ : c.title && !c.doi
1010
+ ? { title: c.title }
1011
+ : null,
1012
+ },
1013
+ ];
1014
+
1015
+ /** The cache key of one registry's question: the identifier it was asked by. */
1016
+ const paperKey = (name, q) =>
1017
+ cacheKey(
1018
+ name,
1019
+ q.doi
1020
+ ? `doi:${q.doi}`
1021
+ : q.arxiv
1022
+ ? `arxiv:${q.arxiv}`
1023
+ : `title:${titleHash(q.title)}`,
1024
+ );
1025
+
1026
+ /**
1027
+ * The evidence for one citation, asked in order through `lookup(key, service, fetch, keep)`, which
1028
+ * decides where each answer comes from (the cache, the network, or nowhere). ONE code path for the
1029
+ * live run and for `wouldAsk`, so the two cannot disagree about which questions a citation asks.
1030
+ *
1031
+ * 🔴 IT STOPS AT THE FIRST MATCH. `reduceVerdict` lets a match win over everything else, so once one
1032
+ * registry has confirmed the work, no further answer can change the verdict — asking Semantic
1033
+ * Scholar after Crossref confirmed a DOI only costs a request, and a rate-limited one at that.
1034
+ */
1035
+ async function consult(citation, lookup) {
1036
+ const evidence = [];
1037
+ const matched = () => evidence.at(-1)?.status === "matched";
1038
+ if (citation.doi || citation.arxiv || citation.title) {
1039
+ for (const { name, fn, ask } of PAPER_RESOLVERS) {
1040
+ const question = ask(citation);
1041
+ if (question === null) continue;
1042
+ const resp = await lookup(
1043
+ paperKey(name, question),
1044
+ name,
1045
+ () => fn(question),
1046
+ () => true,
1047
+ );
1048
+ evidence.push(classifyResolver(citation, resp));
1049
+ if (matched()) return evidence;
1050
+ }
1051
+ // DOI authority (doi.org) — the ONLY source that can disprove a DOI (M1). Consulted whenever no
1052
+ // registry confirmed the DOI, so a registry-404 never becomes `false` without doi.org.
1053
+ if (citation.doi) {
1054
+ const auth = await lookup(
1055
+ cacheKey("doi_authority", citation.doi),
1056
+ "doi_authority",
1057
+ () => doiAuthorityCheck(citation.doi),
1058
+ // Cache only POSITIVE existence; never authority-absent (100), so a freshly-minted DOI
1059
+ // checked pre-propagation isn't pinned to `false` on re-run.
1060
+ (a) => a.responseCode !== 100,
1061
+ );
1062
+ evidence.push(classifyDoiAuthority(auth));
1063
+ }
1064
+ }
1065
+ if (citation.cve) {
1066
+ const nvd = await lookup(
1067
+ cacheKey("nvd", citation.cve.toUpperCase()),
1068
+ "nvd",
1069
+ () => nvdCheck(citation.cve),
1070
+ // Cache only POSITIVE existence, as for doi.org: a CVE cited before NVD indexes it must not
1071
+ // be pinned to "not found" in the committed cache. A missing CVE is asked again each build.
1072
+ (a) => a.found,
1073
+ );
1074
+ const e = checkNvd(citation, nvd);
1075
+ if (e) evidence.push(e);
1076
+ }
1077
+ return evidence;
1078
+ }
1079
+
1080
+ /**
1081
+ * Would verifying `citation` make a request, given `cache`? `paperlint build` asks this BEFORE any
1082
+ * request: when no citation would, the build makes no network request at all, not even its
1083
+ * reachability probe. It walks the same `consult` as the live run, answering from the cache only.
1084
+ */
1085
+ export async function wouldAsk(citation, cache) {
1086
+ let missed = false;
1087
+ await consult(normalizeIdentifiers(citation), async (key) => {
1088
+ if (cache[key]) return cache[key];
1089
+ missed = true;
1090
+ return { transport: "error" };
1091
+ });
1092
+ return missed;
1093
+ }
1094
+
1095
+ /**
1096
+ * A circuit breaker for ONE run, per service. The first request to a service goes alone; until it
1097
+ * has answered, every other request to that service waits for it. Once a service REFUSES — a
1098
+ * transport error, a 429 or 5xx, a timeout, HTML where JSON was asked — it is not asked again for
1099
+ * the rest of the run: every later question to it is answered "refused earlier in this run" at no
1100
+ * cost. A service that refuses from its first request costs ONE request per run, not one per
1101
+ * citation, and not one per retry (#120). One that answers first and starts refusing later costs
1102
+ * at most the requests already in flight when the refusal lands — the caller's concurrency (six in
1103
+ * `paperlint build`). Queueing every request to a service behind the previous one would remove that
1104
+ * margin, at the price of making every cold build sequential per service.
1105
+ *
1106
+ * Nothing about a refusal is remembered past the run: the next run asks again, once.
1107
+ *
1108
+ * `attempt` returns `{ ok: true, value }` or `{ ok: false, reason }`; `call` returns the same shape,
1109
+ * with `reason` saying "refused earlier" when the service was not asked.
1110
+ */
1111
+ export function createBreaker() {
1112
+ const state = new Map();
1113
+ const call = async (service, attempt) => {
1114
+ const s = state.get(service);
1115
+ if (s?.refused)
1116
+ return {
1117
+ ok: false,
1118
+ reason: `${service} refused earlier in this run (${s.refused})`,
1119
+ };
1120
+ if (s?.first) {
1121
+ await s.first;
1122
+ return call(service, attempt);
1123
+ }
1124
+ const pending = attempt();
1125
+ if (!s) state.set(service, { first: pending });
1126
+ const r = await pending;
1127
+ if (!r.ok) state.set(service, { refused: r.reason });
1128
+ else if (!s) state.set(service, { answered: true });
1129
+ return r;
1130
+ };
1131
+ return { call };
1132
+ }
1133
+
1134
+ /**
1135
+ * Verify ONE citation live (with caching). Returns the reduceVerdict result, plus `refused` —
1136
+ * "<service>: <reason>" for every question that went unanswered — when there were any.
952
1137
  *
953
1138
  * ⚠️ CONCURRENT CALLS SHARING ONE `cache` MAY ASK TWICE. The cache is read BEFORE the `await` and
954
- * written AFTER it (three places below), so two concurrent calls for the SAME identifier both miss
955
- * and both go to the network. The data does not suffer — the resolvers are deterministic and the
956
- * second write is equivalent — the cost is an extra request per duplicate identifier.
1139
+ * written AFTER it, so two concurrent calls for the SAME identifier both miss and both go to the
1140
+ * network. The data does not suffer — the resolvers are deterministic and the second write is
1141
+ * equivalent — the cost is an extra request per duplicate identifier.
957
1142
  *
958
1143
  * 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 — serially, 27 references took
960
- * 217 s). Six is the bound on requests in flight to any one service, since a citation's own
961
- * requests go to the four resolvers one after another. `require-atomic-updates` stays at `warn`
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.
1144
+ * (`src/adapters/references/index.ts`) calls it six at a time (#107), sharing one `breaker` for the
1145
+ * run. `require-atomic-updates` stays at `warn` because the interleaving it describes exists, and is
1146
+ * accepted for the reason above.
965
1147
  */
966
1148
  export async function verifyCitationLive(
967
1149
  citation,
968
- { cache = {}, offline = false } = {},
1150
+ { cache = {}, offline = false, breaker = createBreaker() } = {},
969
1151
  ) {
970
1152
  citation = normalizeIdentifiers(citation); // clean doi:/arXiv: prefixes + trailing punct first
971
1153
  const commitFlags = checkCommit(citation);
@@ -975,65 +1157,27 @@ export async function verifyCitationLive(
975
1157
  return reduceVerdict(citation, [], commitFlags);
976
1158
  }
977
1159
 
978
- const evidence = [];
979
-
980
- // Paper resolvers (skip when the citation is CVE/commit-only).
981
- if (citation.doi || citation.arxiv || citation.title) {
982
- const resolvers = [
983
- { name: "crossref", fn: crossrefResolve },
984
- { name: "openalex", fn: openalexResolve },
985
- { name: "semantic_scholar", fn: semanticScholarResolve },
986
- { name: "arxiv", fn: arxivResolve },
987
- ];
988
- for (const { name, fn } of resolvers) {
989
- const ck = cacheKey(
990
- name,
991
- citation.doi
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;
1160
+ const refused = [];
1161
+ const evidence = await consult(
1162
+ citation,
1163
+ async (key, service, fetch, keep) => {
1164
+ if (cache[key]) return cache[key];
1165
+ const r = await breaker.call(service, async () => {
1166
+ const resp = await fetch();
1167
+ return resp.transport === "ok"
1168
+ ? { ok: true, value: resp }
1169
+ : { ok: false, reason: resp.reason };
1170
+ });
1171
+ if (!r.ok) {
1172
+ refused.push(`${service}: ${r.reason}`);
1173
+ return { db: service, transport: "error" };
1019
1174
  }
1020
- evidence.push(classifyDoiAuthority(auth));
1021
- }
1022
- }
1023
-
1024
- // CVE via NVD.
1025
- if (citation.cve) {
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);
1175
+ if (keep(r.value)) cache[key] = r.value; // only successes are ever cached
1176
+ return r.value;
1177
+ },
1178
+ );
1179
+ const verdict = reduceVerdict(citation, evidence, commitFlags);
1180
+ return refused.length ? { ...verdict, refused } : verdict;
1037
1181
  }
1038
1182
 
1039
1183
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1068,6 +1212,7 @@ async function main() {
1068
1212
  }
1069
1213
 
1070
1214
  const cache = loadCache();
1215
+ const breaker = createBreaker();
1071
1216
  const results = [];
1072
1217
  for (const c of cites) {
1073
1218
  if (!c || !c.id) {
@@ -1079,7 +1224,7 @@ async function main() {
1079
1224
  });
1080
1225
  continue;
1081
1226
  }
1082
- results.push(await verifyCitationLive(c, { cache, offline }));
1227
+ results.push(await verifyCitationLive(c, { cache, offline, breaker }));
1083
1228
  }
1084
1229
  saveCache(cache);
1085
1230