paperlint 2.1.0 → 3.1.0
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/CLAUDE.md +6 -1
- package/README.md +243 -235
- package/action.yml +4 -6
- package/dist/adapters/references/index.d.ts +13 -0
- package/dist/adapters/references/index.d.ts.map +1 -0
- package/dist/adapters/references/index.js +55 -0
- package/dist/adapters/references/index.js.map +1 -0
- package/dist/adapters/references/reach.io.d.ts +3 -0
- package/dist/adapters/references/reach.io.d.ts.map +1 -0
- package/dist/adapters/references/reach.io.js +14 -0
- package/dist/adapters/references/reach.io.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 +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +60 -7
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +94 -30
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +500 -288
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +40 -72
- package/dist/doctor.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/facts-file.d.ts +2 -2
- package/dist/facts-file.js +2 -2
- package/dist/hooks-settings.d.ts +0 -21
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +17 -135
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +39 -29
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +144 -173
- package/dist/init.js.map +1 -1
- package/dist/link-skills.d.ts +1 -1
- package/dist/link-skills.d.ts.map +1 -1
- package/dist/link-skills.js +11 -29
- 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 +49 -25
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +85 -0
- package/dist/paper-files.d.ts.map +1 -0
- package/dist/paper-files.js +96 -0
- package/dist/paper-files.js.map +1 -0
- package/dist/paper-settings.d.ts +13 -41
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +71 -71
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/check-references.d.ts +31 -0
- package/dist/ports/check-references.d.ts.map +1 -0
- package/dist/ports/check-references.js +7 -0
- package/dist/ports/check-references.js.map +1 -0
- package/dist/presets.d.ts +1 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -6
- package/dist/presets.js.map +1 -1
- package/dist/reference-rules.d.ts +47 -0
- package/dist/reference-rules.d.ts.map +1 -0
- package/dist/reference-rules.js +156 -0
- package/dist/reference-rules.js.map +1 -0
- package/dist/references.d.ts +36 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +91 -0
- package/dist/references.js.map +1 -0
- package/dist/rules-config.d.ts +5 -3
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +15 -6
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.js +3 -3
- package/dist/structure.js.map +1 -1
- package/dist/tex-requirements.js +2 -2
- package/dist/tex-requirements.js.map +1 -1
- package/dist/toolchain.d.ts +5 -1
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +22 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +26 -18
- 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 +163 -104
- package/docs/e2e.md +1 -1
- package/docs/install.md +25 -40
- package/docs/optional-rules.md +5 -4
- package/docs/rules.md +65 -24
- package/docs/skills.md +80 -0
- package/eslint-rules/bib-reachable-entry.mjs +75 -0
- package/eslint-rules/latex-language.mjs +29 -1
- package/eslint-rules/paper-stages.harness.mjs +1 -117
- package/eslint-rules/paper-stages.mjs +0 -120
- package/eslint-rules/paper-stages.mutations.mjs +0 -76
- package/eslint-rules/paper-typography.mjs +306 -191
- package/eslint-rules/paper-typography.test.mjs +337 -0
- package/eslint-rules/papers.harness.mjs +8 -12
- package/eslint-rules/papers.mjs +17 -31
- package/eslint-rules/pdf-last-page-balance.mjs +4 -7
- package/eslint-rules/review-frontmatter.mjs +118 -0
- package/eslint-rules/review-frontmatter.schema.json +36 -0
- package/eslint-rules/review-frontmatter.test.mjs +118 -0
- package/eslint-rules/sibling-frontmatter.mjs +19 -0
- package/eslint-rules/sibling-frontmatter.schema.json +14 -0
- package/eslint.config.mjs +0 -15
- package/fixtures/real-markdown-paper/baseline.json +6 -7
- 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 +39 -98
- package/hooks/hooks.mutations.mjs +6 -6
- package/hooks/paper-edit-guard.hook.d.mts +2 -3
- package/hooks/paper-edit-guard.hook.mjs +31 -42
- package/hooks/paper-skills-nudge.hook.mjs +21 -19
- package/hooks/paper-status-gates.hook.mjs +21 -19
- package/hooks/paper-status-gates.sh +8 -13
- package/lib/paper-config.d.mts +15 -25
- package/lib/paper-config.harness.mjs +18 -35
- package/lib/paper-config.mjs +83 -105
- package/lib/paper-config.mutations.mjs +2 -2
- package/lib/skill-checks.mjs +1 -1
- package/lib/skill-trigger-cases.harness.mjs +9 -8
- package/lib/skill-trigger-cases.mjs +7 -15
- package/package.json +1 -1
- package/scripts/eslint-report-guard.mjs +1 -1
- package/scripts/harness-api.frozen.json +0 -3
- package/scripts/mutation-batteries.frozen.json +5 -14
- package/skills/analyze-sibling-paper/SKILL.md +16 -12
- package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
- package/skills/cold-read-diff/SKILL.md +20 -1
- package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
- package/skills/grade-paper-writing/SKILL.md +20 -1
- package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
- package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
- package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
- package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
- package/skills/plan-paper-timeline/SKILL.md +2 -2
- package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
- package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
- package/skills/render-paper/check-render.sh +2 -3
- package/skills/render-paper/render-paper.harness.mjs +2 -2
- package/skills/verify-citations/SKILL.md +10 -1
- package/skills/verify-citations/SKILL.md.spec.ts +9 -0
- package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
- package/src/adapters/references/index.ts +102 -0
- package/src/adapters/references/reach.io.ts +12 -0
- package/src/build-engine.ts +6 -1
- package/src/build.harness.mjs +54 -6
- package/src/build.ts +89 -11
- package/src/cli.harness.mjs +149 -294
- package/src/cli.mutations.mjs +35 -44
- package/src/cli.ts +637 -328
- package/src/doctor.harness.mjs +41 -84
- package/src/doctor.mutations.mjs +9 -9
- package/src/doctor.ts +44 -89
- package/src/engine.harness.mjs +50 -0
- package/src/engine.ts +35 -0
- package/src/facts-file.test.ts +0 -7
- package/src/facts-file.ts +2 -2
- package/src/hooks-settings.harness.mjs +7 -92
- package/src/hooks-settings.mutations.mjs +2 -17
- package/src/hooks-settings.ts +18 -162
- package/src/init.test.ts +206 -0
- package/src/init.ts +187 -231
- package/src/link-skills.harness.mjs +1 -38
- package/src/link-skills.mutations.mjs +9 -5
- package/src/link-skills.ts +10 -34
- package/src/lint-fix.test.ts +120 -0
- package/src/linted-set.test.ts +118 -0
- package/src/new-paper.harness.mjs +2 -0
- package/src/new-paper.mutations.mjs +0 -14
- package/src/new-paper.test.ts +173 -14
- package/src/new-paper.ts +82 -30
- package/src/paper-files.test.ts +395 -0
- package/src/paper-files.ts +200 -0
- package/src/paper-settings-commands.test.ts +121 -122
- package/src/paper-settings.test.ts +93 -64
- package/src/paper-settings.ts +99 -97
- package/src/parse-args.test.ts +48 -0
- package/src/ports/check-references.ts +28 -0
- package/src/presets.ts +5 -15
- package/src/reference-rules.ts +229 -0
- package/src/references.test.ts +209 -0
- package/src/references.ts +138 -0
- package/src/rules-config.harness.mjs +24 -8
- package/src/rules-config.ts +19 -7
- package/src/rules-off.test.ts +111 -0
- package/src/structure.harness.mjs +4 -2
- package/src/structure.ts +3 -3
- package/src/tex-requirements.ts +2 -2
- package/src/toolchain.harness.mjs +41 -6
- package/src/toolchain.ts +29 -5
- package/src/types.ts +36 -18
- package/src/venue-rules.test.ts +23 -11
- package/src/venue-rules.ts +12 -14
- package/templates/paper/paperlint.json +1 -1
- package/eslint-rules/doc-fields.harness.mjs +0 -336
- package/eslint-rules/doc-fields.mjs +0 -186
- package/eslint-rules/doc-fields.mutations.mjs +0 -96
- package/eslint-rules/paper-typography.harness.mjs +0 -291
- package/eslint-rules/paper-typography.mutations.mjs +0 -131
- package/eslint-rules/review-findings-cause.harness.mjs +0 -228
- package/eslint-rules/review-findings-cause.mjs +0 -135
- package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
- package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
- package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
- package/fixtures/review-findings-cause/clean.md +0 -17
- package/fixtures/review-findings-cause/defect.md +0 -14
- package/fixtures/review-findings-cause/old-debt.md +0 -14
- package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference rules — `paper/author-list`, `paper/cite-exists`, `paper/refs-checked`,
|
|
3
|
+
* `paper/refs-fresh` — judge `_build/references.json` (`references.ts`), offline. `paperlint build`
|
|
4
|
+
* does the online work; lint never touches the network.
|
|
5
|
+
*
|
|
6
|
+
* entry whose authors are the preprint's paper/author-list (error), on the entry
|
|
7
|
+
* entry whose identifier provably fails paper/cite-exists (error), on the entry
|
|
8
|
+
* no record, or recorded "not checked" paper/refs-checked (warn) — like pdf/measured
|
|
9
|
+
* bibliography edited after the build paper/refs-fresh (error) — like pdf/fresh
|
|
10
|
+
*
|
|
11
|
+
* While the record is stale, the per-entry rules are silent: they would judge a bibliography
|
|
12
|
+
* that no longer exists, and refs-fresh already says so once. A paper with no bibliography gets
|
|
13
|
+
* nothing.
|
|
14
|
+
*
|
|
15
|
+
* `paper/author-list` used to live on PIPELINE-STATUS.md and ask whether the scorecard mentioned
|
|
16
|
+
* a run. It lives here now because its subject is the bibliography, and the record of the run is
|
|
17
|
+
* the run's own output.
|
|
18
|
+
*/
|
|
19
|
+
import { basename, dirname } from "node:path";
|
|
20
|
+
import type { Files } from "./ports/files.ts";
|
|
21
|
+
import {
|
|
22
|
+
bibHash,
|
|
23
|
+
bibliographyOf,
|
|
24
|
+
readReferences,
|
|
25
|
+
REFERENCES_FILE,
|
|
26
|
+
type Bibliography,
|
|
27
|
+
} from "./references.ts";
|
|
28
|
+
|
|
29
|
+
interface Loc {
|
|
30
|
+
readonly line: number;
|
|
31
|
+
readonly column: number;
|
|
32
|
+
}
|
|
33
|
+
interface RuleContext {
|
|
34
|
+
readonly filename: string;
|
|
35
|
+
readonly cwd: string;
|
|
36
|
+
readonly sourceCode: {
|
|
37
|
+
getLocFromIndex(i: number): Loc;
|
|
38
|
+
};
|
|
39
|
+
report(d: {
|
|
40
|
+
loc: { start: Loc; end: Loc };
|
|
41
|
+
messageId: string;
|
|
42
|
+
data?: Record<string, string>;
|
|
43
|
+
}): void;
|
|
44
|
+
}
|
|
45
|
+
export interface ReferenceRuleModule {
|
|
46
|
+
readonly meta: {
|
|
47
|
+
readonly type: "problem" | "suggestion";
|
|
48
|
+
readonly docs: { readonly description: string };
|
|
49
|
+
readonly schema: readonly object[];
|
|
50
|
+
readonly messages: Readonly<Record<string, string>>;
|
|
51
|
+
};
|
|
52
|
+
create(context: RuleContext): { "root:exit"?: () => void };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Name = "author-list" | "cite-exists" | "refs-checked" | "refs-fresh";
|
|
56
|
+
|
|
57
|
+
/** What one lint run knows about a paper's references. */
|
|
58
|
+
type Assessment =
|
|
59
|
+
| { readonly kind: "no-bibliography" }
|
|
60
|
+
| { readonly kind: "unrecorded" }
|
|
61
|
+
| { readonly kind: "not-checked"; readonly why: string }
|
|
62
|
+
| { readonly kind: "stale" }
|
|
63
|
+
| {
|
|
64
|
+
readonly kind: "ready";
|
|
65
|
+
readonly bib: Bibliography;
|
|
66
|
+
readonly failing: readonly {
|
|
67
|
+
readonly key: string;
|
|
68
|
+
readonly rule: "author-list" | "cite-exists";
|
|
69
|
+
readonly why: string;
|
|
70
|
+
}[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function assess(files: Files, paperDir: string): Assessment {
|
|
74
|
+
const bib = bibliographyOf(files, paperDir);
|
|
75
|
+
if (bib === null) return { kind: "no-bibliography" };
|
|
76
|
+
const doc = readReferences(files, paperDir);
|
|
77
|
+
if (doc === null) return { kind: "unrecorded" };
|
|
78
|
+
if (doc.bib.sha256 !== bibHash(bib)) return { kind: "stale" };
|
|
79
|
+
if (doc.status === "not-checked")
|
|
80
|
+
return { kind: "not-checked", why: doc.why ?? "no reason recorded" };
|
|
81
|
+
const failing = doc.entries.flatMap((e) => [
|
|
82
|
+
...(e.authors === "mismatch"
|
|
83
|
+
? [{ key: e.key, rule: "author-list" as const, why: e.why ?? "" }]
|
|
84
|
+
: []),
|
|
85
|
+
...(e.exists === "false"
|
|
86
|
+
? [{ key: e.key, rule: "cite-exists" as const, why: e.why ?? "" }]
|
|
87
|
+
: []),
|
|
88
|
+
]);
|
|
89
|
+
return { kind: "ready", bib, failing };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Where entry `key` starts in the source file: its `@type{key,` line, or the file's start. */
|
|
93
|
+
function entryOffset(bib: Bibliography, key: string): number {
|
|
94
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
95
|
+
const m = new RegExp(`^@\\w+\\s*\\{\\s*${escaped}\\s*,`, "m").exec(bib.text);
|
|
96
|
+
return m ? bib.offset + m.index : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const BUILD = "`npx paperlint build`";
|
|
100
|
+
|
|
101
|
+
const META: Readonly<Record<Name, ReferenceRuleModule["meta"]>> = {
|
|
102
|
+
"author-list": {
|
|
103
|
+
type: "problem",
|
|
104
|
+
docs: {
|
|
105
|
+
description:
|
|
106
|
+
"each entry's authors are those of the version it cites, not the preprint's (recorded by paperlint build)",
|
|
107
|
+
},
|
|
108
|
+
schema: [],
|
|
109
|
+
messages: {
|
|
110
|
+
mismatch:
|
|
111
|
+
"`{{key}}`: the authors are not those of the version cited — {{why}}. The citation resolves and the id resolves, and the list is the PREPRINT's under a published venue",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
"cite-exists": {
|
|
115
|
+
type: "problem",
|
|
116
|
+
docs: {
|
|
117
|
+
description:
|
|
118
|
+
"each entry's identifier resolves to the work cited (recorded by paperlint build)",
|
|
119
|
+
},
|
|
120
|
+
schema: [],
|
|
121
|
+
messages: {
|
|
122
|
+
missing: "`{{key}}` does not resolve to the work cited — {{why}}",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
"refs-checked": {
|
|
126
|
+
type: "suggestion",
|
|
127
|
+
docs: {
|
|
128
|
+
description:
|
|
129
|
+
"the build checked the references online (existence, title, authors); a warning when it could not",
|
|
130
|
+
},
|
|
131
|
+
schema: [],
|
|
132
|
+
messages: {
|
|
133
|
+
unrecorded: `the references have not been checked — ${BUILD} checks them online and records the result in _build/${REFERENCES_FILE}`,
|
|
134
|
+
notChecked: `the last build could not check the references ({{why}}) — run ${BUILD} with network`,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
"refs-fresh": {
|
|
138
|
+
type: "problem",
|
|
139
|
+
docs: {
|
|
140
|
+
description:
|
|
141
|
+
"the recorded reference verdicts are about the bibliography on disk, not an earlier one",
|
|
142
|
+
},
|
|
143
|
+
schema: [],
|
|
144
|
+
messages: {
|
|
145
|
+
stale: `the references changed since the last build checked them — run ${BUILD}`,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
type Report = { at: number; messageId: string; data?: Record<string, string> };
|
|
151
|
+
|
|
152
|
+
const JUDGES: Readonly<Record<Name, (a: Assessment) => Report[]>> = {
|
|
153
|
+
"author-list": (a) =>
|
|
154
|
+
a.kind === "ready"
|
|
155
|
+
? a.failing
|
|
156
|
+
.filter((f) => f.rule === "author-list")
|
|
157
|
+
.map((f) => ({
|
|
158
|
+
at: entryOffset(a.bib, f.key),
|
|
159
|
+
messageId: "mismatch",
|
|
160
|
+
data: { key: f.key, why: f.why },
|
|
161
|
+
}))
|
|
162
|
+
: [],
|
|
163
|
+
"cite-exists": (a) =>
|
|
164
|
+
a.kind === "ready"
|
|
165
|
+
? a.failing
|
|
166
|
+
.filter((f) => f.rule === "cite-exists")
|
|
167
|
+
.map((f) => ({
|
|
168
|
+
at: entryOffset(a.bib, f.key),
|
|
169
|
+
messageId: "missing",
|
|
170
|
+
data: { key: f.key, why: f.why },
|
|
171
|
+
}))
|
|
172
|
+
: [],
|
|
173
|
+
"refs-checked": (a) =>
|
|
174
|
+
a.kind === "unrecorded"
|
|
175
|
+
? [{ at: 0, messageId: "unrecorded" }]
|
|
176
|
+
: a.kind === "not-checked"
|
|
177
|
+
? [{ at: 0, messageId: "notChecked", data: { why: a.why } }]
|
|
178
|
+
: [],
|
|
179
|
+
"refs-fresh": (a) =>
|
|
180
|
+
a.kind === "stale" ? [{ at: 0, messageId: "stale" }] : [],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** The four rules, reading through `files`. They act on `paper.tex` only. */
|
|
184
|
+
export function referenceRules({
|
|
185
|
+
files,
|
|
186
|
+
}: {
|
|
187
|
+
files: Files;
|
|
188
|
+
}): Record<Name, ReferenceRuleModule> {
|
|
189
|
+
const make = (name: Name): ReferenceRuleModule => ({
|
|
190
|
+
meta: META[name],
|
|
191
|
+
create: (context) =>
|
|
192
|
+
basename(context.filename) !== "paper.tex"
|
|
193
|
+
? {}
|
|
194
|
+
: {
|
|
195
|
+
"root:exit": () => {
|
|
196
|
+
const paperDir = dirname(context.filename);
|
|
197
|
+
const a = assess(files, paperDir);
|
|
198
|
+
// An external refs.bib: the entry is not in this file, so the finding sits at its
|
|
199
|
+
// start and names the key.
|
|
200
|
+
const external =
|
|
201
|
+
a.kind === "ready" && a.bib.source !== "paper.tex";
|
|
202
|
+
for (const r of JUDGES[name](a)) {
|
|
203
|
+
const loc = context.sourceCode.getLocFromIndex(
|
|
204
|
+
external ? 0 : r.at,
|
|
205
|
+
);
|
|
206
|
+
context.report({
|
|
207
|
+
loc: { start: loc, end: loc },
|
|
208
|
+
messageId: r.messageId,
|
|
209
|
+
...(r.data ? { data: r.data } : {}),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
"author-list": make("author-list"),
|
|
217
|
+
"cite-exists": make("cite-exists"),
|
|
218
|
+
"refs-checked": make("refs-checked"),
|
|
219
|
+
"refs-fresh": make("refs-fresh"),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The level each is on at for every `paper.tex` in paperlint's own config. */
|
|
224
|
+
export const REFERENCE_RULE_LEVELS = {
|
|
225
|
+
"paper/author-list": "error",
|
|
226
|
+
"paper/cite-exists": "error",
|
|
227
|
+
"paper/refs-checked": "warn",
|
|
228
|
+
"paper/refs-fresh": "error",
|
|
229
|
+
} as const;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The references a build checked, end to end without a network: the `references` build step
|
|
3
|
+
* records what a (fake) checker answers, and the offline rules judge that record.
|
|
4
|
+
*
|
|
5
|
+
* a pass is recorded, with the hash of the bibliography checked
|
|
6
|
+
* one mismatched entry → paper/author-list on THAT entry's line
|
|
7
|
+
* the bibliography edited after the build → paper/refs-fresh, and the per-entry rules go quiet
|
|
8
|
+
* offline → the step still succeeds (the build is not failed) and paper/refs-checked warns
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { ESLint } from "eslint";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
14
|
+
import type { AbsolutePath } from "./domain/paths.ts";
|
|
15
|
+
import type {
|
|
16
|
+
CheckReferences,
|
|
17
|
+
EntryVerdict,
|
|
18
|
+
} from "./ports/check-references.ts";
|
|
19
|
+
import { referencesStep } from "./build.ts";
|
|
20
|
+
import {
|
|
21
|
+
bibHash,
|
|
22
|
+
bibliographyOf,
|
|
23
|
+
readReferences,
|
|
24
|
+
referencesPath,
|
|
25
|
+
} from "./references.ts";
|
|
26
|
+
import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
|
|
27
|
+
// @ts-expect-error — an ESLint language in .mjs, it has no types
|
|
28
|
+
import { texLanguage } from "../eslint-rules/latex-language.mjs";
|
|
29
|
+
|
|
30
|
+
const PAPER = "/work/papers/p";
|
|
31
|
+
const TEX = (entries: string) =>
|
|
32
|
+
[
|
|
33
|
+
"\\documentclass{acmart}",
|
|
34
|
+
"\\begin{filecontents*}{refs.bib}",
|
|
35
|
+
entries,
|
|
36
|
+
"\\end{filecontents*}",
|
|
37
|
+
"\\begin{document}",
|
|
38
|
+
"x",
|
|
39
|
+
"\\end{document}",
|
|
40
|
+
"",
|
|
41
|
+
].join("\n");
|
|
42
|
+
const ENTRIES =
|
|
43
|
+
"@inproceedings{schick2023,\n title = {Toolformer},\n author = {Schick, Timo},\n booktitle = {NeurIPS}\n}\n@misc{other,\n title = {Other},\n url = {https://x.org}\n}";
|
|
44
|
+
|
|
45
|
+
const verdict = (
|
|
46
|
+
key: string,
|
|
47
|
+
authors: EntryVerdict["authors"] = "match",
|
|
48
|
+
exists: EntryVerdict["exists"] = "true",
|
|
49
|
+
): EntryVerdict => ({
|
|
50
|
+
key,
|
|
51
|
+
exists,
|
|
52
|
+
authors,
|
|
53
|
+
...(authors === "mismatch" ? { why: "missing hambro" } : {}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const checker =
|
|
57
|
+
(entries: readonly EntryVerdict[]): CheckReferences =>
|
|
58
|
+
async () => ({ kind: "checked", entries });
|
|
59
|
+
const offline: CheckReferences = async () => ({
|
|
60
|
+
kind: "not-checked",
|
|
61
|
+
why: "the citation services cannot be reached (fetch failed)",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Run the build step on a paper held in memory; returns the files and the step's outcome. */
|
|
65
|
+
async function build(tex: string, check: CheckReferences) {
|
|
66
|
+
const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
|
|
67
|
+
const out = await referencesStep.run({
|
|
68
|
+
paperDir: PAPER,
|
|
69
|
+
env: {},
|
|
70
|
+
run: (() => ({})) as never,
|
|
71
|
+
readPdf: (() => null) as never,
|
|
72
|
+
measure: (() => null) as never,
|
|
73
|
+
files,
|
|
74
|
+
checkReferences: check,
|
|
75
|
+
});
|
|
76
|
+
return { files, out };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The rules as an ESLint plugin. Their context type is the narrow one they read (a custom
|
|
81
|
+
* language's SourceCode), which ESLint's JS-centred plugin type does not describe.
|
|
82
|
+
*/
|
|
83
|
+
const asPlugin = (rules: object): ESLint.Plugin => ({ rules }) as ESLint.Plugin;
|
|
84
|
+
|
|
85
|
+
/** Lint `tex` as the paper's paper.tex against what `files` recorded. */
|
|
86
|
+
async function lint(files: ReturnType<typeof memoryFiles>, tex: string) {
|
|
87
|
+
files.writeAtomic(
|
|
88
|
+
`${PAPER}/paper.tex` as AbsolutePath,
|
|
89
|
+
new TextEncoder().encode(tex),
|
|
90
|
+
);
|
|
91
|
+
const eslint = new ESLint({
|
|
92
|
+
cwd: "/work",
|
|
93
|
+
overrideConfigFile: true,
|
|
94
|
+
overrideConfig: [
|
|
95
|
+
{
|
|
96
|
+
files: ["**/paper.tex"],
|
|
97
|
+
plugins: {
|
|
98
|
+
tex: { languages: { latex: texLanguage } },
|
|
99
|
+
paper: asPlugin(referenceRules({ files })),
|
|
100
|
+
},
|
|
101
|
+
language: "tex/latex",
|
|
102
|
+
rules: REFERENCE_RULE_LEVELS,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
const [res] = await eslint.lintText(tex, {
|
|
107
|
+
filePath: join(PAPER, "paper.tex"),
|
|
108
|
+
});
|
|
109
|
+
return res!.messages;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("the references build step", () => {
|
|
113
|
+
it("records a pass, with the hash of the bibliography it checked", async () => {
|
|
114
|
+
const tex = TEX(ENTRIES);
|
|
115
|
+
const { files, out } = await build(
|
|
116
|
+
tex,
|
|
117
|
+
checker([verdict("schick2023"), verdict("other")]),
|
|
118
|
+
);
|
|
119
|
+
expect(out.ok).toBe(true);
|
|
120
|
+
const doc = readReferences(files, PAPER);
|
|
121
|
+
expect(doc?.status).toBe("checked");
|
|
122
|
+
expect(doc?.bib.sha256).toBe(bibHash(bibliographyOf(files, PAPER)!));
|
|
123
|
+
expect(await lint(files, tex)).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("🔴 offline: the step succeeds — the build is not failed — and lint warns once", async () => {
|
|
127
|
+
const tex = TEX(ENTRIES);
|
|
128
|
+
const { files, out } = await build(tex, offline);
|
|
129
|
+
expect(out.ok).toBe(true);
|
|
130
|
+
expect(readReferences(files, PAPER)?.status).toBe("not-checked");
|
|
131
|
+
const msgs = await lint(files, tex);
|
|
132
|
+
expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
|
|
133
|
+
["paper/refs-checked", 1],
|
|
134
|
+
]);
|
|
135
|
+
expect(msgs[0]!.message).toMatch(/cannot be reached/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a checker that throws is recorded as not checked, not as a failed build", async () => {
|
|
139
|
+
const { files, out } = await build(TEX(ENTRIES), async () => {
|
|
140
|
+
throw new Error("DBLP exploded");
|
|
141
|
+
});
|
|
142
|
+
expect(out.ok).toBe(true);
|
|
143
|
+
expect(readReferences(files, PAPER)?.why).toMatch(/DBLP exploded/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("the reference rules", () => {
|
|
148
|
+
it("one mismatched entry → paper/author-list on that entry's own line", async () => {
|
|
149
|
+
const tex = TEX(ENTRIES);
|
|
150
|
+
const { files } = await build(
|
|
151
|
+
tex,
|
|
152
|
+
checker([verdict("schick2023", "mismatch"), verdict("other")]),
|
|
153
|
+
);
|
|
154
|
+
const msgs = await lint(files, tex);
|
|
155
|
+
expect(msgs.map((m) => m.ruleId)).toEqual(["paper/author-list"]);
|
|
156
|
+
expect(msgs[0]!.line).toBe(3);
|
|
157
|
+
expect(msgs[0]!.message).toMatch(/`schick2023`.*missing hambro/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("an identifier that provably fails → paper/cite-exists on that entry", async () => {
|
|
161
|
+
const tex = TEX(ENTRIES);
|
|
162
|
+
const { files } = await build(
|
|
163
|
+
tex,
|
|
164
|
+
checker([verdict("schick2023"), verdict("other", "skipped", "false")]),
|
|
165
|
+
);
|
|
166
|
+
const msgs = await lint(files, tex);
|
|
167
|
+
expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
|
|
168
|
+
["paper/cite-exists", 8],
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("🔴 the bibliography edited after the build → refs-fresh, and the stale verdicts are not judged", async () => {
|
|
173
|
+
const { files } = await build(
|
|
174
|
+
TEX(ENTRIES),
|
|
175
|
+
checker([verdict("schick2023", "mismatch"), verdict("other")]),
|
|
176
|
+
);
|
|
177
|
+
const edited = TEX(
|
|
178
|
+
ENTRIES.replace("Schick, Timo", "Schick, Timo and Hambro, Eric"),
|
|
179
|
+
);
|
|
180
|
+
const msgs = await lint(files, edited);
|
|
181
|
+
expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
|
|
182
|
+
["paper/refs-fresh", 2],
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("the same bibliography, unchanged → silent", async () => {
|
|
187
|
+
const tex = TEX(ENTRIES);
|
|
188
|
+
const { files } = await build(tex, checker([verdict("schick2023")]));
|
|
189
|
+
expect(await lint(files, tex)).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("never built → one warning naming the command", async () => {
|
|
193
|
+
const files = memoryFiles({});
|
|
194
|
+
const msgs = await lint(files, TEX(ENTRIES));
|
|
195
|
+
expect(msgs.map((m) => m.ruleId)).toEqual(["paper/refs-checked"]);
|
|
196
|
+
expect(msgs[0]!.message).toMatch(/npx paperlint build/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("a paper with no bibliography gets nothing", async () => {
|
|
200
|
+
const files = memoryFiles({});
|
|
201
|
+
expect(
|
|
202
|
+
await lint(
|
|
203
|
+
files,
|
|
204
|
+
"\\documentclass{article}\n\\begin{document}x\\end{document}\n",
|
|
205
|
+
),
|
|
206
|
+
).toEqual([]);
|
|
207
|
+
expect(referencesPath(PAPER)).toBe(`${PAPER}/_build/references.json`);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE REFERENCES A BUILD CHECKED — `<paper>/_build/references.json`, written by `paperlint build`
|
|
3
|
+
* and read, offline, by the lint rules in `reference-rules.ts`.
|
|
4
|
+
*
|
|
5
|
+
* The package ships two online checks: `verify-cites` (the cited work exists and its title
|
|
6
|
+
* matches) and `bib-authors` (the authors are those of the version cited, not the preprint's).
|
|
7
|
+
* Until 3.0.0 lint could only ask whether someone had run them — by finding the word
|
|
8
|
+
* "bib-authors" in a scorecard table, or a command the project configured. Now the build runs
|
|
9
|
+
* them and records the result as data, beside the facts it already writes about the PDF:
|
|
10
|
+
*
|
|
11
|
+
* { "schema": 1,
|
|
12
|
+
* "bib": { "source": "paper.tex", "sha256": "…" }, the bibliography that was checked
|
|
13
|
+
* "status": "checked" | "not-checked", "why": "…", not-checked: nothing could be asked
|
|
14
|
+
* "entries": [ { "key", "exists", "authors", "why" } ] }
|
|
15
|
+
*
|
|
16
|
+
* A sibling file, not a new field of `paper.facts.json`: those facts are about ONE PDF and are
|
|
17
|
+
* stale when it changes (`pdf_sha256`); these are about the bibliography and are stale when IT
|
|
18
|
+
* changes (`bib.sha256`). One staleness key per file, the way `pdf/fresh` already works.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 THE STEP NEVER FAILS THE BUILD. A build without network still builds the PDF; the reference
|
|
21
|
+
* step records `not-checked` and the lint rule says so, as a warning. Recording "not checked" as
|
|
22
|
+
* a pass would be the counter that counts what it never looked at.
|
|
23
|
+
*/
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import type { AbsolutePath } from "./domain/paths.ts";
|
|
26
|
+
import { sha256Hex } from "./domain/sha256.ts";
|
|
27
|
+
import type { Files } from "./ports/files.ts";
|
|
28
|
+
import type {
|
|
29
|
+
CheckReferences,
|
|
30
|
+
EntryVerdict,
|
|
31
|
+
ReferencesCheck,
|
|
32
|
+
} from "./ports/check-references.ts";
|
|
33
|
+
// @ts-expect-error — an ESLint rule module in .mjs, it has no types
|
|
34
|
+
import { bibRange } from "../eslint-rules/paper-typography.mjs";
|
|
35
|
+
|
|
36
|
+
export const REFERENCES_SCHEMA = 1;
|
|
37
|
+
export const REFERENCES_FILE = "references.json";
|
|
38
|
+
|
|
39
|
+
/** Where a paper's reference verdicts live — beside `paper.facts.json`. */
|
|
40
|
+
export const referencesPath = (paperDir: string): string =>
|
|
41
|
+
join(paperDir, "_build", REFERENCES_FILE);
|
|
42
|
+
|
|
43
|
+
const at = (p: string): AbsolutePath => p as AbsolutePath;
|
|
44
|
+
const text = (files: Files, p: string): string | null => {
|
|
45
|
+
const b = files.readBytes(at(p));
|
|
46
|
+
return b === null ? null : new TextDecoder().decode(b);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The bibliography a paper carries: inside `paper.tex` (`filecontents`), else `refs.bib`. */
|
|
50
|
+
export interface Bibliography {
|
|
51
|
+
readonly source: "paper.tex" | "refs.bib";
|
|
52
|
+
readonly text: string;
|
|
53
|
+
/** Where `text` starts in the source file — so a finding can point at an entry. */
|
|
54
|
+
readonly offset: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function bibliographyOf(
|
|
58
|
+
files: Files,
|
|
59
|
+
paperDir: string,
|
|
60
|
+
): Bibliography | null {
|
|
61
|
+
const tex = text(files, join(paperDir, "paper.tex"));
|
|
62
|
+
const inline = tex === null ? null : bibRange(tex);
|
|
63
|
+
if (inline)
|
|
64
|
+
return { source: "paper.tex", text: inline.body, offset: inline.bodyStart };
|
|
65
|
+
const bib = text(files, join(paperDir, "refs.bib"));
|
|
66
|
+
return bib === null ? null : { source: "refs.bib", text: bib, offset: 0 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const bibHash = (b: Bibliography): string =>
|
|
70
|
+
sha256Hex(new TextEncoder().encode(b.text));
|
|
71
|
+
|
|
72
|
+
/** What `_build/references.json` holds. */
|
|
73
|
+
export interface ReferencesDocument {
|
|
74
|
+
readonly schema: number;
|
|
75
|
+
readonly bib: { readonly source: string; readonly sha256: string };
|
|
76
|
+
readonly status: "checked" | "not-checked";
|
|
77
|
+
readonly why?: string;
|
|
78
|
+
readonly entries: readonly EntryVerdict[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const documentOf = (
|
|
82
|
+
bib: Bibliography,
|
|
83
|
+
check: ReferencesCheck,
|
|
84
|
+
): ReferencesDocument => ({
|
|
85
|
+
schema: REFERENCES_SCHEMA,
|
|
86
|
+
bib: { source: bib.source, sha256: bibHash(bib) },
|
|
87
|
+
...(check.kind === "checked"
|
|
88
|
+
? { status: "checked" as const, entries: check.entries }
|
|
89
|
+
: { status: "not-checked" as const, why: check.why, entries: [] }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** The recorded verdicts, or null when there are none or they do not parse. */
|
|
93
|
+
export function readReferences(
|
|
94
|
+
files: Files,
|
|
95
|
+
paperDir: string,
|
|
96
|
+
): ReferencesDocument | null {
|
|
97
|
+
const raw = text(files, referencesPath(paperDir));
|
|
98
|
+
if (raw === null) return null;
|
|
99
|
+
try {
|
|
100
|
+
const d = JSON.parse(raw) as ReferencesDocument;
|
|
101
|
+
return d?.schema === REFERENCES_SCHEMA && Array.isArray(d.entries)
|
|
102
|
+
? d
|
|
103
|
+
: null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The build step's work: check the bibliography and record the verdicts. Returns the one-line
|
|
111
|
+
* note the build prints. A checker that throws is recorded as `not-checked` — the step reports,
|
|
112
|
+
* it never refuses.
|
|
113
|
+
*/
|
|
114
|
+
export async function recordReferences(
|
|
115
|
+
files: Files,
|
|
116
|
+
paperDir: string,
|
|
117
|
+
check: CheckReferences,
|
|
118
|
+
): Promise<string> {
|
|
119
|
+
const bib = bibliographyOf(files, paperDir);
|
|
120
|
+
if (bib === null) return "no bibliography — nothing to check";
|
|
121
|
+
let result: ReferencesCheck;
|
|
122
|
+
try {
|
|
123
|
+
result = await check(bib.text);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
result = { kind: "not-checked", why: (e as Error).message };
|
|
126
|
+
}
|
|
127
|
+
const doc = documentOf(bib, result);
|
|
128
|
+
files.writeAtomic(
|
|
129
|
+
at(referencesPath(paperDir)),
|
|
130
|
+
new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`),
|
|
131
|
+
);
|
|
132
|
+
if (doc.status === "not-checked")
|
|
133
|
+
return `references NOT checked — ${doc.why ?? "no reason given"}; lint will say so`;
|
|
134
|
+
const bad = doc.entries.filter(
|
|
135
|
+
(e) => e.exists === "false" || e.authors === "mismatch",
|
|
136
|
+
).length;
|
|
137
|
+
return `references: ${String(doc.entries.length)} checked, ${String(bad)} failing → _build/${REFERENCES_FILE}`;
|
|
138
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `rules-config.ts` — parsing the `rules` key into ESLint config blocks, and deriving which rules
|
|
3
|
-
* paperlint ships. The end-to-end path (a
|
|
3
|
+
* paperlint ships. The end-to-end path (a project's paperlint.json → `paperlint lint`) is in `cli.harness.mjs`;
|
|
4
4
|
* this pins the parser's output shape and each refusal.
|
|
5
5
|
*/
|
|
6
6
|
import assert from "node:assert/strict";
|
|
@@ -46,23 +46,39 @@ check(
|
|
|
46
46
|
JSON.stringify(r),
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
|
+
{
|
|
50
|
+
const r = parse({ "pdf/last-page-balance": "error" });
|
|
51
|
+
check(
|
|
52
|
+
"the object form is ONE block with no `files` — narrowed to each rule's own files later",
|
|
53
|
+
r.ok &&
|
|
54
|
+
JSON.stringify(r.value) ===
|
|
55
|
+
JSON.stringify([
|
|
56
|
+
{
|
|
57
|
+
basePath: "/consumer",
|
|
58
|
+
rules: { "pdf/last-page-balance": "error" },
|
|
59
|
+
},
|
|
60
|
+
]),
|
|
61
|
+
JSON.stringify(r),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
49
64
|
check(
|
|
50
65
|
"both severity spellings are accepted",
|
|
51
66
|
parse([{ rules: { "paper/typography": 0, "pdf/last-page-balance": "warn" } }])
|
|
52
67
|
.ok,
|
|
53
68
|
);
|
|
54
69
|
const refusals = [
|
|
55
|
-
[
|
|
56
|
-
[
|
|
57
|
-
[[
|
|
58
|
-
[[{
|
|
70
|
+
["pdf/last-page-balance", 'S → "rules" must be { "<rule>": "<severity>" }'],
|
|
71
|
+
[{ a: 1 }, '"a" is not a rule paperlint ships'],
|
|
72
|
+
[[42], "S → rules[0] must be an object"],
|
|
73
|
+
[[{ rules: [] }], "S → rules[0].rules must be an object"],
|
|
74
|
+
[[{ files: [], rules: {} }], "S → rules[0].files must be a non-empty list"],
|
|
59
75
|
[
|
|
60
76
|
[{ files: ["x"], ignores: [1], rules: {} }],
|
|
61
|
-
"S
|
|
77
|
+
"S → rules[0].ignores must be a non-empty list",
|
|
62
78
|
],
|
|
63
79
|
[
|
|
64
80
|
[{ languageOptions: {}, rules: {} }],
|
|
65
|
-
'S
|
|
81
|
+
'S → rules[0]: unknown key "languageOptions"',
|
|
66
82
|
],
|
|
67
83
|
[
|
|
68
84
|
[{ rules: { "markdown/no-html": "error" } }],
|
|
@@ -70,7 +86,7 @@ const refusals = [
|
|
|
70
86
|
],
|
|
71
87
|
[
|
|
72
88
|
[{ rules: { "paper/typography": ["loud"] } }],
|
|
73
|
-
'S
|
|
89
|
+
'S → rules[0].rules["paper/typography"]: ["loud"] is not a severity',
|
|
74
90
|
],
|
|
75
91
|
];
|
|
76
92
|
for (const [raw, says] of refusals) {
|