paperlint 3.0.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/README.md +179 -180
- 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-engine.d.ts.map +1 -1
- package/dist/build-engine.js +6 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts +10 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +29 -11
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +93 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +296 -80
- 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/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +27 -0
- package/dist/engine.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 +4 -4
- 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/new-paper.d.ts +8 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +50 -15
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +30 -8
- package/dist/paper-files.d.ts.map +1 -1
- package/dist/paper-files.js +59 -30
- 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/structure.js +1 -1
- package/dist/structure.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 +8 -3
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +26 -10
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +12 -10
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +48 -4
- package/docs/prior-art/README.md +1 -1
- package/docs/prior-art/test-tooling.md +7 -30
- package/docs/rules.md +29 -25
- package/docs/skills.md +80 -0
- 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 +56 -9
- package/eslint-rules/paper-typography.test.mjs +186 -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.schema.json +1 -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/fixtures/toolchain-mirror/catalog.txt +1 -0
- package/fixtures/toolchain-mirror/install-tl +4 -1
- package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
- package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
- 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-engine.ts +6 -1
- package/src/build.harness.mjs +51 -11
- package/src/build.test.ts +362 -0
- package/src/build.ts +40 -14
- package/src/cli.harness.mjs +12 -19
- package/src/cli.test.ts +584 -0
- package/src/cli.ts +409 -94
- 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 +77 -7
- package/src/engine.ts +35 -0
- 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 +4 -4
- 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/linted-set.test.ts +118 -0
- package/src/new-paper.harness.mjs +5 -7
- package/src/new-paper.test.ts +203 -2
- package/src/new-paper.ts +84 -17
- package/src/paper-files.test.ts +122 -5
- package/src/paper-files.ts +99 -39
- package/src/paper-settings-commands.test.ts +41 -5
- package/src/paper-settings.test.ts +20 -0
- package/src/parse-args.test.ts +48 -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/rules-off.test.ts +111 -0
- package/src/structure.harness.mjs +5 -9
- package/src/structure.ts +1 -1
- package/src/tex-requirements.harness.mjs +34 -7
- package/src/tex-requirements.ts +12 -6
- package/src/toolchain.harness.mjs +44 -13
- package/src/toolchain.test.ts +280 -0
- package/src/toolchain.ts +37 -12
- package/src/types.ts +6 -0
- package/src/venue-rules.test.ts +63 -0
- package/src/venue-rules.ts +14 -12
- package/templates/paper/paperlint.json +1 -1
- 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 -79
- package/src/structure.mutations.mjs +0 -83
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-cites.mjs's network layer — the four registries, the DOI authority and NVD — driven
|
|
3
|
+
* in-process with a fake `fetch`, so every transport outcome is produced on purpose and nothing
|
|
4
|
+
* touches the network. Each case compares the whole verdict AND the exact requests made: a
|
|
5
|
+
* resolver that asks the wrong URL is as broken as one that misreads the answer.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
9
|
+
import { verifyCitationLive } from "./verify-cites.mjs";
|
|
10
|
+
|
|
11
|
+
const json = (status, body) =>
|
|
12
|
+
new Response(body === undefined ? null : JSON.stringify(body), { status });
|
|
13
|
+
const text = (status, body) => new Response(body, { status });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A fake `fetch`: `routes` maps a URL prefix to a response factory (or an Error to throw). The
|
|
17
|
+
* longest matching prefix wins; an unrouted URL fails the test. Returns the URLs asked, in order.
|
|
18
|
+
*/
|
|
19
|
+
function fakeFetch(routes) {
|
|
20
|
+
const calls = [];
|
|
21
|
+
vi.stubGlobal("fetch", async (url) => {
|
|
22
|
+
calls.push(url);
|
|
23
|
+
const hit = Object.keys(routes)
|
|
24
|
+
.filter((p) => url.startsWith(p))
|
|
25
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
26
|
+
if (hit === undefined) throw new Error(`unrouted request: ${url}`);
|
|
27
|
+
const r = routes[hit];
|
|
28
|
+
if (r instanceof Error) throw r;
|
|
29
|
+
return r();
|
|
30
|
+
});
|
|
31
|
+
return calls;
|
|
32
|
+
}
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.unstubAllGlobals();
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const CR = "https://api.crossref.org/works";
|
|
39
|
+
const OA = "https://api.openalex.org/works";
|
|
40
|
+
const S2 = "https://api.semanticscholar.org/graph/v1/paper";
|
|
41
|
+
const AX = "https://export.arxiv.org/api/query";
|
|
42
|
+
const DOI = "https://doi.org/api/handles/";
|
|
43
|
+
const NVD = "https://services.nvd.nist.gov/rest/json/cves/2.0";
|
|
44
|
+
const TITLE = "Attention Is All You Need";
|
|
45
|
+
const feed = (title, year) =>
|
|
46
|
+
`<feed><entry><id>http://arxiv.org/abs/1706.03762v1</id><published>${year}-06-12</published><title>${title}</title></entry></feed>`;
|
|
47
|
+
|
|
48
|
+
test("a DOI every registry resolves to the claimed paper, and the authority knows: true", async () => {
|
|
49
|
+
const calls = fakeFetch({
|
|
50
|
+
[CR]: () =>
|
|
51
|
+
json(200, {
|
|
52
|
+
message: { title: [TITLE], issued: { "date-parts": [[2017]] } },
|
|
53
|
+
}),
|
|
54
|
+
[OA]: () =>
|
|
55
|
+
json(200, { id: "W1", display_name: TITLE, publication_year: 2017 }),
|
|
56
|
+
[S2]: () => json(200, { title: TITLE, year: 2017 }),
|
|
57
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
58
|
+
});
|
|
59
|
+
const cite = { id: "v", doi: "10.1/x", title: TITLE, year: 2017 };
|
|
60
|
+
expect({ result: await verifyCitationLive(cite), calls }).toMatchSnapshot();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a DOI nobody knows, and the authority says it does not exist: false", async () => {
|
|
64
|
+
const calls = fakeFetch({
|
|
65
|
+
[CR]: () => json(404),
|
|
66
|
+
[OA]: () => json(404),
|
|
67
|
+
[S2]: () => json(404),
|
|
68
|
+
[DOI]: () => json(404),
|
|
69
|
+
});
|
|
70
|
+
const result = await verifyCitationLive({ id: "f", doi: "10.1/nope" });
|
|
71
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("every registry down (429, 503, 403, a thrown error): unresolvable, never false", async () => {
|
|
75
|
+
const calls = fakeFetch({
|
|
76
|
+
[CR]: () => json(429),
|
|
77
|
+
[OA]: () => json(503),
|
|
78
|
+
[S2]: () => json(403),
|
|
79
|
+
[DOI]: new TypeError("fetch failed"),
|
|
80
|
+
});
|
|
81
|
+
const result = await verifyCitationLive({
|
|
82
|
+
id: "u",
|
|
83
|
+
doi: "10.1/x",
|
|
84
|
+
title: TITLE,
|
|
85
|
+
});
|
|
86
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("records with fields missing: no title, a subtitle, a year only in `published`, no id", async () => {
|
|
90
|
+
const calls = fakeFetch({
|
|
91
|
+
[CR]: () =>
|
|
92
|
+
json(200, {
|
|
93
|
+
message: {
|
|
94
|
+
subtitle: ["All You Need"],
|
|
95
|
+
published: { "date-parts": [[2017]] },
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
[OA]: () => json(200, { display_name: TITLE }), // no `id` → no record
|
|
99
|
+
[S2]: () => json(200, { title: null }), // a null title → no record
|
|
100
|
+
[DOI]: () => json(200, { responseCode: "1" }), // not a number → unknown
|
|
101
|
+
});
|
|
102
|
+
const result = await verifyCitationLive({
|
|
103
|
+
id: "m",
|
|
104
|
+
doi: "10.1/x",
|
|
105
|
+
title: TITLE,
|
|
106
|
+
});
|
|
107
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an arXiv id: Semantic Scholar by arXiv id, the arXiv API by id_list", async () => {
|
|
111
|
+
const calls = fakeFetch({
|
|
112
|
+
[S2]: () => json(200, { title: TITLE, year: 2017 }),
|
|
113
|
+
[AX]: () => text(200, feed(TITLE, 2017)),
|
|
114
|
+
});
|
|
115
|
+
const result = await verifyCitationLive({
|
|
116
|
+
id: "a",
|
|
117
|
+
arxiv: "1706.03762",
|
|
118
|
+
title: TITLE,
|
|
119
|
+
});
|
|
120
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("an arXiv id nobody has: the arXiv API's empty feed and S2's 404", async () => {
|
|
124
|
+
const calls = fakeFetch({
|
|
125
|
+
[S2]: () => json(404),
|
|
126
|
+
[AX]: () => text(200, "<feed></feed>"),
|
|
127
|
+
});
|
|
128
|
+
const result = await verifyCitationLive({ id: "n", arxiv: "9999.99999" });
|
|
129
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a title only: every registry's search endpoint, hits and all", async () => {
|
|
133
|
+
const calls = fakeFetch({
|
|
134
|
+
[`${CR}?`]: () =>
|
|
135
|
+
json(200, {
|
|
136
|
+
message: {
|
|
137
|
+
items: [{ title: [TITLE], issued: { "date-parts": [[2017]] } }, {}],
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
[`${OA}?`]: () =>
|
|
141
|
+
json(200, {
|
|
142
|
+
results: [{ display_name: TITLE, publication_year: 2017 }, {}],
|
|
143
|
+
}),
|
|
144
|
+
[`${S2}/search`]: () =>
|
|
145
|
+
json(200, { data: [{ title: TITLE, year: 2017 }, {}] }),
|
|
146
|
+
[AX]: () => text(200, feed(TITLE, 2017)),
|
|
147
|
+
});
|
|
148
|
+
const result = await verifyCitationLive({
|
|
149
|
+
id: "t",
|
|
150
|
+
title: TITLE,
|
|
151
|
+
year: 2017,
|
|
152
|
+
});
|
|
153
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a title search whose answers are empty or shapeless is a miss, not a match", async () => {
|
|
157
|
+
const calls = fakeFetch({
|
|
158
|
+
[`${CR}?`]: () => json(200, {}),
|
|
159
|
+
[`${OA}?`]: () => json(200, {}),
|
|
160
|
+
[`${S2}/search`]: () => json(200, {}),
|
|
161
|
+
[AX]: () => text(200, ""),
|
|
162
|
+
});
|
|
163
|
+
const result = await verifyCitationLive({ id: "e", title: TITLE });
|
|
164
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a title search that cannot reach any registry is unresolvable", async () => {
|
|
168
|
+
const abort = Object.assign(new Error("aborted"), { name: "AbortError" });
|
|
169
|
+
const calls = fakeFetch({
|
|
170
|
+
[`${CR}?`]: () => json(500),
|
|
171
|
+
[`${OA}?`]: () => json(502),
|
|
172
|
+
[`${S2}/search`]: abort,
|
|
173
|
+
[AX]: () => text(503, ""),
|
|
174
|
+
});
|
|
175
|
+
const result = await verifyCitationLive({ id: "d", title: TITLE });
|
|
176
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("S2 and arXiv unreachable for an arXiv id", async () => {
|
|
180
|
+
const calls = fakeFetch({ [S2]: () => json(500), [AX]: () => text(500, "") });
|
|
181
|
+
const result = await verifyCitationLive({ id: "x", arxiv: "1706.03762" });
|
|
182
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("CVEs: found by count, found by list, not found, NVD down, and a malformed id", async () => {
|
|
186
|
+
const calls = fakeFetch({
|
|
187
|
+
[`${NVD}?cveId=CVE-2024-0001`]: () => json(200, { totalResults: 1 }),
|
|
188
|
+
[`${NVD}?cveId=CVE-2024-0002`]: () => json(200, { vulnerabilities: [{}] }),
|
|
189
|
+
[`${NVD}?cveId=CVE-2024-0003`]: () => json(404),
|
|
190
|
+
[`${NVD}?cveId=CVE-2024-0004`]: () => json(500),
|
|
191
|
+
[`${NVD}?cveId=CVE-2024-0005`]: () => json(200, {}),
|
|
192
|
+
});
|
|
193
|
+
const results = [];
|
|
194
|
+
for (const cve of [
|
|
195
|
+
"cve-2024-0001",
|
|
196
|
+
"CVE-2024-0002",
|
|
197
|
+
"CVE-2024-0003",
|
|
198
|
+
"CVE-2024-0004",
|
|
199
|
+
"CVE-2024-0005",
|
|
200
|
+
"CVE-24-1",
|
|
201
|
+
])
|
|
202
|
+
results.push(await verifyCitationLive({ id: cve, cve }));
|
|
203
|
+
expect({ results, calls }).toMatchSnapshot();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a second lookup is served from the cache; failures and a nonexistent DOI are not cached", async () => {
|
|
207
|
+
const cache = {};
|
|
208
|
+
fakeFetch({
|
|
209
|
+
[CR]: () => json(200, { message: { title: [TITLE] } }),
|
|
210
|
+
[OA]: () => json(503),
|
|
211
|
+
[S2]: () => json(404),
|
|
212
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
213
|
+
[NVD]: () => json(200, { totalResults: 1 }),
|
|
214
|
+
});
|
|
215
|
+
const cite = { id: "c", doi: "10.1/x", title: TITLE, cve: "CVE-2024-0001" };
|
|
216
|
+
const first = await verifyCitationLive(cite, { cache });
|
|
217
|
+
const again = fakeFetch({ [OA]: () => json(503) });
|
|
218
|
+
const second = await verifyCitationLive(cite, { cache });
|
|
219
|
+
assert.deepEqual(
|
|
220
|
+
{ same: second, keys: Object.keys(cache).sort(), again },
|
|
221
|
+
{
|
|
222
|
+
same: first,
|
|
223
|
+
// arXiv is not asked about a DOI at all, so it has nothing to cache.
|
|
224
|
+
keys: [
|
|
225
|
+
"crossref:doi:10.1/x",
|
|
226
|
+
"doi_authority:10.1/x",
|
|
227
|
+
"nvd:CVE-2024-0001",
|
|
228
|
+
"semantic_scholar:doi:10.1/x",
|
|
229
|
+
],
|
|
230
|
+
// Only the resolver that failed the first time is asked again.
|
|
231
|
+
again: ["https://api.openalex.org/works/doi:10.1%2Fx"],
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("offline: no request at all, the verdict from the citation alone", async () => {
|
|
237
|
+
const calls = fakeFetch({});
|
|
238
|
+
const result = await verifyCitationLive(
|
|
239
|
+
{ id: "o", doi: "10.1/x", title: TITLE },
|
|
240
|
+
{ offline: true },
|
|
241
|
+
);
|
|
242
|
+
expect({ result, calls }).toMatchSnapshot();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a registry that never answers is abandoned after the timeout, as unreachable", async () => {
|
|
246
|
+
vi.useFakeTimers();
|
|
247
|
+
const calls = [];
|
|
248
|
+
vi.stubGlobal("fetch", (url, { signal }) => {
|
|
249
|
+
calls.push(url);
|
|
250
|
+
return new Promise((_, reject) =>
|
|
251
|
+
signal.addEventListener("abort", () =>
|
|
252
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
const pending = verifyCitationLive({ id: "s", title: TITLE });
|
|
257
|
+
for (let i = 0; i < 4; i++) await vi.advanceTimersByTimeAsync(15000);
|
|
258
|
+
const result = await pending;
|
|
259
|
+
assert.deepEqual(
|
|
260
|
+
{ verdict: result.verdict, calls: calls.length },
|
|
261
|
+
{ verdict: "unresolvable", calls: 4 },
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a registry that answers 200 with no body, or a record without its title, still classifies", async () => {
|
|
266
|
+
const calls = fakeFetch({
|
|
267
|
+
[CR]: () => json(200, {}),
|
|
268
|
+
[OA]: () => json(200, { id: "W1", publication_year: 2017 }),
|
|
269
|
+
[S2]: () => json(200, { title: "", year: 2017 }),
|
|
270
|
+
[DOI]: () => json(200, { responseCode: 1 }),
|
|
271
|
+
});
|
|
272
|
+
const result = await verifyCitationLive({
|
|
273
|
+
id: "b",
|
|
274
|
+
doi: "10.1/x",
|
|
275
|
+
title: TITLE,
|
|
276
|
+
});
|
|
277
|
+
// OpenAlex resolves the DOI to a record with no title: an incomparable title is not a
|
|
278
|
+
// mismatch, so the DOI stands confirmed.
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
{
|
|
281
|
+
verdict: result.verdict,
|
|
282
|
+
matched_db: result.matched_db,
|
|
283
|
+
calls: calls.length,
|
|
284
|
+
},
|
|
285
|
+
{ verdict: "true", matched_db: "openalex", calls: 4 },
|
|
286
|
+
);
|
|
287
|
+
});
|
|
@@ -15,7 +15,6 @@ import { test } from "vitest";
|
|
|
15
15
|
import {
|
|
16
16
|
levenshtein,
|
|
17
17
|
titleSimilarity,
|
|
18
|
-
titlesMatch,
|
|
19
18
|
yearMatch,
|
|
20
19
|
titleRelation,
|
|
21
20
|
bestTitleRelation,
|
|
@@ -30,6 +29,8 @@ import {
|
|
|
30
29
|
normalizeDoi,
|
|
31
30
|
normalizeArxiv,
|
|
32
31
|
normalizeIdentifiers,
|
|
32
|
+
normalizeTitle,
|
|
33
|
+
isValidCveId,
|
|
33
34
|
} from "./verify-cites.mjs";
|
|
34
35
|
|
|
35
36
|
/** One vitest case per check, named by it. */
|
|
@@ -52,17 +53,17 @@ ok(
|
|
|
52
53
|
"case/normalize identical titles",
|
|
53
54
|
);
|
|
54
55
|
ok(
|
|
55
|
-
|
|
56
|
+
titleSimilarity(
|
|
56
57
|
"Deep Residual Learning for Image Recognition",
|
|
57
58
|
"Deep Residual Learning for Image Recognitionn",
|
|
58
|
-
),
|
|
59
|
+
) >= 0.7,
|
|
59
60
|
"one typo still ≥0.70",
|
|
60
61
|
);
|
|
61
62
|
ok(
|
|
62
|
-
|
|
63
|
+
titleSimilarity(
|
|
63
64
|
"Attention Is All You Need",
|
|
64
65
|
"A Survey of Reinforcement Learning",
|
|
65
|
-
),
|
|
66
|
+
) < 0.7,
|
|
66
67
|
"unrelated titles < 0.70",
|
|
67
68
|
);
|
|
68
69
|
ok(yearMatch(2020, 2021), "year ±1 ok");
|
|
@@ -733,3 +734,146 @@ console.log("\n[S4] .bib parser edge cases:");
|
|
|
733
734
|
"bibitem DOI trailing period not swallowed",
|
|
734
735
|
);
|
|
735
736
|
}
|
|
737
|
+
|
|
738
|
+
// ── Edges of the pure layer: the defensive arms, each with the input that reaches it ──────────
|
|
739
|
+
|
|
740
|
+
test("levenshtein and normalizeTitle take a missing string as empty", () => {
|
|
741
|
+
assert.deepEqual(
|
|
742
|
+
[
|
|
743
|
+
levenshtein(null, "ab"),
|
|
744
|
+
levenshtein("abc", undefined),
|
|
745
|
+
levenshtein("", ""),
|
|
746
|
+
normalizeTitle(undefined),
|
|
747
|
+
],
|
|
748
|
+
[2, 3, 0, ""],
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("normalizeDoi / normalizeArxiv pass a missing value through untouched", () => {
|
|
753
|
+
assert.deepEqual(
|
|
754
|
+
[
|
|
755
|
+
normalizeDoi(undefined),
|
|
756
|
+
normalizeDoi(""),
|
|
757
|
+
normalizeArxiv(null),
|
|
758
|
+
normalizeArxiv(""),
|
|
759
|
+
],
|
|
760
|
+
[undefined, "", null, ""],
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("titleRelation: an acronym of one letter is not an acronym; scripts are told apart", () => {
|
|
765
|
+
assert.deepEqual(
|
|
766
|
+
[
|
|
767
|
+
// "x" is one letter: it cannot spell the initials of anything.
|
|
768
|
+
titleRelation("x", "extensible markup language"),
|
|
769
|
+
titleRelation("Нейронные сети", "Neural networks"),
|
|
770
|
+
titleRelation("ニューラルネットワーク", "Neural networks"),
|
|
771
|
+
titleRelation("Νευρωνικά δίκτυα", "Neural networks"),
|
|
772
|
+
titleRelation("الشبكات العصبية", "Neural networks"),
|
|
773
|
+
titleRelation("!!!", "Neural networks"),
|
|
774
|
+
],
|
|
775
|
+
[
|
|
776
|
+
"different",
|
|
777
|
+
"incomparable",
|
|
778
|
+
"incomparable",
|
|
779
|
+
"incomparable",
|
|
780
|
+
"incomparable",
|
|
781
|
+
"incomparable",
|
|
782
|
+
],
|
|
783
|
+
);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("yearMatch reads a year out of a string and ignores one that is not a year", () => {
|
|
787
|
+
assert.deepEqual(
|
|
788
|
+
[
|
|
789
|
+
yearMatch("2017-06", 2018),
|
|
790
|
+
yearMatch("2017", 2020),
|
|
791
|
+
yearMatch("n.d.", 2020),
|
|
792
|
+
],
|
|
793
|
+
[true, false, true],
|
|
794
|
+
);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("classifyResolver: a title search with no `records` field is a miss", () => {
|
|
798
|
+
assert.deepEqual(
|
|
799
|
+
classifyResolver(
|
|
800
|
+
{ title: "A Paper" },
|
|
801
|
+
{ db: "crossref", transport: "ok", query: "title" },
|
|
802
|
+
),
|
|
803
|
+
{ db: "crossref", status: "title_miss" },
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("checkNvd with no CVE on the citation says nothing; isValidCveId of nothing is false", () => {
|
|
808
|
+
assert.deepEqual(
|
|
809
|
+
[checkNvd({}, null), isValidCveId(undefined)],
|
|
810
|
+
[null, false],
|
|
811
|
+
);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("reduceVerdict: an arXiv id missed only by a non-authoritative source is unresolvable", () => {
|
|
815
|
+
const r = reduceVerdict({ id: "a", arxiv: "1706.03762" }, [
|
|
816
|
+
{
|
|
817
|
+
db: "semantic_scholar",
|
|
818
|
+
status: "id_unmatched",
|
|
819
|
+
query: "arxiv",
|
|
820
|
+
authoritative: false,
|
|
821
|
+
},
|
|
822
|
+
]);
|
|
823
|
+
assert.deepEqual(r, {
|
|
824
|
+
id: "a",
|
|
825
|
+
verdict: "unresolvable",
|
|
826
|
+
reason:
|
|
827
|
+
"arXiv id 1706.03762 missed only by non-authoritative sources (the reachable arXiv API did not definitively reject it) — cannot assert fabrication → unresolvable",
|
|
828
|
+
matched_db: null,
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("parseBib: @comment/@string/@preamble are skipped, a bare value is read, an empty one is absent", () => {
|
|
833
|
+
const bib = [
|
|
834
|
+
"@comment{c, not an entry}",
|
|
835
|
+
"@string{s, x = {y}}",
|
|
836
|
+
'@article{k1, title = "Quoted Title", year = 2017, eprint = {1706.03762}, archiveprefix = {arXiv}}',
|
|
837
|
+
"@misc{k2, title = {T}, year = ,}",
|
|
838
|
+
].join("\n");
|
|
839
|
+
assert.deepEqual(parseBib(bib), [
|
|
840
|
+
{ id: "k1", title: "Quoted Title", year: "2017", arxiv: "1706.03762" },
|
|
841
|
+
{ id: "k2", title: "T" },
|
|
842
|
+
]);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test("parseBib's \\bibitem fallback reads an arXiv id and an \\emph title", () => {
|
|
846
|
+
const text = [
|
|
847
|
+
"\\begin{thebibliography}{9}",
|
|
848
|
+
"\\bibitem{a} A. Author. \\emph{A Long Enough Title}. arXiv:1706.03762, 2017.",
|
|
849
|
+
"\\end{thebibliography}",
|
|
850
|
+
].join("\n");
|
|
851
|
+
assert.deepEqual(parseBib(text), [
|
|
852
|
+
{
|
|
853
|
+
id: "a",
|
|
854
|
+
arxiv: "1706.03762",
|
|
855
|
+
year: "2017",
|
|
856
|
+
title: "A Long Enough Title",
|
|
857
|
+
},
|
|
858
|
+
]);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("parseArxivFeed: an entry without a title is skipped, one without a date has no year", () => {
|
|
862
|
+
assert.deepEqual(
|
|
863
|
+
parseArxivFeed("<entry><id>x</id></entry><entry><title>T</title></entry>"),
|
|
864
|
+
[{ title: "T", year: null }],
|
|
865
|
+
);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("a two-letter token ending in s is depluralised to one letter, which spells no acronym", () => {
|
|
869
|
+
assert.equal(
|
|
870
|
+
titleRelation("TS models", "totally separate models"),
|
|
871
|
+
"different",
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("parseArxivFeed keeps an entry titled Error when it has no <id> to prove it is the sentinel", () => {
|
|
876
|
+
assert.deepEqual(parseArxivFeed("<entry><title>Error</title></entry>"), [
|
|
877
|
+
{ title: "Error", year: null },
|
|
878
|
+
]);
|
|
879
|
+
});
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { test } from "vitest";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type BanalFailure,
|
|
9
|
+
describe,
|
|
10
|
+
describeLine,
|
|
11
|
+
PERL_MISSING,
|
|
12
|
+
} from "./failure.ts";
|
|
8
13
|
import { acceptProbe } from "./probe.ts";
|
|
9
14
|
|
|
10
15
|
test("describe: perl missing carries the fix", () => {
|
|
@@ -61,3 +66,67 @@ test("acceptProbe: one page and a body size is accepted; anything else is `probe
|
|
|
61
66
|
assert.equal(!r.ok && r.error.kind, "probe-rejected");
|
|
62
67
|
assert.equal(acceptProbe({ pages: [], bodyfontsize: 10 }).ok, false);
|
|
63
68
|
});
|
|
69
|
+
|
|
70
|
+
test("describe: every other kind is one exact sentence", () => {
|
|
71
|
+
const cases: readonly [BanalFailure, string][] = [
|
|
72
|
+
[
|
|
73
|
+
{ kind: "process-failed", status: 2, stderrHead: "boom" },
|
|
74
|
+
"banal failed (exit 2): boom",
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
{ kind: "signalled", signal: "SIGKILL", stderrHead: "" },
|
|
78
|
+
"banal failed (SIGKILL): no output",
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
{ kind: "signalled", signal: "SIGTERM", stderrHead: "late" },
|
|
82
|
+
"banal failed (SIGTERM): late",
|
|
83
|
+
],
|
|
84
|
+
[{ kind: "spawn-failed", message: "EACCES" }, "banal failed: EACCES"],
|
|
85
|
+
[
|
|
86
|
+
{ kind: "timed-out", afterMs: 500 },
|
|
87
|
+
"banal failed: no answer after 500 ms",
|
|
88
|
+
],
|
|
89
|
+
[{ kind: "no-json", head: "" }, "banal printed no JSON: nothing"],
|
|
90
|
+
[{ kind: "no-json", head: "Usage:" }, "banal printed no JSON: Usage:"],
|
|
91
|
+
[
|
|
92
|
+
{ kind: "banal-error", stderrHead: "bad xml" },
|
|
93
|
+
"banal could not read the banal input XML: bad xml",
|
|
94
|
+
],
|
|
95
|
+
[
|
|
96
|
+
{
|
|
97
|
+
kind: "unexpected-shape",
|
|
98
|
+
issues: ["pages missing", "bodyfontsize not a number"],
|
|
99
|
+
},
|
|
100
|
+
"banal printed JSON that is not a measurement: pages missing; bodyfontsize not a number",
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
{ kind: "probe-rejected", got: { pages: [] } },
|
|
104
|
+
'banal ran on a one-page probe but measured nothing: {"pages":[]}',
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
{ kind: "download-failed", url: "https://x/banal", detail: "HTTP 404" },
|
|
108
|
+
"could not download banal from https://x/banal: HTTP 404",
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
{ kind: "not-pinned", path: "/c/banal" },
|
|
112
|
+
"banal in /c/banal is not the pinned one (sha256 differs)",
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
{
|
|
116
|
+
kind: "does-not-run",
|
|
117
|
+
path: "/c/banal",
|
|
118
|
+
why: { kind: "timed-out", afterMs: 9 },
|
|
119
|
+
},
|
|
120
|
+
"banal in /c/banal does not run: banal failed: no answer after 9 ms",
|
|
121
|
+
],
|
|
122
|
+
];
|
|
123
|
+
for (const [failure, sentence] of cases)
|
|
124
|
+
assert.deepEqual(describe(failure), [sentence]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("describeLine: a sha256 mismatch joins its three lines", () => {
|
|
128
|
+
assert.equal(
|
|
129
|
+
describeLine({ kind: "sha-mismatch", url: "u", expected: "e", got: "g" }),
|
|
130
|
+
"banal from u does not have the pinned sha256 — refusing to install it; expected e; got g",
|
|
131
|
+
);
|
|
132
|
+
});
|
|
@@ -103,6 +103,22 @@ test("installer: ensure reports the download, and Ready says where and what was
|
|
|
103
103
|
assert.equal(i.check().ok, true);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("installer with no source: the pin is downloaded, and bytes that are not the pin are refused", () => {
|
|
107
|
+
const download = fixedDownload(BODY);
|
|
108
|
+
const io = memoryPorts({
|
|
109
|
+
run: scriptedProcess(() => exitedWith("")),
|
|
110
|
+
download,
|
|
111
|
+
});
|
|
112
|
+
const r = banalInstaller(io, parseBanalSettings({}, dirs)).ensure(() => {});
|
|
113
|
+
assert.ok(!r.ok);
|
|
114
|
+
assert.match(
|
|
115
|
+
r.error[0],
|
|
116
|
+
/does not have the pinned sha256 — refusing to install it/,
|
|
117
|
+
);
|
|
118
|
+
assert.equal(download.urls.length, 1);
|
|
119
|
+
assert.notEqual(download.urls[0], source.url);
|
|
120
|
+
});
|
|
121
|
+
|
|
106
122
|
test("installer: a failure is lines of text, the first saying what happened", () => {
|
|
107
123
|
const io = memoryPorts({
|
|
108
124
|
run: scriptedProcess(() => ({ kind: "not-found", file: "perl" })),
|
|
@@ -60,6 +60,11 @@ const ROWS: readonly (readonly [
|
|
|
60
60
|
exited({ stdout: "Usage: banal" }),
|
|
61
61
|
{ kind: "no-json", head: "Usage: banal" },
|
|
62
62
|
],
|
|
63
|
+
[
|
|
64
|
+
"nothing on stdout: the head is stderr's first line",
|
|
65
|
+
exited({ stdout: "", stderr: "\nCan't locate XML/Parser.pm\n" }),
|
|
66
|
+
{ kind: "no-json", head: "Can't locate XML/Parser.pm" },
|
|
67
|
+
],
|
|
63
68
|
[
|
|
64
69
|
// Guards: banal's own failure object exits 0 — it must not read as zero pages.
|
|
65
70
|
'banal\'s `"error": true` with exit 0',
|
|
@@ -16,7 +16,8 @@ import { join } from "node:path";
|
|
|
16
16
|
import { pathToFileURL } from "node:url";
|
|
17
17
|
import { afterAll as after, test } from "vitest";
|
|
18
18
|
import type { AbsolutePath } from "../../domain/paths.ts";
|
|
19
|
-
import { curlDownload } from "./download.io.ts";
|
|
19
|
+
import { curlDownload, curlFailure } from "./download.io.ts";
|
|
20
|
+
import type { ProcessExit } from "../../ports/process.ts";
|
|
20
21
|
import { spawnProcess } from "../node/index.ts";
|
|
21
22
|
|
|
22
23
|
const root = realpathSync(
|
|
@@ -55,3 +56,44 @@ test("🔴 Download: no curl on PATH is named as that, not as a failed URL", ()
|
|
|
55
56
|
// Guards: a missing curl is its own diagnosis — once, it read "spawnSync curl ENOENT".
|
|
56
57
|
assert.deepEqual(!r.ok && r.error, { detail: "curl is not installed" });
|
|
57
58
|
});
|
|
59
|
+
|
|
60
|
+
test("curlFailure: every way curl can end, in its own words", () => {
|
|
61
|
+
const cases: readonly [ProcessExit, string | null][] = [
|
|
62
|
+
[{ kind: "exited", status: 0, stdout: "", stderr: "" }, null],
|
|
63
|
+
[{ kind: "spawn-failed", message: "EACCES" }, "EACCES"],
|
|
64
|
+
[
|
|
65
|
+
{ kind: "timed-out", afterMs: 40_000, stdout: "", stderr: "" },
|
|
66
|
+
"no answer after 40000 ms",
|
|
67
|
+
],
|
|
68
|
+
[{ kind: "exited", status: 22, stdout: "", stderr: "" }, "curl exited 22"],
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
kind: "exited",
|
|
72
|
+
status: 22,
|
|
73
|
+
stdout: "",
|
|
74
|
+
stderr: "curl: (22) 404\nmore",
|
|
75
|
+
},
|
|
76
|
+
"curl: (22) 404",
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
{ kind: "signalled", signal: "SIGTERM", stdout: "", stderr: "" },
|
|
80
|
+
"SIGTERM",
|
|
81
|
+
],
|
|
82
|
+
];
|
|
83
|
+
for (const [exit, words] of cases) assert.equal(curlFailure(exit), words);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("Download: curl exits 0 and writes nothing — a failure that says so, not empty bytes", () => {
|
|
87
|
+
const tmp = at("dl-silent");
|
|
88
|
+
mkdirSync(tmp, { recursive: true });
|
|
89
|
+
const d = curlDownload({
|
|
90
|
+
run: { run: () => ({ kind: "exited", status: 0, stdout: "", stderr: "" }) },
|
|
91
|
+
env: {},
|
|
92
|
+
tmpDir: tmp,
|
|
93
|
+
});
|
|
94
|
+
assert.deepEqual(d.fetch("https://example.test/banal", 1_000), {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: { detail: "curl wrote no file" },
|
|
97
|
+
});
|
|
98
|
+
assert.deepEqual(readdirSync(tmp), []);
|
|
99
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The in-memory test doubles keep the contracts of the ports they stand in for. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { test } from "vitest";
|
|
4
|
+
import { fixedDownload, memoryWorkspace } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
test("memoryWorkspace: a `within` that throws is recorded as `threw`, and the error goes on", () => {
|
|
7
|
+
const ws = memoryWorkspace();
|
|
8
|
+
assert.throws(
|
|
9
|
+
() =>
|
|
10
|
+
ws.within("x-", (s) => {
|
|
11
|
+
s.write("f", "body", "read");
|
|
12
|
+
throw new Error("inside");
|
|
13
|
+
}),
|
|
14
|
+
/inside/,
|
|
15
|
+
);
|
|
16
|
+
assert.deepEqual(ws.ended, ["threw"]);
|
|
17
|
+
assert.deepEqual(ws.written, [{ name: "f", content: "body", mode: "read" }]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("fixedDownload: bytes are handed back as they are, and every URL is recorded", () => {
|
|
21
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
22
|
+
const d = fixedDownload(bytes);
|
|
23
|
+
assert.deepEqual(d.fetch("https://a", 1), { ok: true, value: bytes });
|
|
24
|
+
assert.deepEqual(d.urls, ["https://a"]);
|
|
25
|
+
});
|
|
@@ -11,7 +11,8 @@ import { dirname } from "node:path";
|
|
|
11
11
|
import type { AbsolutePath } from "../../domain/paths.ts";
|
|
12
12
|
import type { Files } from "../../ports/files.ts";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** An error's errno code; anything thrown that is not an Error has none. */
|
|
15
|
+
export const errorCode = (e: unknown): unknown =>
|
|
15
16
|
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
|
|
16
17
|
|
|
17
18
|
export const nodeFiles: Files = {
|
|
@@ -22,7 +23,7 @@ export const nodeFiles: Files = {
|
|
|
22
23
|
try {
|
|
23
24
|
return readFileSync(p);
|
|
24
25
|
} catch (e) {
|
|
25
|
-
if (
|
|
26
|
+
if (errorCode(e) === "ENOENT") return null;
|
|
26
27
|
throw e;
|
|
27
28
|
}
|
|
28
29
|
},
|