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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
freshPart,
|
|
4
|
+
EMPTY_LOOKUP_CACHE,
|
|
5
|
+
parseLookupCache,
|
|
6
|
+
serializeLookupCache,
|
|
7
|
+
type LookupCache,
|
|
8
|
+
} from "./lookup-cache.ts";
|
|
9
|
+
import { err, ok } from "./result.ts";
|
|
10
|
+
|
|
11
|
+
const HIT = {
|
|
12
|
+
venue: "ICSE",
|
|
13
|
+
year: "2024",
|
|
14
|
+
type: "Conference and Workshop Papers",
|
|
15
|
+
title: "Good Paper.",
|
|
16
|
+
authors: ["Ada Lovelace"],
|
|
17
|
+
};
|
|
18
|
+
const CACHE: LookupCache = {
|
|
19
|
+
citations: new Map([
|
|
20
|
+
[
|
|
21
|
+
"crossref:doi:10.1/good",
|
|
22
|
+
{
|
|
23
|
+
fetched: "2026-09-27",
|
|
24
|
+
response: { db: "crossref", transport: "ok", query: "doi" },
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
]),
|
|
28
|
+
dblp: new Map([
|
|
29
|
+
[
|
|
30
|
+
"0123456789abcdef",
|
|
31
|
+
{ fetched: "2026-09-26", title: "Good Paper", hits: [HIT] },
|
|
32
|
+
],
|
|
33
|
+
]),
|
|
34
|
+
};
|
|
35
|
+
const doc = (o: object): string => JSON.stringify({ schema: 1, ...o });
|
|
36
|
+
|
|
37
|
+
describe("serializeLookupCache / parseLookupCache", () => {
|
|
38
|
+
it("round-trips, keys sorted, one trailing newline", () => {
|
|
39
|
+
const text = serializeLookupCache(CACHE);
|
|
40
|
+
expect(text).toBe(
|
|
41
|
+
`${JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
schema: 1,
|
|
44
|
+
citations: {
|
|
45
|
+
"crossref:doi:10.1/good": {
|
|
46
|
+
fetched: "2026-09-27",
|
|
47
|
+
response: { db: "crossref", transport: "ok", query: "doi" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
dblp: {
|
|
51
|
+
["0123456789abcdef"]: {
|
|
52
|
+
fetched: "2026-09-26",
|
|
53
|
+
title: "Good Paper",
|
|
54
|
+
hits: [HIT],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
null,
|
|
59
|
+
2,
|
|
60
|
+
)}\n`,
|
|
61
|
+
);
|
|
62
|
+
expect(parseLookupCache(text)).toEqual(ok(CACHE));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("the empty cache writes both sections, and reads back as empty", () => {
|
|
66
|
+
expect(parseLookupCache(serializeLookupCache(EMPTY_LOOKUP_CACHE))).toEqual(
|
|
67
|
+
ok(EMPTY_LOOKUP_CACHE),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Guards: a committed file that is wrong in any way is refused with a NAMED reason. Read as
|
|
73
|
+
// empty, it would silently turn every build into a full online run — the defect this file fixes.
|
|
74
|
+
const REFUSALS: [string, string][] = [
|
|
75
|
+
["[]", "expected an object with `schema`, `citations` and `dblp`"],
|
|
76
|
+
[doc({ schema: 2 }), "schema 2: this paperlint reads schema 1"],
|
|
77
|
+
[doc({ citations: {}, dblp: {}, x: 1 }), "unknown field `x`"],
|
|
78
|
+
[doc({ dblp: {} }), "`citations`: expected an object"],
|
|
79
|
+
[doc({ citations: {} }), "`dblp`: expected an object"],
|
|
80
|
+
[doc({ citations: { k: 1 }, dblp: {} }), "`citations.k`: expected an object"],
|
|
81
|
+
[
|
|
82
|
+
doc({
|
|
83
|
+
citations: { k: { fetched: "2026-9-1", response: {} } },
|
|
84
|
+
dblp: {},
|
|
85
|
+
}),
|
|
86
|
+
"`citations.k.fetched`: expected a date, YYYY-MM-DD",
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
doc({
|
|
90
|
+
citations: {
|
|
91
|
+
k: { fetched: "2026-09-01", response: { transport: "error" } },
|
|
92
|
+
},
|
|
93
|
+
dblp: {},
|
|
94
|
+
}),
|
|
95
|
+
'`citations.k.response`: expected a successful response ({"transport": "ok", …})',
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
doc({
|
|
99
|
+
citations: {
|
|
100
|
+
k: { fetched: "2026-09-01", response: { transport: "ok" }, y: 1 },
|
|
101
|
+
},
|
|
102
|
+
dblp: {},
|
|
103
|
+
}),
|
|
104
|
+
"`citations.k`: unknown field `y`",
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
doc({
|
|
108
|
+
citations: {},
|
|
109
|
+
dblp: { t: { fetched: "2026-09-01", title: 3, hits: [] } },
|
|
110
|
+
}),
|
|
111
|
+
"`dblp.t.title`: expected a string",
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
doc({
|
|
115
|
+
citations: {},
|
|
116
|
+
dblp: { t: { fetched: "2026-09-01", title: "T", hits: {} } },
|
|
117
|
+
}),
|
|
118
|
+
"`dblp.t.hits`: expected an array",
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
doc({
|
|
122
|
+
citations: {},
|
|
123
|
+
dblp: {
|
|
124
|
+
t: {
|
|
125
|
+
fetched: "2026-09-01",
|
|
126
|
+
title: "T",
|
|
127
|
+
hits: [{ ...HIT, authors: [1] }],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
"`dblp.t.hits[0]`: expected {venue, year, type, title: string; authors: string[]}",
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
doc({
|
|
135
|
+
citations: {},
|
|
136
|
+
dblp: { t: { fetched: "2026-09-01", title: "T", hits: [3] } },
|
|
137
|
+
}),
|
|
138
|
+
"`dblp.t.hits[0]`: expected {venue, year, type, title: string; authors: string[]}",
|
|
139
|
+
],
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
describe("parseLookupCache refusals", () => {
|
|
143
|
+
it("refuses text that is not JSON, saying so", () => {
|
|
144
|
+
const r = parseLookupCache("{");
|
|
145
|
+
// The rest of the message is Node's parser; its wording is not the subject.
|
|
146
|
+
expect(r.ok ? "" : r.error).toMatch(/^not JSON: /);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Guards: see REFUSALS.
|
|
150
|
+
it.each(REFUSALS)("refuses %s with a named reason", (text, why) => {
|
|
151
|
+
expect(parseLookupCache(text)).toEqual(err(why));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("freshPart", () => {
|
|
156
|
+
const cache = (fetched: string): LookupCache => ({
|
|
157
|
+
citations: new Map([
|
|
158
|
+
[
|
|
159
|
+
"k",
|
|
160
|
+
{
|
|
161
|
+
fetched,
|
|
162
|
+
response: { db: "crossref", transport: "ok", query: "doi" },
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
]),
|
|
166
|
+
dblp: new Map(),
|
|
167
|
+
});
|
|
168
|
+
// Guards: the age window has two ends. A date in the future (a clock ahead, a typo) has a
|
|
169
|
+
// negative age; read as fresh, a miss would be trusted until that date plus 30 days.
|
|
170
|
+
it.each([
|
|
171
|
+
["2026-09-27", 1],
|
|
172
|
+
["2026-08-28", 1],
|
|
173
|
+
["2026-08-27", 0],
|
|
174
|
+
["2026-09-28", 0],
|
|
175
|
+
])("on 2026-09-27, an answer fetched %s is kept %i time(s)", (fetched, n) => {
|
|
176
|
+
expect(freshPart(cache(fetched), "2026-09-27").citations.size).toBe(n);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LOOKUP CACHE — `<paper>/repro/references-cache.json`: what the citation services ANSWERED,
|
|
3
|
+
* kept so that a build asks each question at most once every MAX_AGE_DAYS, not once per build (#107).
|
|
4
|
+
*
|
|
5
|
+
* { "schema": 1,
|
|
6
|
+
* "citations": { "<resolver>:<identifier>": { "fetched": "YYYY-MM-DD", "response": {…} } },
|
|
7
|
+
* "dblp": { "<title key>": { "fetched": "YYYY-MM-DD", "title": "…", "hits": […] } } }
|
|
8
|
+
*
|
|
9
|
+
* `citations` holds verify-cites' per-resolver responses (Crossref, OpenAlex, Semantic Scholar,
|
|
10
|
+
* arXiv, doi.org, NVD), under verify-cites' own cache keys. `dblp` holds the hits DBLP returned for
|
|
11
|
+
* a title, under the references adapter's `dblpTitleKey`. Only successful answers are ever stored: a failed request is not an
|
|
12
|
+
* answer, so the next build asks again.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 RESPONSES, NEVER VERDICTS. The verdicts (`_build/references.json`) are DERIVED from these
|
|
15
|
+
* responses on every build, by the current version of the checkers. A cached verdict would freeze
|
|
16
|
+
* whatever the checker believed the day it ran — a bug fixed in the comparison would never reach a
|
|
17
|
+
* paper whose entries are cached. A cached response is evidence: it cannot be made up, only
|
|
18
|
+
* refetched. The precedent is `extract-ref-facts.mjs`'s `repro/refs-cache.json` ("the cache is
|
|
19
|
+
* evidence, not a second declaration").
|
|
20
|
+
*
|
|
21
|
+
* 🔴 COMMITTED, so CI and every other clone start warm: a build whose entries are all here makes no
|
|
22
|
+
* network request at all, not even the reachability probe. It lives in `repro/`, beside the paper,
|
|
23
|
+
* not in `_build/`, which is build output and gitignored.
|
|
24
|
+
*
|
|
25
|
+
* WHAT INVALIDATES AN ENTRY: its key, or its age. Editing a bib entry's DOI, arXiv id or title
|
|
26
|
+
* changes the keys it is looked up under, so only that entry is asked again. An answer older than
|
|
27
|
+
* MAX_AGE_DAYS (by `fetched`) is asked again too, so a work a service had not indexed yet is found
|
|
28
|
+
* once it is. To refresh an answer sooner, delete its entry, or the file.
|
|
29
|
+
*
|
|
30
|
+
* Parsed here, once, at the boundary: a file that does not match is refused with a named reason,
|
|
31
|
+
* never read as empty.
|
|
32
|
+
*/
|
|
33
|
+
import { err, ok, type Result } from "./result.ts";
|
|
34
|
+
|
|
35
|
+
export const LOOKUP_CACHE_SCHEMA = 1;
|
|
36
|
+
|
|
37
|
+
type Json = Readonly<Record<string, unknown>>;
|
|
38
|
+
|
|
39
|
+
/** One resolver's successful answer, as verify-cites shapes it (`{ transport: "ok", … }`). */
|
|
40
|
+
export interface CachedResponse {
|
|
41
|
+
/** When it was fetched, YYYY-MM-DD. */
|
|
42
|
+
readonly fetched: string;
|
|
43
|
+
readonly response: Json;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One DBLP hit, as bib-authors' `dblpHits` shapes it. */
|
|
47
|
+
export interface DblpHit {
|
|
48
|
+
readonly venue: string;
|
|
49
|
+
readonly year: string;
|
|
50
|
+
readonly type: string;
|
|
51
|
+
readonly title: string;
|
|
52
|
+
readonly authors: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CachedDblp {
|
|
56
|
+
readonly fetched: string;
|
|
57
|
+
/** The title as it was asked — for a human reading the file; the key is the adapter's `dblpTitleKey(title)`. */
|
|
58
|
+
readonly title: string;
|
|
59
|
+
readonly hits: readonly DblpHit[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LookupCache {
|
|
63
|
+
readonly citations: ReadonlyMap<string, CachedResponse>;
|
|
64
|
+
readonly dblp: ReadonlyMap<string, CachedDblp>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const EMPTY_LOOKUP_CACHE: LookupCache = {
|
|
68
|
+
citations: new Map(),
|
|
69
|
+
dblp: new Map(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How long a cached answer is used before it is asked again. A registry, doi.org or DBLP that had
|
|
74
|
+
* not indexed a work yet answers "no record"; kept forever, that answer would outlive the work's
|
|
75
|
+
* indexing. The age is judged against `fetched`, so every answer — a hit or a miss — gets the
|
|
76
|
+
* same rule, and nothing has to decide which answers are "negative".
|
|
77
|
+
*/
|
|
78
|
+
export const MAX_AGE_DAYS = 30;
|
|
79
|
+
|
|
80
|
+
const dayNumber = (date: string): number =>
|
|
81
|
+
Date.parse(`${date}T00:00:00Z`) / 86_400_000;
|
|
82
|
+
|
|
83
|
+
/** The part of `cache` still young enough to answer from on `today` (YYYY-MM-DD). */
|
|
84
|
+
export function freshPart(cache: LookupCache, today: string): LookupCache {
|
|
85
|
+
// An age below zero is a date in the future (a clock ahead, a typo): not trusted, asked again.
|
|
86
|
+
const young = ({ fetched }: { fetched: string }): boolean => {
|
|
87
|
+
const age = dayNumber(today) - dayNumber(fetched);
|
|
88
|
+
return age >= 0 && age <= MAX_AGE_DAYS;
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
citations: new Map([...cache.citations].filter(([, v]) => young(v))),
|
|
92
|
+
dblp: new Map([...cache.dblp].filter(([, v]) => young(v))),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isObject = (v: unknown): v is Json =>
|
|
97
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
98
|
+
const unknownField = (o: Json, known: readonly string[]): string | undefined =>
|
|
99
|
+
Object.keys(o).find((k) => !known.includes(k));
|
|
100
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
101
|
+
const isDate = (v: unknown): v is string =>
|
|
102
|
+
typeof v === "string" && DATE.test(v);
|
|
103
|
+
|
|
104
|
+
/** Parse every value of `section` with `one`, or the first named reason. */
|
|
105
|
+
function sectionOf<T>(
|
|
106
|
+
name: string,
|
|
107
|
+
section: unknown,
|
|
108
|
+
known: readonly string[],
|
|
109
|
+
one: (o: Json, at: string) => Result<T, string>,
|
|
110
|
+
): Result<Map<string, T>, string> {
|
|
111
|
+
if (!isObject(section)) return err(`\`${name}\`: expected an object`);
|
|
112
|
+
const out = new Map<string, T>();
|
|
113
|
+
for (const [key, v] of Object.entries(section)) {
|
|
114
|
+
const at = `\`${name}.${key}`;
|
|
115
|
+
if (!isObject(v)) return err(`${at}\`: expected an object`);
|
|
116
|
+
const extra = unknownField(v, known);
|
|
117
|
+
if (extra !== undefined) return err(`${at}\`: unknown field \`${extra}\``);
|
|
118
|
+
if (!isDate(v["fetched"]))
|
|
119
|
+
return err(`${at}.fetched\`: expected a date, YYYY-MM-DD`);
|
|
120
|
+
const r = one(v, at);
|
|
121
|
+
if (!r.ok) return r;
|
|
122
|
+
out.set(key, r.value);
|
|
123
|
+
}
|
|
124
|
+
return ok(out);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cachedResponse = (o: Json, at: string): Result<CachedResponse, string> =>
|
|
128
|
+
isObject(o["response"]) && o["response"]["transport"] === "ok"
|
|
129
|
+
? ok({ fetched: o["fetched"] as string, response: o["response"] })
|
|
130
|
+
: err(
|
|
131
|
+
`${at}.response\`: expected a successful response ({"transport": "ok", …})`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const isHit = (h: unknown): h is DblpHit =>
|
|
135
|
+
isObject(h) &&
|
|
136
|
+
["venue", "year", "type", "title"].every((k) => typeof h[k] === "string") &&
|
|
137
|
+
Array.isArray(h["authors"]) &&
|
|
138
|
+
h["authors"].every((a) => typeof a === "string");
|
|
139
|
+
|
|
140
|
+
function cachedDblp(o: Json, at: string): Result<CachedDblp, string> {
|
|
141
|
+
const { title, hits } = o;
|
|
142
|
+
if (typeof title !== "string") return err(`${at}.title\`: expected a string`);
|
|
143
|
+
if (!Array.isArray(hits)) return err(`${at}.hits\`: expected an array`);
|
|
144
|
+
const bad = hits.findIndex((h) => !isHit(h));
|
|
145
|
+
if (bad !== -1)
|
|
146
|
+
return err(
|
|
147
|
+
`${at}.hits[${String(bad)}]\`: expected {venue, year, type, title: string; authors: string[]}`,
|
|
148
|
+
);
|
|
149
|
+
return ok({
|
|
150
|
+
fetched: o["fetched"] as string,
|
|
151
|
+
title,
|
|
152
|
+
hits: hits as DblpHit[],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The cache in `text`, or why it cannot be read. */
|
|
157
|
+
export function parseLookupCache(text: string): Result<LookupCache, string> {
|
|
158
|
+
let json: unknown;
|
|
159
|
+
try {
|
|
160
|
+
json = JSON.parse(text);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return err(`not JSON: ${(e as Error).message}`);
|
|
163
|
+
}
|
|
164
|
+
if (!isObject(json))
|
|
165
|
+
return err("expected an object with `schema`, `citations` and `dblp`");
|
|
166
|
+
if (json["schema"] !== LOOKUP_CACHE_SCHEMA)
|
|
167
|
+
return err(
|
|
168
|
+
`schema ${String(json["schema"])}: this paperlint reads schema ${String(LOOKUP_CACHE_SCHEMA)}`,
|
|
169
|
+
);
|
|
170
|
+
const extra = unknownField(json, ["schema", "citations", "dblp"]);
|
|
171
|
+
if (extra !== undefined) return err(`unknown field \`${extra}\``);
|
|
172
|
+
const citations = sectionOf(
|
|
173
|
+
"citations",
|
|
174
|
+
json["citations"],
|
|
175
|
+
["fetched", "response"],
|
|
176
|
+
cachedResponse,
|
|
177
|
+
);
|
|
178
|
+
if (!citations.ok) return citations;
|
|
179
|
+
const dblp = sectionOf(
|
|
180
|
+
"dblp",
|
|
181
|
+
json["dblp"],
|
|
182
|
+
["fetched", "title", "hits"],
|
|
183
|
+
cachedDblp,
|
|
184
|
+
);
|
|
185
|
+
if (!dblp.ok) return dblp;
|
|
186
|
+
return ok({ citations: citations.value, dblp: dblp.value });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sorted = <T>(m: ReadonlyMap<string, T>): Record<string, T> =>
|
|
190
|
+
Object.fromEntries([...m.keys()].sort().map((k) => [k, m.get(k) as T]));
|
|
191
|
+
|
|
192
|
+
/** The cache as the file holds it: keys sorted, so one new answer is a small diff. */
|
|
193
|
+
export const serializeLookupCache = (c: LookupCache): string =>
|
|
194
|
+
`${JSON.stringify(
|
|
195
|
+
{
|
|
196
|
+
schema: LOOKUP_CACHE_SCHEMA,
|
|
197
|
+
citations: sorted(c.citations),
|
|
198
|
+
dblp: sorted(c.dblp),
|
|
199
|
+
},
|
|
200
|
+
null,
|
|
201
|
+
2,
|
|
202
|
+
)}\n`;
|
|
@@ -10,6 +10,10 @@ test("map and andThen run on ok and pass an error through untouched", () => {
|
|
|
10
10
|
map(ok(2), (n) => n + 1),
|
|
11
11
|
ok(3),
|
|
12
12
|
);
|
|
13
|
+
assert.deepEqual(
|
|
14
|
+
map(half(3), () => assert.fail("mapped after an error")),
|
|
15
|
+
err("3 is odd"),
|
|
16
|
+
);
|
|
13
17
|
assert.deepEqual(andThen(ok(8), half), ok(4));
|
|
14
18
|
// Guards: an error short-circuits — the function after it is not called.
|
|
15
19
|
assert.deepEqual(
|
package/src/domain/result.ts
CHANGED
|
@@ -22,8 +22,3 @@ export const match = <T, E, R>(
|
|
|
22
22
|
r: Result<T, E>,
|
|
23
23
|
on: { readonly ok: (t: T) => R; readonly err: (e: E) => R },
|
|
24
24
|
): R => (r.ok ? on.ok(r.value) : on.err(r.error));
|
|
25
|
-
|
|
26
|
-
/** The `default` of an exhaustive `switch`: a new variant is a compile error, not a silent fall-through. */
|
|
27
|
-
export function assertNever(x: never): never {
|
|
28
|
-
throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
|
|
29
|
-
}
|
package/src/engine.harness.mjs
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
* `probeTree` through a fake runner.
|
|
8
8
|
* All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/build.mjs`.
|
|
9
9
|
*/
|
|
10
|
-
import assert from "node:assert/strict";
|
|
11
10
|
import {
|
|
12
11
|
chmodSync,
|
|
13
12
|
mkdirSync,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
import { tmpdir } from "node:os";
|
|
20
19
|
import { dirname, join } from "node:path";
|
|
21
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { createChecker } from "../lib/check.mjs";
|
|
22
22
|
|
|
23
23
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const {
|
|
@@ -32,11 +32,7 @@ const {
|
|
|
32
32
|
supportedPlatform,
|
|
33
33
|
} = await import(join(HERE, "engine.ts"));
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const check = (label, cond, detail = "") => {
|
|
37
|
-
assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
|
|
38
|
-
n++;
|
|
39
|
-
};
|
|
35
|
+
const check = createChecker();
|
|
40
36
|
|
|
41
37
|
// ── 1. resolveEngine ────────────────────────────────────────────────────────────────────
|
|
42
38
|
const tree = (label, missing = []) => ({ label, bin: `/${label}`, missing });
|
|
@@ -301,4 +297,28 @@ check(
|
|
|
301
297
|
) === "[]",
|
|
302
298
|
);
|
|
303
299
|
|
|
304
|
-
|
|
300
|
+
check(
|
|
301
|
+
"missingDependencies: a tlmgr that cannot start reports nothing (the tree is judged by probeTree alone)",
|
|
302
|
+
JSON.stringify(
|
|
303
|
+
missingDependencies("/tl/bin", () => ({ error: new Error("ENOENT") })),
|
|
304
|
+
) === "[]",
|
|
305
|
+
);
|
|
306
|
+
check(
|
|
307
|
+
"missingDependencies: a section that runs to the end of the output, with no header after it",
|
|
308
|
+
JSON.stringify(
|
|
309
|
+
missingDependencies("/tl/bin", () => ({
|
|
310
|
+
stdout:
|
|
311
|
+
"\f DEPENDS WITHOUT PACKAGES:\nunicode-data in: latex-bin\nhyphen-base in: x",
|
|
312
|
+
status: 2,
|
|
313
|
+
})),
|
|
314
|
+
) === '["hyphen-base","unicode-data"]',
|
|
315
|
+
);
|
|
316
|
+
check(
|
|
317
|
+
"a runner that returns no stdout at all: tlmgr lists nothing, kpsewhich finds nothing",
|
|
318
|
+
JSON.stringify(missingDependencies("/tl/bin", () => ({ status: 0 }))) ===
|
|
319
|
+
"[]" &&
|
|
320
|
+
JSON.stringify(probeTree("/tl/bin", PKGS, () => ({ status: 0 }))) ===
|
|
321
|
+
JSON.stringify(Object.keys(PKGS).sort()),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
console.log(`engine: ${check.count} checks passed`);
|
package/src/facts-file.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
factsDocument,
|
|
19
19
|
factsPath,
|
|
20
20
|
measurePaper,
|
|
21
|
+
parseFactsText,
|
|
21
22
|
writeFactsFile,
|
|
22
23
|
type MeasureOptions,
|
|
23
24
|
} from "./facts-file.ts";
|
|
@@ -278,3 +279,112 @@ test("declaredVenue: a paperlint.json with an unknown key is refused, naming the
|
|
|
278
279
|
});
|
|
279
280
|
assert.throws(() => declaredVenue(files, PAPER), /unknown key "venu"/);
|
|
280
281
|
});
|
|
282
|
+
|
|
283
|
+
test("a PDF with no text has an empty font list, not a failed document", () => {
|
|
284
|
+
const d = factsDocument({
|
|
285
|
+
pdf: "paper.pdf",
|
|
286
|
+
sha: "a".repeat(64),
|
|
287
|
+
venue: null,
|
|
288
|
+
kind: null,
|
|
289
|
+
read: {
|
|
290
|
+
pages: 1,
|
|
291
|
+
fonts: { kind: "no-text" },
|
|
292
|
+
last: lastPage(0, 0),
|
|
293
|
+
layout: [],
|
|
294
|
+
},
|
|
295
|
+
geometry: NONE,
|
|
296
|
+
});
|
|
297
|
+
assert.deepEqual(d.fonts, []);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("measure: a PDF gone between pdf.js's read and the hash is an error, not a hash of nothing", async () => {
|
|
301
|
+
const { o, files } = setup();
|
|
302
|
+
files.map.delete(PDF);
|
|
303
|
+
const m = await measurePaper(PAPER, PDF, o);
|
|
304
|
+
assert.equal(!m.ok && m.error, `${PDF}: gone after pdf.js read it`);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("measure: no paperlint.json and no override — venue and kind are null", async () => {
|
|
308
|
+
const { o, files } = setup();
|
|
309
|
+
files.map.delete(`${PAPER}/paperlint.json`);
|
|
310
|
+
const m = await measurePaper(PAPER, PDF, o);
|
|
311
|
+
assert.deepEqual(m.ok && [m.value.facts.venue, m.value.facts.kind], [
|
|
312
|
+
null,
|
|
313
|
+
null,
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── reading the file back: every refusal names what is wrong ────────────────────────────
|
|
318
|
+
const written = (geometry: Geometry = MEASURED) => ({
|
|
319
|
+
...doc(lastPage(60, 30), geometry),
|
|
320
|
+
});
|
|
321
|
+
const parse = (d: unknown) => parseFactsText(JSON.stringify(d));
|
|
322
|
+
const why = (d: unknown): string => {
|
|
323
|
+
const r = parse(d);
|
|
324
|
+
return !r.ok && r.error.kind === "broken" ? r.error.why : "";
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
test("parseFactsText: a written document reads back, with and without geometry", () => {
|
|
328
|
+
const measured = parse(written());
|
|
329
|
+
assert.ok(measured.ok);
|
|
330
|
+
assert.equal(measured.value.geometry?.columns, 2);
|
|
331
|
+
const none = parse(written(NONE));
|
|
332
|
+
assert.equal(none.ok && none.value.geometry, null);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("parseFactsText: every malformed shape is refused with its reason", () => {
|
|
336
|
+
assert.equal(why([1]), "not a JSON object");
|
|
337
|
+
assert.equal(why({ ...written(), pdf: "" }), "no `pdf` path");
|
|
338
|
+
assert.equal(why({ ...written(), pdf: 7 }), "no `pdf` path");
|
|
339
|
+
assert.equal(
|
|
340
|
+
why({ ...written(), pdf_sha256: "xyz" }),
|
|
341
|
+
"no `pdf_sha256` of 64 hex digits",
|
|
342
|
+
);
|
|
343
|
+
assert.equal(
|
|
344
|
+
why({ ...written(), fonts: [{ name: "x" }] }),
|
|
345
|
+
"`fonts` is not a list of { name, type, embedded, program }",
|
|
346
|
+
);
|
|
347
|
+
assert.equal(
|
|
348
|
+
why({ ...written(), fonts: ["Times"] }),
|
|
349
|
+
"`fonts` is not a list of { name, type, embedded, program }",
|
|
350
|
+
);
|
|
351
|
+
assert.equal(
|
|
352
|
+
why({ ...written(), geometry_source: 3 }),
|
|
353
|
+
"`geometry_source` is neither a measurer's name nor null",
|
|
354
|
+
);
|
|
355
|
+
assert.equal(
|
|
356
|
+
why({ ...written(), columns: "two" }),
|
|
357
|
+
"`columns` is not a number",
|
|
358
|
+
);
|
|
359
|
+
assert.equal(
|
|
360
|
+
why({ ...written(), ref_pages: null }),
|
|
361
|
+
"`ref_pages` is not a number",
|
|
362
|
+
);
|
|
363
|
+
assert.equal(
|
|
364
|
+
why({ ...written(), pages_by_type: { body: "3" } }),
|
|
365
|
+
"`pages_by_type` is not an object of page counts",
|
|
366
|
+
);
|
|
367
|
+
assert.equal(
|
|
368
|
+
why({ ...written(), pages_by_type: [3] }),
|
|
369
|
+
"`pages_by_type` is not an object of page counts",
|
|
370
|
+
);
|
|
371
|
+
assert.equal(
|
|
372
|
+
why({ ...written(), fonts: "Times" }),
|
|
373
|
+
"`fonts` is not a list of { name, type, embedded, program }",
|
|
374
|
+
);
|
|
375
|
+
const notJson = parseFactsText("{");
|
|
376
|
+
assert.match(
|
|
377
|
+
!notJson.ok && notJson.error.kind === "broken" ? notJson.error.why : "",
|
|
378
|
+
/^not JSON \(/,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("parseFactsText: a missing schema is reported as null, another schema by its value", () => {
|
|
383
|
+
const missing = parse({ ...written(), schema: undefined });
|
|
384
|
+
assert.deepEqual(!missing.ok && missing.error, {
|
|
385
|
+
kind: "schema",
|
|
386
|
+
got: "null",
|
|
387
|
+
});
|
|
388
|
+
const other = parse({ ...written(), schema: 1 });
|
|
389
|
+
assert.deepEqual(!other.ok && other.error, { kind: "schema", got: "1" });
|
|
390
|
+
});
|
|
@@ -15,8 +15,6 @@
|
|
|
15
15
|
* ⚠️ Assertions at the TOP LEVEL: `vigiles test` imports the file and counts "did not throw"
|
|
16
16
|
* as a pass.
|
|
17
17
|
*/
|
|
18
|
-
import assert from "node:assert/strict";
|
|
19
|
-
import { recordCheck } from "vigiles";
|
|
20
18
|
import {
|
|
21
19
|
existsSync,
|
|
22
20
|
mkdirSync,
|
|
@@ -29,6 +27,7 @@ import {
|
|
|
29
27
|
import { tmpdir } from "node:os";
|
|
30
28
|
import { dirname, join } from "node:path";
|
|
31
29
|
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { createChecker } from "../lib/check.mjs";
|
|
32
31
|
|
|
33
32
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
34
33
|
const {
|
|
@@ -44,12 +43,7 @@ const {
|
|
|
44
43
|
const { claudeCodeHookProtocol } = await import("vigiles/claude-code");
|
|
45
44
|
const merge = (e, c, m) => claudeCodeHookProtocol.mergeRegistrations(e, c, m);
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
const check = (label, cond) => {
|
|
49
|
-
n++;
|
|
50
|
-
assert.ok(cond, label);
|
|
51
|
-
recordCheck(label);
|
|
52
|
-
};
|
|
46
|
+
const check = createChecker();
|
|
53
47
|
|
|
54
48
|
// ── the contract with vigiles, checked before anything relies on it ──────────────────────
|
|
55
49
|
check(
|
|
@@ -64,6 +58,40 @@ check(
|
|
|
64
58
|
wiring.names.join() ===
|
|
65
59
|
"paper-edit-guard,paper-skills-nudge,paper-status-gates",
|
|
66
60
|
);
|
|
61
|
+
// ── settings written by hand: shapes Claude Code would ignore are skipped, not crashed on ──
|
|
62
|
+
check(
|
|
63
|
+
"an event that is not a list, an entry with no `hooks` list, a handler with no command → no commands",
|
|
64
|
+
JSON.stringify(
|
|
65
|
+
wiredCounts(
|
|
66
|
+
{
|
|
67
|
+
hooks: {
|
|
68
|
+
PreToolUse: "npx paperlint hook paper-edit-guard",
|
|
69
|
+
PostToolUse: [{ matcher: "Edit" }, null, { hooks: "x" }],
|
|
70
|
+
Stop: [{ hooks: [{ type: "command" }, null] }],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
wiring.names,
|
|
74
|
+
),
|
|
75
|
+
) ===
|
|
76
|
+
JSON.stringify(wiring.names.map((name) => ({ name, ours: 0, other: 0 }))),
|
|
77
|
+
);
|
|
78
|
+
{
|
|
79
|
+
const file = join(tmpdir(), `paperlint-no-hooks-${String(process.pid)}.json`);
|
|
80
|
+
writeFileSync(file, "{}\n");
|
|
81
|
+
let thrown = "";
|
|
82
|
+
try {
|
|
83
|
+
shippedWiring(file);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
thrown = e.message;
|
|
86
|
+
}
|
|
87
|
+
rmSync(file);
|
|
88
|
+
check(
|
|
89
|
+
"a wiring file without `hooks` names no hook — and THROWS instead of wiring nothing",
|
|
90
|
+
thrown === `${file} names no paperlint hook — there is nothing to wire`,
|
|
91
|
+
thrown,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-hooks-")));
|
|
68
96
|
try {
|
|
69
97
|
const empty = join(work, "empty-hooks.json");
|
|
@@ -135,7 +163,7 @@ try {
|
|
|
135
163
|
check(
|
|
136
164
|
"no settings file → it is created with the three hooks",
|
|
137
165
|
r.status === "written" &&
|
|
138
|
-
|
|
166
|
+
wiredCounts(s, wiring.names).every(
|
|
139
167
|
(c) => c.ours === 1 && c.other === 0,
|
|
140
168
|
),
|
|
141
169
|
);
|
|
@@ -172,7 +200,7 @@ try {
|
|
|
172
200
|
);
|
|
173
201
|
check(
|
|
174
202
|
"and ours are there, once each",
|
|
175
|
-
|
|
203
|
+
wiredCounts(s, wiring.names).every((c) => c.ours === 1),
|
|
176
204
|
);
|
|
177
205
|
}
|
|
178
206
|
|
|
@@ -224,7 +252,9 @@ try {
|
|
|
224
252
|
// The control: without the check, vigiles' merge WOULD add a second copy. This is the
|
|
225
253
|
// reason the check exists, measured rather than asserted.
|
|
226
254
|
const merged = merge(handWired, wiring.compiled, MANAGED_BY);
|
|
227
|
-
const c = wiredCounts(merged, wiring.names).
|
|
255
|
+
const c = wiredCounts(merged, wiring.names).find(
|
|
256
|
+
(c) => c.name === "paper-edit-guard",
|
|
257
|
+
);
|
|
228
258
|
check(
|
|
229
259
|
"(control) vigiles' merge alone would wire paper-edit-guard TWICE here",
|
|
230
260
|
c.ours === 1 && c.other === 1,
|
|
@@ -247,7 +277,7 @@ try {
|
|
|
247
277
|
|
|
248
278
|
// ── doctor's section ───────────────────────────────────────────────────────────────────
|
|
249
279
|
const doc = (settings) => {
|
|
250
|
-
const dir = project(`doc-${String(
|
|
280
|
+
const dir = project(`doc-${String(check.count)}`, settings);
|
|
251
281
|
return doctorHooks(dir, wiring).join("\n");
|
|
252
282
|
};
|
|
253
283
|
const wiredOnce = merge({}, wiring.compiled, MANAGED_BY);
|
|
@@ -306,5 +336,5 @@ try {
|
|
|
306
336
|
}
|
|
307
337
|
|
|
308
338
|
console.log(
|
|
309
|
-
`✓ ${String(
|
|
339
|
+
`✓ ${String(check.count)} assertions passed — the hooks land in .claude/settings.json once`,
|
|
310
340
|
);
|