paperlint 3.1.0 → 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.
- package/.github/workflows/ci.yml +28 -41
- package/.github/workflows/dependabot-automerge.yml +1 -1
- package/CLAUDE.md +21 -105
- package/CONTRIBUTING.md +23 -25
- package/README.md +11 -7
- package/bin/paperlint.test.mjs +45 -0
- package/dist/adapters/node/files.io.d.ts +2 -0
- package/dist/adapters/node/files.io.d.ts.map +1 -1
- package/dist/adapters/node/files.io.js +3 -2
- package/dist/adapters/node/files.io.js.map +1 -1
- package/dist/adapters/pdfjs/fill.d.ts +15 -4
- package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
- package/dist/adapters/pdfjs/fill.js +31 -21
- package/dist/adapters/pdfjs/fill.js.map +1 -1
- 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 +131 -12
- package/dist/adapters/references/index.js.map +1 -1
- package/dist/adapters/references/pool.d.ts +8 -0
- package/dist/adapters/references/pool.d.ts.map +1 -0
- package/dist/adapters/references/pool.js +19 -0
- package/dist/adapters/references/pool.js.map +1 -0
- package/dist/build.d.ts +14 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +17 -11
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +27 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +71 -55
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +4 -3
- package/dist/doctor.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/domain/result.d.ts +0 -2
- package/dist/domain/result.d.ts.map +1 -1
- package/dist/domain/result.js +0 -4
- package/dist/domain/result.js.map +1 -1
- package/dist/hooks-settings.d.ts +8 -5
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +32 -32
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +8 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/dist/latex-log.d.ts +0 -2
- package/dist/latex-log.d.ts.map +1 -1
- package/dist/latex-log.js +7 -13
- package/dist/latex-log.js.map +1 -1
- package/dist/link-skills.d.ts +15 -2
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +36 -20
- package/dist/link-skills.js.map +1 -1
- package/dist/paper-files.d.ts.map +1 -1
- package/dist/paper-files.js +6 -4
- package/dist/paper-files.js.map +1 -1
- package/dist/pdf-facts.d.ts +11 -0
- package/dist/pdf-facts.d.ts.map +1 -1
- package/dist/pdf-facts.js +16 -18
- package/dist/pdf-facts.js.map +1 -1
- package/dist/pdf-geometry.js +1 -1
- package/dist/pdf-geometry.js.map +1 -1
- 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/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +3 -1
- package/dist/presets.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/dist/tex-requirements.d.ts +8 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +6 -5
- package/dist/tex-requirements.js.map +1 -1
- package/dist/toolchain.d.ts +3 -2
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +4 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +11 -9
- package/dist/venue-rules.js.map +1 -1
- package/docs/prior-art/README.md +11 -10
- package/docs/prior-art/citation-checkers.md +174 -0
- package/docs/prior-art/test-tooling.md +7 -30
- package/docs/references.md +126 -0
- package/docs/testing.md +118 -0
- package/docs/toolchain.md +6 -4
- package/eslint-rules/install-path-literals.harness.mjs +17 -1
- package/eslint-rules/latex-language.harness.mjs +3 -3
- package/eslint-rules/latex-language.mjs +12 -9
- package/eslint-rules/latex-language.test.mjs +200 -0
- package/eslint-rules/paper-research-question.harness.mjs +73 -7
- package/eslint-rules/paper-stages.harness.mjs +9 -14
- package/eslint-rules/paper-stages.test.mjs +135 -0
- package/eslint-rules/paper-typography.mjs +9 -2
- package/eslint-rules/paper-typography.test.mjs +146 -1
- package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
- package/eslint-rules/review-frontmatter.mjs +2 -1
- package/eslint-rules/review-frontmatter.test.mjs +37 -1
- package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
- package/eslint-rules/tex-build.mjs +13 -13
- package/eslint.config.mjs +6 -2
- package/hooks/hooks.harness.mjs +6 -11
- package/hooks/paper-edit-guard.hook.mjs +2 -2
- package/hooks/paper-edit-guard.test.mjs +61 -0
- package/hooks/paper-skills-nudge.hook.mjs +2 -2
- package/hooks/paper-skills-nudge.test.mjs +38 -0
- package/hooks/paper-status-gates.test.mjs +42 -0
- package/lib/agent-cli-version.harness.mjs +7 -0
- package/lib/check.d.mts +17 -0
- package/lib/check.mjs +50 -0
- package/lib/check.test.ts +67 -0
- package/lib/markdown.mjs +16 -69
- package/lib/markdown.test.mjs +246 -0
- package/lib/paper-config.harness.mjs +28 -9
- package/lib/paper-config.mjs +7 -2
- package/lib/skill-checks.mjs +18 -46
- package/lib/skill-checks.test.mjs +297 -0
- package/lib/skill-corpus.mjs +11 -13
- package/lib/skill-corpus.test.mjs +54 -0
- package/lib/skill-trigger-cases.harness.mjs +57 -4
- package/package.json +6 -5
- package/scripts/check.harness.mjs +4 -10
- package/scripts/check.mjs +45 -42
- package/scripts/check.test.mjs +104 -0
- package/scripts/coverage-config.test.ts +406 -0
- package/scripts/eslint-report-guard.test.mjs +48 -0
- package/scripts/harness-api.frozen.json +0 -2
- package/scripts/layer-legacy-frozen.d.mts +9 -1
- package/scripts/layer-legacy-frozen.mjs +28 -11
- package/scripts/layer-legacy-frozen.test.ts +143 -1
- package/scripts/rules-are-content-only.harness.mjs +3 -11
- package/scripts/rules-are-content-only.mjs +29 -19
- package/scripts/rules-are-content-only.test.mjs +61 -0
- package/scripts/rules-see-files.mjs +11 -9
- package/scripts/rules-see-files.test.mjs +71 -0
- package/scripts/test.mjs +44 -0
- package/scripts/test.test.mjs +52 -0
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
- package/skills/grade-paper-writing/prose-lint.mjs +31 -12
- package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
- package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
- package/skills/paper-pipeline/scripts/README.md +13 -132
- package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
- package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
- package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
- package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
- package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
- package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
- package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
- package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
- package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
- package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
- package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
- package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
- package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
- package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
- package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
- package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
- package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
- package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
- package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
- package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
- package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
- package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
- package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
- package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
- package/skills/paper-pipeline/scripts/status.mjs +2 -2
- package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
- package/skills/render-paper/checkers-requirements.txt +30 -19
- package/skills/render-paper/ensure-checkers.sh +20 -4
- package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
- package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
- package/skills/tighten-paper/structure.test.mjs +116 -0
- package/skills/verify-citations/SKILL.md +30 -14
- package/skills/verify-citations/SKILL.md.spec.ts +29 -13
- package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
- package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
- package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
- package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
- package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
- package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
- package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
- package/src/adapters/banal/failure.test.ts +70 -1
- package/src/adapters/banal/index.test.ts +16 -0
- package/src/adapters/banal/output.test.ts +5 -0
- package/src/adapters/curl/download.test.ts +43 -1
- package/src/adapters/memory/index.test.ts +25 -0
- package/src/adapters/node/files.io.ts +3 -2
- package/src/adapters/node/files.test.ts +13 -1
- package/src/adapters/node/process.test.ts +38 -0
- package/src/adapters/pdfjs/fill.test.ts +34 -0
- package/src/adapters/pdfjs/fill.ts +44 -25
- package/src/adapters/references/clock.io.ts +8 -0
- package/src/adapters/references/index.test.ts +560 -0
- package/src/adapters/references/index.ts +203 -19
- package/src/adapters/references/pool.test.ts +31 -0
- package/src/adapters/references/pool.ts +24 -0
- package/src/build-engine.harness.mjs +3 -7
- package/src/build-engine.test.ts +137 -0
- package/src/build.harness.mjs +5 -9
- package/src/build.test.ts +362 -0
- package/src/build.ts +23 -12
- package/src/cli.harness.mjs +10 -17
- package/src/cli.test.ts +584 -0
- package/src/cli.ts +112 -66
- package/src/doctor.harness.mjs +3 -8
- package/src/doctor.test.ts +64 -0
- package/src/doctor.ts +12 -4
- package/src/domain/lookup-cache.test.ts +178 -0
- package/src/domain/lookup-cache.ts +202 -0
- package/src/domain/result.test.ts +4 -0
- package/src/domain/result.ts +0 -5
- package/src/engine.harness.mjs +27 -7
- package/src/facts-file.test.ts +110 -0
- package/src/hooks-settings.harness.mjs +43 -13
- package/src/hooks-settings.ts +41 -35
- package/src/init.test.ts +308 -5
- package/src/init.ts +3 -3
- package/src/latex-log.harness.mjs +32 -7
- package/src/latex-log.ts +6 -13
- package/src/latex-loop.harness.mjs +8 -7
- package/src/link-skills.harness.mjs +3 -8
- package/src/link-skills.test.ts +105 -0
- package/src/link-skills.ts +75 -21
- package/src/new-paper.harness.mjs +3 -7
- package/src/new-paper.test.ts +31 -1
- package/src/paper-files.test.ts +12 -0
- package/src/paper-files.ts +9 -5
- package/src/paper-settings.test.ts +20 -0
- package/src/pdf-facts.harness.mjs +3 -6
- package/src/pdf-facts.test.ts +144 -0
- package/src/pdf-facts.ts +17 -19
- package/src/pdf-geometry.harness.mjs +3 -7
- package/src/pdf-geometry.ts +1 -1
- package/src/ports/check-references.ts +16 -2
- package/src/presets.test.ts +2 -0
- package/src/presets.ts +7 -1
- package/src/references.test.ts +257 -10
- package/src/references.ts +93 -17
- package/src/rules-config.harness.mjs +10 -8
- package/src/structure.harness.mjs +3 -7
- package/src/tex-requirements.harness.mjs +34 -7
- package/src/tex-requirements.ts +12 -6
- package/src/toolchain.harness.mjs +3 -7
- package/src/toolchain.test.ts +280 -0
- package/src/toolchain.ts +8 -7
- package/src/venue-rules.test.ts +60 -0
- package/src/venue-rules.ts +13 -11
- package/action.mutations.mjs +0 -162
- package/eslint-rules/install-path-literals.mutations.mjs +0 -62
- package/eslint-rules/latex-language.mutations.mjs +0 -196
- package/eslint-rules/paper-research-question.mutations.mjs +0 -127
- package/eslint-rules/paper-stages.mutations.mjs +0 -81
- package/eslint-rules/papers.mutations.mjs +0 -186
- package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
- package/eslint-rules/tex-build.mutations.mjs +0 -258
- package/hooks/hooks.mutations.mjs +0 -337
- package/lib/agent-cli-version.mutations.mjs +0 -109
- package/lib/mutation-driver.harness.mjs +0 -227
- package/lib/mutation-driver.mjs +0 -397
- package/lib/mutation-driver.mutations.mjs +0 -68
- package/lib/paper-config.mutations.mjs +0 -143
- package/lib/skill-trigger-cases.mutations.mjs +0 -65
- package/scripts/check.mutations.mjs +0 -110
- package/scripts/exclusive.mjs +0 -138
- package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
- package/scripts/mutation-batteries-frozen.mjs +0 -238
- package/scripts/mutation-batteries.frozen.json +0 -108
- package/scripts/rules-are-content-only.mutations.mjs +0 -81
- package/scripts/rules-see-files.mutations.mjs +0 -131
- package/scripts/run-mutations.mjs +0 -100
- package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
- package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
- package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
- package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
- package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
- package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
- package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
- package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
- package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
- package/skills/skill-contract.mutations.mjs +0 -179
- package/src/build.mutations.mjs +0 -196
- package/src/cli.mutations.mjs +0 -414
- package/src/doctor.mutations.mjs +0 -175
- package/src/hooks-settings.mutations.mjs +0 -99
- package/src/link-skills.mutations.mjs +0 -140
- package/src/new-paper.mutations.mjs +0 -65
- package/src/structure.mutations.mjs +0 -83
|
@@ -45,14 +45,22 @@
|
|
|
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
|
-
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
import {
|
|
53
|
+
consumerContactEmail,
|
|
54
|
+
isMain,
|
|
55
|
+
} from "../../paper-pipeline/scripts/consumer.mjs";
|
|
56
|
+
|
|
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.
|
|
61
|
+
const CACHE_PATH =
|
|
62
|
+
process.env.VERIFY_CITES_CACHE ||
|
|
63
|
+
join(homedir(), ".cache", "paperlint", "cite-cache.json");
|
|
56
64
|
// 🔴 THE ADDRESS IS THE CONSUMER'S, NOT OURS. Crossref's "polite pool" keys off this `mailto:`:
|
|
57
65
|
// it decides who gets the faster tier and, more to the point, WHOM THEY WARN before blocking.
|
|
58
66
|
// Hard-coded, it pointed at one person for every user of this package — so the warnings would
|
|
@@ -122,11 +130,6 @@ export function titleSimilarity(a, b) {
|
|
|
122
130
|
return 1 - levenshtein(na, nb) / maxLen;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
/** Two titles "match" iff normalized similarity ≥ threshold (default 0.70). */
|
|
126
|
-
export function titlesMatch(a, b, threshold = TITLE_THRESHOLD) {
|
|
127
|
-
return titleSimilarity(a, b) >= threshold;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
133
|
/** Normalize a DOI: strip a `doi:` / resolver-URL prefix and any TRAILING sentence
|
|
131
134
|
* punctuation a .bib or prose extractor swallowed. A trailing `.` makes doi.org
|
|
132
135
|
* return responseCode 100, so an unnormalized real DOI reads as fabrication (the
|
|
@@ -201,7 +204,7 @@ function isAcronymOf(base, words) {
|
|
|
201
204
|
for (let i = 0; i < words.length; i++) {
|
|
202
205
|
let acr = "";
|
|
203
206
|
for (let j = i; j < words.length && acr.length < base.length; j++) {
|
|
204
|
-
acr += words[j]
|
|
207
|
+
acr += words[j].charAt(0); // "" for an empty token, like the first letter of nothing
|
|
205
208
|
}
|
|
206
209
|
if (acr === base) return true;
|
|
207
210
|
}
|
|
@@ -251,8 +254,7 @@ export function titleRelation(a, b) {
|
|
|
251
254
|
const sb = scriptsOf(nb);
|
|
252
255
|
if (sa.size && sb.size && ![...sa].some((x) => sb.has(x)))
|
|
253
256
|
return "incomparable";
|
|
254
|
-
|
|
255
|
-
if (sim >= TITLE_THRESHOLD) return "match";
|
|
257
|
+
if (titleSimilarity(a, b) >= TITLE_THRESHOLD) return "match";
|
|
256
258
|
const A = contentTokens(na);
|
|
257
259
|
const B = contentTokens(nb);
|
|
258
260
|
if (A.length && B.length) {
|
|
@@ -686,50 +688,78 @@ 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}`;
|
|
703
720
|
}
|
|
704
721
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
722
|
+
/** One request, classified: rate-limit/outage and other failures → `ok:false`, 404 → `notFound`. */
|
|
723
|
+
async function request(url, json, signal) {
|
|
724
|
+
const res = await fetch(url, {
|
|
725
|
+
headers: {
|
|
726
|
+
"User-Agent": USER_AGENT,
|
|
727
|
+
Accept: json ? "application/json" : "*/*",
|
|
728
|
+
},
|
|
729
|
+
signal,
|
|
730
|
+
});
|
|
731
|
+
if (res.status === 429 || res.status >= 500) {
|
|
732
|
+
return { ok: false, reason: `http ${res.status}` }; // rate-limit / outage
|
|
733
|
+
}
|
|
734
|
+
if (res.status === 404) return { ok: true, notFound: true };
|
|
735
|
+
if (!res.ok) return { ok: false, reason: `http ${res.status}` };
|
|
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.
|
|
708
740
|
try {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
"User-Agent": USER_AGENT,
|
|
712
|
-
Accept: json ? "application/json" : "*/*",
|
|
713
|
-
},
|
|
714
|
-
signal: ctrl.signal,
|
|
715
|
-
});
|
|
716
|
-
if (res.status === 429 || res.status >= 500) {
|
|
717
|
-
return { ok: false, reason: `http ${res.status}` }; // rate-limit / outage
|
|
718
|
-
}
|
|
719
|
-
if (res.status === 404) return { ok: true, notFound: true };
|
|
720
|
-
if (!res.ok) return { ok: false, reason: `http ${res.status}` };
|
|
721
|
-
const data = json ? await res.json() : await res.text();
|
|
722
|
-
return { ok: true, data };
|
|
723
|
-
} catch (e) {
|
|
741
|
+
return { ok: true, data: JSON.parse(text) };
|
|
742
|
+
} catch {
|
|
724
743
|
return {
|
|
725
744
|
ok: false,
|
|
726
|
-
reason:
|
|
745
|
+
reason: text.trimStart().startsWith("<")
|
|
746
|
+
? "answered HTML, not JSON"
|
|
747
|
+
: "answered something that is not JSON",
|
|
727
748
|
};
|
|
728
|
-
} finally {
|
|
729
|
-
clearTimeout(timer);
|
|
730
749
|
}
|
|
731
750
|
}
|
|
732
751
|
|
|
752
|
+
async function httpGet(url, { json = true } = {}) {
|
|
753
|
+
const ctrl = new AbortController();
|
|
754
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
755
|
+
const result = await request(url, json, ctrl.signal).catch((e) => ({
|
|
756
|
+
ok: false,
|
|
757
|
+
reason: e.name === "AbortError" ? "timeout" : String(e),
|
|
758
|
+
}));
|
|
759
|
+
clearTimeout(timer);
|
|
760
|
+
return result;
|
|
761
|
+
}
|
|
762
|
+
|
|
733
763
|
// Each *Resolver returns a normalized response (the shape classifyResolver eats).
|
|
734
764
|
// A transport failure → { transport:'error' }: degrade to unresolvable, NEVER false.
|
|
735
765
|
|
|
@@ -739,7 +769,7 @@ async function crossrefResolve(citation) {
|
|
|
739
769
|
const r = await httpGet(
|
|
740
770
|
`https://api.crossref.org/works/${encodeURIComponent(citation.doi)}`,
|
|
741
771
|
);
|
|
742
|
-
if (!r.ok) return { db, transport: "error" };
|
|
772
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
743
773
|
if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
|
|
744
774
|
const w = r.data?.message;
|
|
745
775
|
return {
|
|
@@ -757,24 +787,22 @@ async function crossrefResolve(citation) {
|
|
|
757
787
|
: null,
|
|
758
788
|
};
|
|
759
789
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
}
|
|
777
|
-
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
|
+
};
|
|
778
806
|
}
|
|
779
807
|
function crYear(w) {
|
|
780
808
|
return (
|
|
@@ -790,7 +818,7 @@ async function openalexResolve(citation) {
|
|
|
790
818
|
const r = await httpGet(
|
|
791
819
|
`https://api.openalex.org/works/doi:${encodeURIComponent(citation.doi)}`,
|
|
792
820
|
);
|
|
793
|
-
if (!r.ok) return { db, transport: "error" };
|
|
821
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
794
822
|
if (r.notFound) return { db, transport: "ok", query: "doi", record: null };
|
|
795
823
|
const w = r.data;
|
|
796
824
|
return {
|
|
@@ -802,63 +830,61 @@ async function openalexResolve(citation) {
|
|
|
802
830
|
: null,
|
|
803
831
|
};
|
|
804
832
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
}
|
|
821
|
-
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
|
+
};
|
|
822
848
|
}
|
|
823
849
|
|
|
824
|
-
|
|
850
|
+
const S2 = "https://api.semanticscholar.org/graph/v1/paper";
|
|
851
|
+
|
|
852
|
+
// By its id when the citation has one, else by the title — the resolvers run only for a citation
|
|
853
|
+
// with at least one of the three, so the title search is given a title.
|
|
854
|
+
const semanticScholarResolve = (citation) =>
|
|
855
|
+
citation.doi || citation.arxiv
|
|
856
|
+
? semanticScholarById(citation)
|
|
857
|
+
: semanticScholarByTitle(citation.title);
|
|
858
|
+
|
|
859
|
+
async function semanticScholarById(citation) {
|
|
825
860
|
const db = "semantic_scholar";
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
)
|
|
852
|
-
|
|
853
|
-
const items = r.data?.data || [];
|
|
854
|
-
return {
|
|
855
|
-
db,
|
|
856
|
-
transport: "ok",
|
|
857
|
-
query: "title",
|
|
858
|
-
records: items.map((w) => ({ title: w.title || "", year: w.year })),
|
|
859
|
-
};
|
|
860
|
-
}
|
|
861
|
-
return null;
|
|
861
|
+
const query = citation.doi ? "doi" : "arxiv";
|
|
862
|
+
const id = citation.doi ? `DOI:${citation.doi}` : `arXiv:${citation.arxiv}`;
|
|
863
|
+
const r = await httpGet(`${S2}/${encodeURIComponent(id)}?fields=title,year`);
|
|
864
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
865
|
+
if (r.notFound) return { db, transport: "ok", query, record: null };
|
|
866
|
+
const w = r.data;
|
|
867
|
+
return {
|
|
868
|
+
db,
|
|
869
|
+
transport: "ok",
|
|
870
|
+
query,
|
|
871
|
+
record: w?.title != null ? { title: w.title || "", year: w.year } : null,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function semanticScholarByTitle(title) {
|
|
876
|
+
const db = "semantic_scholar";
|
|
877
|
+
const r = await httpGet(
|
|
878
|
+
`${S2}/search?query=${encodeURIComponent(title)}&fields=title,year&limit=5`,
|
|
879
|
+
);
|
|
880
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
881
|
+
const items = r.data?.data || [];
|
|
882
|
+
return {
|
|
883
|
+
db,
|
|
884
|
+
transport: "ok",
|
|
885
|
+
query: "title",
|
|
886
|
+
records: items.map((w) => ({ title: w.title || "", year: w.year })),
|
|
887
|
+
};
|
|
862
888
|
}
|
|
863
889
|
|
|
864
890
|
async function arxivResolve(citation) {
|
|
@@ -869,26 +895,24 @@ async function arxivResolve(citation) {
|
|
|
869
895
|
`https://export.arxiv.org/api/query?id_list=${encodeURIComponent(citation.arxiv)}&max_results=1`,
|
|
870
896
|
{ json: false },
|
|
871
897
|
);
|
|
872
|
-
if (!r.ok) return { db, transport: "error" };
|
|
898
|
+
if (!r.ok) return { db, transport: "error", reason: r.reason };
|
|
873
899
|
const entry = parseArxivFeed(r.data);
|
|
874
900
|
return { db, transport: "ok", query: "arxiv", record: entry[0] ?? null };
|
|
875
901
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
}
|
|
891
|
-
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
|
+
};
|
|
892
916
|
}
|
|
893
917
|
export function parseArxivFeed(xml) {
|
|
894
918
|
const out = [];
|
|
@@ -917,13 +941,12 @@ export function parseArxivFeed(xml) {
|
|
|
917
941
|
* means "no metadata here"; doi.org responseCode 100 means "this DOI does not
|
|
918
942
|
* exist". Returns { transport, responseCode } for classifyDoiAuthority.
|
|
919
943
|
*/
|
|
920
|
-
async function doiAuthorityCheck(
|
|
921
|
-
if (!citation.doi) return null;
|
|
944
|
+
async function doiAuthorityCheck(doi) {
|
|
922
945
|
// Keep the DOI's own slashes as path separators; encode the rest.
|
|
923
|
-
const path = encodeURIComponent(
|
|
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",
|
|
@@ -931,113 +954,230 @@ async function doiAuthorityCheck(citation) {
|
|
|
931
954
|
};
|
|
932
955
|
}
|
|
933
956
|
|
|
934
|
-
async function nvdCheck(
|
|
935
|
-
if (!
|
|
936
|
-
if (!isValidCveId(citation.cve)) {
|
|
957
|
+
async function nvdCheck(cve) {
|
|
958
|
+
if (!isValidCveId(cve)) {
|
|
937
959
|
// Malformed CVE id — treat as a provided-id-that-cannot-resolve (fabrication).
|
|
938
960
|
return { transport: "ok", found: false };
|
|
939
961
|
}
|
|
940
962
|
const r = await httpGet(
|
|
941
963
|
`https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(
|
|
942
|
-
|
|
964
|
+
cve.toUpperCase(),
|
|
943
965
|
)}`,
|
|
944
966
|
);
|
|
945
|
-
if (!r.ok) return { transport: "error" };
|
|
967
|
+
if (!r.ok) return { transport: "error", reason: r.reason };
|
|
946
968
|
if (r.notFound) return { transport: "ok", found: false };
|
|
947
969
|
const total = r.data?.totalResults ?? (r.data?.vulnerabilities || []).length;
|
|
948
970
|
return { transport: "ok", found: total > 0 };
|
|
949
971
|
}
|
|
950
972
|
|
|
951
973
|
/**
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
* CrossRef / OpenAlex / Semantic Scholar / arXiv / NVD, i.e. a direct route to a 429, which is
|
|
959
|
-
* exactly why the neighboring `bib-authors.mjs` has 900ms pauses.
|
|
960
|
-
*
|
|
961
|
-
* Today the contract IS HONORED: the only caller is `main()` in this same file (a sequential
|
|
962
|
-
* `for … of` with `await`), so the interleaving `require-atomic-updates` warns about does not
|
|
963
|
-
* exist. The rule is deliberately left at `warn` for that reason: it judges the SHAPE, not the
|
|
964
|
-
* fact of it. But the function IS EXPORTED and takes a shared cache — exactly what gets
|
|
965
|
-
* parallelized via `Promise.all` — so the warning is left visible rather than suppressed. The real
|
|
966
|
-
* fix would be caching UNRESOLVED promises in the cache (in-flight deduplication) rather than
|
|
967
|
-
* values; that's incompatible with the current JSON-based `saveCache()` and so hasn't been done
|
|
968
|
-
* (measured 2026-08-28).
|
|
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.
|
|
969
980
|
*/
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
+
);
|
|
981
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) {
|
|
982
1036
|
const evidence = [];
|
|
983
|
-
|
|
984
|
-
// Paper resolvers (skip when the citation is CVE/commit-only).
|
|
1037
|
+
const matched = () => evidence.at(-1)?.status === "matched";
|
|
985
1038
|
if (citation.doi || citation.arxiv || citation.title) {
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
];
|
|
992
|
-
for (const { name, fn } of resolvers) {
|
|
993
|
-
const ck = cacheKey(
|
|
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),
|
|
994
1044
|
name,
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
: citation.arxiv
|
|
998
|
-
? `arxiv:${citation.arxiv}`
|
|
999
|
-
: `title:${titleHash(citation.title)}`,
|
|
1045
|
+
() => fn(question),
|
|
1046
|
+
() => true,
|
|
1000
1047
|
);
|
|
1001
|
-
|
|
1002
|
-
if (
|
|
1003
|
-
resp = cache[ck];
|
|
1004
|
-
} else {
|
|
1005
|
-
resp = await fn(citation);
|
|
1006
|
-
if (resp && resp.transport === "ok") cache[ck] = resp; // only cache successes
|
|
1007
|
-
}
|
|
1008
|
-
if (resp) evidence.push(classifyResolver(citation, resp));
|
|
1048
|
+
evidence.push(classifyResolver(citation, resp));
|
|
1049
|
+
if (matched()) return evidence;
|
|
1009
1050
|
}
|
|
1010
|
-
|
|
1011
|
-
//
|
|
1012
|
-
// Always consulted when a DOI is present so a registry-404 never becomes
|
|
1013
|
-
// `false` without doi.org confirming responseCode 100.
|
|
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.
|
|
1014
1053
|
if (citation.doi) {
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
// Cache only POSITIVE existence; never
|
|
1020
|
-
//
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
if (auth) evidence.push(classifyDoiAuthority(auth));
|
|
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));
|
|
1025
1063
|
}
|
|
1026
1064
|
}
|
|
1027
|
-
|
|
1028
|
-
// CVE via NVD.
|
|
1029
1065
|
if (citation.cve) {
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
+
);
|
|
1036
1074
|
const e = checkNvd(citation, nvd);
|
|
1037
1075
|
if (e) evidence.push(e);
|
|
1038
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
|
+
}
|
|
1039
1133
|
|
|
1040
|
-
|
|
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.
|
|
1137
|
+
*
|
|
1138
|
+
* ⚠️ CONCURRENT CALLS SHARING ONE `cache` MAY ASK TWICE. The cache is read BEFORE the `await` and
|
|
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.
|
|
1142
|
+
*
|
|
1143
|
+
* Callers: `main()` here calls it one citation at a time; `paperlint build`'s references step
|
|
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.
|
|
1147
|
+
*/
|
|
1148
|
+
export async function verifyCitationLive(
|
|
1149
|
+
citation,
|
|
1150
|
+
{ cache = {}, offline = false, breaker = createBreaker() } = {},
|
|
1151
|
+
) {
|
|
1152
|
+
citation = normalizeIdentifiers(citation); // clean doi:/arXiv: prefixes + trailing punct first
|
|
1153
|
+
const commitFlags = checkCommit(citation);
|
|
1154
|
+
|
|
1155
|
+
if (offline) {
|
|
1156
|
+
// No network: everything degrades to unresolvable (matches narrowed-false).
|
|
1157
|
+
return reduceVerdict(citation, [], commitFlags);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
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" };
|
|
1174
|
+
}
|
|
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;
|
|
1041
1181
|
}
|
|
1042
1182
|
|
|
1043
1183
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1072,6 +1212,7 @@ async function main() {
|
|
|
1072
1212
|
}
|
|
1073
1213
|
|
|
1074
1214
|
const cache = loadCache();
|
|
1215
|
+
const breaker = createBreaker();
|
|
1075
1216
|
const results = [];
|
|
1076
1217
|
for (const c of cites) {
|
|
1077
1218
|
if (!c || !c.id) {
|
|
@@ -1083,7 +1224,7 @@ async function main() {
|
|
|
1083
1224
|
});
|
|
1084
1225
|
continue;
|
|
1085
1226
|
}
|
|
1086
|
-
results.push(await verifyCitationLive(c, { cache, offline }));
|
|
1227
|
+
results.push(await verifyCitationLive(c, { cache, offline, breaker }));
|
|
1087
1228
|
}
|
|
1088
1229
|
saveCache(cache);
|
|
1089
1230
|
|
|
@@ -1103,6 +1244,10 @@ async function main() {
|
|
|
1103
1244
|
}
|
|
1104
1245
|
|
|
1105
1246
|
// Run as CLI only when invoked directly (not when imported by the test).
|
|
1106
|
-
|
|
1247
|
+
// 🔴 `isMain`, not `fileURLToPath(import.meta.url) === process.argv[1]`. The skill runs this file
|
|
1248
|
+
// through `.claude/skills/verify-citations`, a symlink; Node reports `import.meta.url` as the
|
|
1249
|
+
// realpath and `argv[1]` as the link, so the old comparison was false and the CLI exited 0 having
|
|
1250
|
+
// done nothing — a silent PASS for any bibliography. (consumer.mjs `isMain` documents the class.)
|
|
1251
|
+
if (isMain(import.meta.url)) {
|
|
1107
1252
|
main();
|
|
1108
1253
|
}
|