paperlint 3.1.0 → 3.1.1
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 +51 -56
- package/CONTRIBUTING.md +30 -18
- 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/index.d.ts.map +1 -1
- package/dist/adapters/references/index.js +14 -5
- 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 +10 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +7 -8
- 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/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/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.js +2 -2
- 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 +1 -1
- package/docs/prior-art/test-tooling.md +7 -30
- 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/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/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 +5 -5
- package/skills/verify-citations/SKILL.md.spec.ts +4 -4
- package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -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 +2 -1
- package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
- package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
- package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
- package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -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/index.test.ts +228 -0
- package/src/adapters/references/index.ts +21 -6
- 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 +13 -9
- 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/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/presets.test.ts +2 -0
- package/src/presets.ts +7 -1
- package/src/references.test.ts +97 -0
- package/src/references.ts +2 -2
- 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
|
@@ -278,7 +278,8 @@ export function surnames(authorField) {
|
|
|
278
278
|
.filter((s) => s && s !== "others");
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
// Only called for an entry that has an author (checkAuthors filters on it first).
|
|
282
|
+
const truncated = (authorField) => /\band\s+others\b/i.test(authorField);
|
|
282
283
|
|
|
283
284
|
/** Is the entry claiming a PUBLISHED venue (as opposed to a preprint)? */
|
|
284
285
|
export function claimsPublished(e) {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-cites.mjs as a process — the way the verify-citations skill runs it, which is THROUGH A
|
|
3
|
+
* SYMLINK (`.claude/skills/verify-citations -> …/skills/verify-citations`, made by `paperlint
|
|
4
|
+
* init`). Only `--offline` runs here: the online resolvers are driven in-process with a fake
|
|
5
|
+
* `fetch` (verify-cites.net.test.mjs), because a test must not depend on four live APIs.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { test } from "vitest";
|
|
12
|
+
import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const root = useTempDir("verify-cites-cli-");
|
|
16
|
+
// The skill's own address for the script: a link to the skill directory, as `paperlint init` makes.
|
|
17
|
+
symlinkSync(join(HERE, ".."), join(root, "verify-citations"));
|
|
18
|
+
const LINKED = join(root, "verify-citations", "scripts", "verify-cites.mjs");
|
|
19
|
+
|
|
20
|
+
test("run through the skill's symlink, the CLI runs at all (--help prints the usage)", () => {
|
|
21
|
+
assert.deepEqual(runNode(LINKED, ["--help"]), {
|
|
22
|
+
status: 0,
|
|
23
|
+
stdout: "",
|
|
24
|
+
stderr: "usage: verify-cites.mjs <cites.json | refs.bib | -> [--offline]\n",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const SCRIPT = join(HERE, "verify-cites.mjs");
|
|
29
|
+
const CACHE = join(root, "cache.json");
|
|
30
|
+
const env = { VERIFY_CITES_CACHE: CACHE };
|
|
31
|
+
writeTree(root, {
|
|
32
|
+
"cites.json": JSON.stringify([
|
|
33
|
+
{ id: "a", doi: "10.1/x", title: "A Title Of Some Length" },
|
|
34
|
+
{ title: "no id at all" },
|
|
35
|
+
{ id: "h", title: "Hygiene", commit: "abc123" },
|
|
36
|
+
]),
|
|
37
|
+
"one.json": JSON.stringify({ id: "solo", title: "Just One Citation" }),
|
|
38
|
+
"refs.bib": "@article{k, title={A Bib Title}, doi={10.2/y}, year={2020}}\n",
|
|
39
|
+
"bad.json": "{ not json",
|
|
40
|
+
// Loaded with --import before the script: every registry answers 404, so a DOI the authority
|
|
41
|
+
// does not know is FALSE — the one verdict that fails the run.
|
|
42
|
+
"fetch-404.mjs":
|
|
43
|
+
"globalThis.fetch = async () => new Response(null, { status: 404 });\n",
|
|
44
|
+
// Prints the User-Agent of every request, then answers 404.
|
|
45
|
+
"fetch-ua.mjs":
|
|
46
|
+
"globalThis.fetch = async (url, init) => {\n" +
|
|
47
|
+
" process.stderr.write(`UA ${init.headers['User-Agent']}\\n`);\n" +
|
|
48
|
+
" return new Response(null, { status: 404 });\n};\n",
|
|
49
|
+
"consumer/paperlint.json": JSON.stringify({ contactEmail: "me@example.org" }),
|
|
50
|
+
});
|
|
51
|
+
const offline = (args, opts = {}) =>
|
|
52
|
+
runNode(SCRIPT, [...args, "--offline"], { env, ...opts });
|
|
53
|
+
const parsed = (r) => ({ ...r, stdout: JSON.parse(r.stdout) });
|
|
54
|
+
const SUMMARY_PASS = (n, t, u, flagged) =>
|
|
55
|
+
`\nverify-cites: ${n} citation(s) — ${t} true, 0 false (fabrication), ${u} unresolvable` +
|
|
56
|
+
(flagged ? `, ${flagged} with hygiene flag(s)` : "") +
|
|
57
|
+
"\nPASS — no fabrication (unresolvable is advisory)\n";
|
|
58
|
+
|
|
59
|
+
test("-h is --help", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
runNode(SCRIPT, ["-h"]).stderr,
|
|
62
|
+
runNode(SCRIPT, ["--help"]).stderr,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an input that cannot be read: exit 2, named", () => {
|
|
67
|
+
const r = offline([join(root, "bad.json")]);
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
{ status: r.status, stdout: r.stdout, stderr: r.stderr.split(" — ")[0] },
|
|
70
|
+
{ status: 2, stdout: "", stderr: "error: could not read input" },
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("offline over a JSON list: a citation with no id is unresolvable by name, a short SHA is flagged", () => {
|
|
75
|
+
const r = parsed(offline([join(root, "cites.json")]));
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
{
|
|
78
|
+
status: r.status,
|
|
79
|
+
ids: r.stdout.map((x) => [x.id, x.verdict, x.flags?.length ?? 0]),
|
|
80
|
+
stderr: r.stderr,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
status: 0,
|
|
84
|
+
ids: [
|
|
85
|
+
["a", "unresolvable", 0],
|
|
86
|
+
[null, "unresolvable", 0],
|
|
87
|
+
["h", "unresolvable", 1],
|
|
88
|
+
],
|
|
89
|
+
stderr: SUMMARY_PASS(3, 0, 3, 1),
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("one JSON object on stdin, with `-` and with no path at all", () => {
|
|
95
|
+
const input = readFileSync(join(root, "one.json"), "utf8");
|
|
96
|
+
const dash = parsed(offline(["-"], { input }));
|
|
97
|
+
const bare = parsed(offline([], { input }));
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
[dash.stdout.map((x) => x.id), bare.stdout.map((x) => x.id), dash.status],
|
|
100
|
+
[["solo"], ["solo"], 0],
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a .bib file is parsed into citations", () => {
|
|
105
|
+
const r = parsed(offline([join(root, "refs.bib")]));
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
r.stdout.map((x) => x.id),
|
|
108
|
+
["k"],
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("online, a DOI the authority does not know is FALSE: FAIL, exit 1, and the cache is written", () => {
|
|
113
|
+
rmSync(CACHE, { force: true });
|
|
114
|
+
const r = runNode(SCRIPT, [join(root, "refs.bib")], {
|
|
115
|
+
env,
|
|
116
|
+
nodeArgs: ["--import", join(root, "fetch-404.mjs")],
|
|
117
|
+
});
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
{
|
|
120
|
+
status: r.status,
|
|
121
|
+
verdicts: JSON.parse(r.stdout).map((x) => x.verdict),
|
|
122
|
+
tail: r.stderr.trim().split("\n").pop(),
|
|
123
|
+
cached: Object.keys(JSON.parse(readFileSync(CACHE, "utf8"))).sort(),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
status: 1,
|
|
127
|
+
verdicts: ["false"],
|
|
128
|
+
tail: "FAIL — fabrication(s) found (exit 1)",
|
|
129
|
+
// 404s are successful transports, so they are cached; the authority's 100 is not.
|
|
130
|
+
cached: [
|
|
131
|
+
"crossref:doi:10.2/y",
|
|
132
|
+
"openalex:doi:10.2/y",
|
|
133
|
+
"semantic_scholar:doi:10.2/y",
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a corrupt cache is ignored, and a cache that cannot be written does not fail the run", () => {
|
|
140
|
+
writeFileSync(CACHE, "{ corrupt");
|
|
141
|
+
const corrupt = offline([join(root, "one.json")]);
|
|
142
|
+
const unwritable = runNode(SCRIPT, [join(root, "one.json"), "--offline"], {
|
|
143
|
+
env: { VERIFY_CITES_CACHE: root }, // a directory: reading and writing both fail
|
|
144
|
+
});
|
|
145
|
+
assert.deepEqual([corrupt.status, unwritable.status], [0, 0]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("the consumer's declared contact reaches every request's User-Agent (Crossref's polite pool)", () => {
|
|
149
|
+
const r = runNode(SCRIPT, [join(root, "refs.bib")], {
|
|
150
|
+
env: { ...env, CLAUDE_PROJECT_DIR: join(root, "consumer") },
|
|
151
|
+
nodeArgs: ["--import", join(root, "fetch-ua.mjs")],
|
|
152
|
+
});
|
|
153
|
+
const agents = new Set(
|
|
154
|
+
r.stderr.split("\n").filter((l) => l.startsWith("UA ")),
|
|
155
|
+
);
|
|
156
|
+
assert.deepEqual(
|
|
157
|
+
[...agents],
|
|
158
|
+
["UA verify-cites/1.0 (citation gate; mailto:me@example.org)"],
|
|
159
|
+
);
|
|
160
|
+
});
|
|
@@ -49,10 +49,16 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
|
49
49
|
import { fileURLToPath } from "node:url";
|
|
50
50
|
import { dirname, join, extname } from "node:path";
|
|
51
51
|
import { createHash } from "node:crypto";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
consumerContactEmail,
|
|
54
|
+
isMain,
|
|
55
|
+
} from "../../paper-pipeline/scripts/consumer.mjs";
|
|
53
56
|
|
|
54
57
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
55
|
-
|
|
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.
|
|
60
|
+
const CACHE_PATH =
|
|
61
|
+
process.env.VERIFY_CITES_CACHE || join(__dirname, ".cite-cache.json");
|
|
56
62
|
// 🔴 THE ADDRESS IS THE CONSUMER'S, NOT OURS. Crossref's "polite pool" keys off this `mailto:`:
|
|
57
63
|
// it decides who gets the faster tier and, more to the point, WHOM THEY WARN before blocking.
|
|
58
64
|
// Hard-coded, it pointed at one person for every user of this package — so the warnings would
|
|
@@ -122,11 +128,6 @@ export function titleSimilarity(a, b) {
|
|
|
122
128
|
return 1 - levenshtein(na, nb) / maxLen;
|
|
123
129
|
}
|
|
124
130
|
|
|
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
131
|
/** Normalize a DOI: strip a `doi:` / resolver-URL prefix and any TRAILING sentence
|
|
131
132
|
* punctuation a .bib or prose extractor swallowed. A trailing `.` makes doi.org
|
|
132
133
|
* return responseCode 100, so an unnormalized real DOI reads as fabrication (the
|
|
@@ -201,7 +202,7 @@ function isAcronymOf(base, words) {
|
|
|
201
202
|
for (let i = 0; i < words.length; i++) {
|
|
202
203
|
let acr = "";
|
|
203
204
|
for (let j = i; j < words.length && acr.length < base.length; j++) {
|
|
204
|
-
acr += words[j]
|
|
205
|
+
acr += words[j].charAt(0); // "" for an empty token, like the first letter of nothing
|
|
205
206
|
}
|
|
206
207
|
if (acr === base) return true;
|
|
207
208
|
}
|
|
@@ -251,8 +252,7 @@ export function titleRelation(a, b) {
|
|
|
251
252
|
const sb = scriptsOf(nb);
|
|
252
253
|
if (sa.size && sb.size && ![...sa].some((x) => sb.has(x)))
|
|
253
254
|
return "incomparable";
|
|
254
|
-
|
|
255
|
-
if (sim >= TITLE_THRESHOLD) return "match";
|
|
255
|
+
if (titleSimilarity(a, b) >= TITLE_THRESHOLD) return "match";
|
|
256
256
|
const A = contentTokens(na);
|
|
257
257
|
const B = contentTokens(nb);
|
|
258
258
|
if (A.length && B.length) {
|
|
@@ -702,32 +702,33 @@ function cacheKey(kind, val) {
|
|
|
702
702
|
return `${kind}:${val}`;
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
+
/** One request, classified: rate-limit/outage and other failures → `ok:false`, 404 → `notFound`. */
|
|
706
|
+
async function request(url, json, signal) {
|
|
707
|
+
const res = await fetch(url, {
|
|
708
|
+
headers: {
|
|
709
|
+
"User-Agent": USER_AGENT,
|
|
710
|
+
Accept: json ? "application/json" : "*/*",
|
|
711
|
+
},
|
|
712
|
+
signal,
|
|
713
|
+
});
|
|
714
|
+
if (res.status === 429 || res.status >= 500) {
|
|
715
|
+
return { ok: false, reason: `http ${res.status}` }; // rate-limit / outage
|
|
716
|
+
}
|
|
717
|
+
if (res.status === 404) return { ok: true, notFound: true };
|
|
718
|
+
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 };
|
|
721
|
+
}
|
|
722
|
+
|
|
705
723
|
async function httpGet(url, { json = true } = {}) {
|
|
706
724
|
const ctrl = new AbortController();
|
|
707
725
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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) {
|
|
724
|
-
return {
|
|
725
|
-
ok: false,
|
|
726
|
-
reason: e.name === "AbortError" ? "timeout" : String(e),
|
|
727
|
-
};
|
|
728
|
-
} finally {
|
|
729
|
-
clearTimeout(timer);
|
|
730
|
-
}
|
|
726
|
+
const result = await request(url, json, ctrl.signal).catch((e) => ({
|
|
727
|
+
ok: false,
|
|
728
|
+
reason: e.name === "AbortError" ? "timeout" : String(e),
|
|
729
|
+
}));
|
|
730
|
+
clearTimeout(timer);
|
|
731
|
+
return result;
|
|
731
732
|
}
|
|
732
733
|
|
|
733
734
|
// Each *Resolver returns a normalized response (the shape classifyResolver eats).
|
|
@@ -821,44 +822,44 @@ async function openalexResolve(citation) {
|
|
|
821
822
|
return null;
|
|
822
823
|
}
|
|
823
824
|
|
|
824
|
-
|
|
825
|
+
const S2 = "https://api.semanticscholar.org/graph/v1/paper";
|
|
826
|
+
|
|
827
|
+
// By its id when the citation has one, else by the title — the resolvers run only for a citation
|
|
828
|
+
// with at least one of the three, so the title search is given a title.
|
|
829
|
+
const semanticScholarResolve = (citation) =>
|
|
830
|
+
citation.doi || citation.arxiv
|
|
831
|
+
? semanticScholarById(citation)
|
|
832
|
+
: semanticScholarByTitle(citation.title);
|
|
833
|
+
|
|
834
|
+
async function semanticScholarById(citation) {
|
|
825
835
|
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;
|
|
836
|
+
const query = citation.doi ? "doi" : "arxiv";
|
|
837
|
+
const id = citation.doi ? `DOI:${citation.doi}` : `arXiv:${citation.arxiv}`;
|
|
838
|
+
const r = await httpGet(`${S2}/${encodeURIComponent(id)}?fields=title,year`);
|
|
839
|
+
if (!r.ok) return { db, transport: "error" };
|
|
840
|
+
if (r.notFound) return { db, transport: "ok", query, record: null };
|
|
841
|
+
const w = r.data;
|
|
842
|
+
return {
|
|
843
|
+
db,
|
|
844
|
+
transport: "ok",
|
|
845
|
+
query,
|
|
846
|
+
record: w?.title != null ? { title: w.title || "", year: w.year } : null,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function semanticScholarByTitle(title) {
|
|
851
|
+
const db = "semantic_scholar";
|
|
852
|
+
const r = await httpGet(
|
|
853
|
+
`${S2}/search?query=${encodeURIComponent(title)}&fields=title,year&limit=5`,
|
|
854
|
+
);
|
|
855
|
+
if (!r.ok) return { db, transport: "error" };
|
|
856
|
+
const items = r.data?.data || [];
|
|
857
|
+
return {
|
|
858
|
+
db,
|
|
859
|
+
transport: "ok",
|
|
860
|
+
query: "title",
|
|
861
|
+
records: items.map((w) => ({ title: w.title || "", year: w.year })),
|
|
862
|
+
};
|
|
862
863
|
}
|
|
863
864
|
|
|
864
865
|
async function arxivResolve(citation) {
|
|
@@ -917,10 +918,9 @@ export function parseArxivFeed(xml) {
|
|
|
917
918
|
* means "no metadata here"; doi.org responseCode 100 means "this DOI does not
|
|
918
919
|
* exist". Returns { transport, responseCode } for classifyDoiAuthority.
|
|
919
920
|
*/
|
|
920
|
-
async function doiAuthorityCheck(
|
|
921
|
-
if (!citation.doi) return null;
|
|
921
|
+
async function doiAuthorityCheck(doi) {
|
|
922
922
|
// Keep the DOI's own slashes as path separators; encode the rest.
|
|
923
|
-
const path = encodeURIComponent(
|
|
923
|
+
const path = encodeURIComponent(doi.trim()).replace(/%2F/gi, "/");
|
|
924
924
|
const r = await httpGet(`https://doi.org/api/handles/${path}`);
|
|
925
925
|
if (r.notFound) return { transport: "ok", responseCode: 100 }; // 404 = not found
|
|
926
926
|
if (!r.ok) return { transport: "error" };
|
|
@@ -931,15 +931,14 @@ async function doiAuthorityCheck(citation) {
|
|
|
931
931
|
};
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
-
async function nvdCheck(
|
|
935
|
-
if (!
|
|
936
|
-
if (!isValidCveId(citation.cve)) {
|
|
934
|
+
async function nvdCheck(cve) {
|
|
935
|
+
if (!isValidCveId(cve)) {
|
|
937
936
|
// Malformed CVE id — treat as a provided-id-that-cannot-resolve (fabrication).
|
|
938
937
|
return { transport: "ok", found: false };
|
|
939
938
|
}
|
|
940
939
|
const r = await httpGet(
|
|
941
940
|
`https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(
|
|
942
|
-
|
|
941
|
+
cve.toUpperCase(),
|
|
943
942
|
)}`,
|
|
944
943
|
);
|
|
945
944
|
if (!r.ok) return { transport: "error" };
|
|
@@ -951,21 +950,18 @@ async function nvdCheck(citation) {
|
|
|
951
950
|
/**
|
|
952
951
|
* Verify ONE citation live (with caching). Returns the reduceVerdict result.
|
|
953
952
|
*
|
|
954
|
-
*
|
|
955
|
-
* it (three places below), so two concurrent calls
|
|
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.
|
|
953
|
+
* ⚠️ 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.
|
|
960
957
|
*
|
|
961
|
-
*
|
|
962
|
-
* `
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
* (measured 2026-08-28).
|
|
958
|
+
* 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.
|
|
969
965
|
*/
|
|
970
966
|
export async function verifyCitationLive(
|
|
971
967
|
citation,
|
|
@@ -1015,13 +1011,13 @@ export async function verifyCitationLive(
|
|
|
1015
1011
|
const ck = cacheKey("doi_authority", citation.doi);
|
|
1016
1012
|
let auth = cache[ck];
|
|
1017
1013
|
if (!auth) {
|
|
1018
|
-
auth = await doiAuthorityCheck(citation);
|
|
1014
|
+
auth = await doiAuthorityCheck(citation.doi);
|
|
1019
1015
|
// Cache only POSITIVE existence; never cache authority-absent (100), so a
|
|
1020
1016
|
// freshly-minted DOI checked pre-propagation isn't pinned to `false` on re-run.
|
|
1021
1017
|
if (auth && auth.transport === "ok" && auth.responseCode !== 100)
|
|
1022
1018
|
cache[ck] = auth;
|
|
1023
1019
|
}
|
|
1024
|
-
|
|
1020
|
+
evidence.push(classifyDoiAuthority(auth));
|
|
1025
1021
|
}
|
|
1026
1022
|
}
|
|
1027
1023
|
|
|
@@ -1030,8 +1026,8 @@ export async function verifyCitationLive(
|
|
|
1030
1026
|
const ck = cacheKey("nvd", citation.cve.toUpperCase());
|
|
1031
1027
|
let nvd = cache[ck];
|
|
1032
1028
|
if (!nvd) {
|
|
1033
|
-
nvd = await nvdCheck(citation);
|
|
1034
|
-
if (nvd
|
|
1029
|
+
nvd = await nvdCheck(citation.cve);
|
|
1030
|
+
if (nvd.transport === "ok") cache[ck] = nvd;
|
|
1035
1031
|
}
|
|
1036
1032
|
const e = checkNvd(citation, nvd);
|
|
1037
1033
|
if (e) evidence.push(e);
|
|
@@ -1103,6 +1099,10 @@ async function main() {
|
|
|
1103
1099
|
}
|
|
1104
1100
|
|
|
1105
1101
|
// Run as CLI only when invoked directly (not when imported by the test).
|
|
1106
|
-
|
|
1102
|
+
// 🔴 `isMain`, not `fileURLToPath(import.meta.url) === process.argv[1]`. The skill runs this file
|
|
1103
|
+
// through `.claude/skills/verify-citations`, a symlink; Node reports `import.meta.url` as the
|
|
1104
|
+
// realpath and `argv[1]` as the link, so the old comparison was false and the CLI exited 0 having
|
|
1105
|
+
// done nothing — a silent PASS for any bibliography. (consumer.mjs `isMain` documents the class.)
|
|
1106
|
+
if (isMain(import.meta.url)) {
|
|
1107
1107
|
main();
|
|
1108
1108
|
}
|