paperlint 2.0.1 → 3.0.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/CONTRIBUTING.md +21 -0
- package/README.md +231 -180
- 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 +5 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts +16 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +40 -5
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +37 -20
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +377 -251
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +37 -52
- package/dist/doctor.js.map +1 -1
- package/dist/facts-file.d.ts +33 -9
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +117 -17
- package/dist/facts-file.js.map +1 -1
- 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 +40 -19
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +144 -109
- 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 +2 -2
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +6 -2
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +63 -0
- package/dist/paper-files.d.ts.map +1 -0
- package/dist/paper-files.js +69 -0
- package/dist/paper-files.js.map +1 -0
- package/dist/paper-settings.d.ts +40 -0
- package/dist/paper-settings.d.ts.map +1 -0
- package/dist/paper-settings.js +144 -0
- package/dist/paper-settings.js.map +1 -0
- 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 +84 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +184 -0
- package/dist/presets.js.map +1 -0
- 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 +11 -2
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +25 -11
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.d.ts.map +1 -1
- package/dist/structure.js +3 -2
- package/dist/structure.js.map +1 -1
- package/dist/tex-requirements.d.ts +63 -7
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +91 -24
- package/dist/tex-requirements.js.map +1 -1
- package/dist/types.d.ts +20 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts +109 -0
- package/dist/venue-rules.d.ts.map +1 -0
- package/dist/venue-rules.js +389 -0
- package/dist/venue-rules.js.map +1 -0
- package/docs/configuration.md +152 -77
- package/docs/e2e.md +1 -1
- package/docs/install.md +25 -40
- package/docs/optional-rules.md +26 -24
- package/docs/rules.md +181 -15
- 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 +267 -192
- package/eslint-rules/paper-typography.test.mjs +297 -0
- package/eslint-rules/papers.harness.mjs +8 -12
- package/eslint-rules/papers.mjs +17 -27
- package/eslint-rules/pdf-last-page-balance.mjs +23 -5
- 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/build-e2e/acmart/paperlint.json +1 -0
- package/fixtures/real-markdown-paper/baseline.json +6 -7
- 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 -18
- package/lib/paper-config.harness.mjs +18 -35
- package/lib/paper-config.mjs +90 -81
- 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 +4 -13
- 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/find-venue/SKILL.md +5 -1
- package/skills/find-venue/SKILL.md.spec.ts +4 -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/PIPELINE-MAP.md +14 -4
- package/skills/paper-pipeline/SKILL.md +4 -3
- package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
- 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/extract-pdf-facts.harness.mjs +5 -5
- package/skills/render-paper/extract-pdf-facts.mjs +4 -4
- package/skills/render-paper/render-paper.harness.mjs +2 -2
- package/skills/study-accepted-papers/SKILL.md +2 -1
- package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
- package/skills/submit-paper/SKILL.md +9 -1
- package/skills/submit-paper/SKILL.md.spec.ts +8 -0
- package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
- package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
- package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
- package/skills/submit-paper/references/venues/realm.jsonc +45 -43
- package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
- package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
- 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 +10 -2
- package/src/build.harness.mjs +31 -22
- package/src/build.ts +65 -8
- package/src/cli.harness.mjs +151 -294
- package/src/cli.mutations.mjs +35 -44
- package/src/cli.ts +457 -288
- package/src/doctor.harness.mjs +41 -84
- package/src/doctor.mutations.mjs +9 -9
- package/src/doctor.ts +41 -61
- package/src/facts-file.test.ts +47 -7
- package/src/facts-file.ts +145 -24
- 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 +193 -159
- 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/new-paper.harness.mjs +9 -6
- package/src/new-paper.test.ts +133 -0
- package/src/new-paper.ts +7 -2
- package/src/paper-files.test.ts +290 -0
- package/src/paper-files.ts +144 -0
- package/src/paper-settings-commands.test.ts +274 -0
- package/src/paper-settings.test.ts +249 -0
- package/src/paper-settings.ts +208 -0
- package/src/ports/check-references.ts +28 -0
- package/src/presets.test.ts +236 -0
- package/src/presets.ts +297 -0
- 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 +30 -13
- package/src/structure.harness.mjs +6 -4
- package/src/structure.ts +3 -2
- package/src/tex-requirements.harness.mjs +11 -17
- package/src/tex-requirements.ts +179 -29
- package/src/types.ts +30 -18
- package/src/venue-rules.test.ts +554 -0
- package/src/venue-rules.ts +571 -0
- package/templates/paper/paperlint.json +4 -0
- 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/build-e2e/acmart/venue.json +0 -1
- 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
package/src/presets.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VENUE PRESETS — what `"extends"` in a paper's `paperlint.json` resolves to.
|
|
3
|
+
*
|
|
4
|
+
* A venue is a preset, the way an ESLint config is a shareable config: a file with the venue's
|
|
5
|
+
* format (page size, columns, fonts, font sizes, page limits per kind), the TeX packages its
|
|
6
|
+
* template needs, and the rules it implies. A preset may build on another with its own `extends`:
|
|
7
|
+
* `agenticdev` extends the family `acm-sigconf`, which holds everything the ACM template decides.
|
|
8
|
+
*
|
|
9
|
+
* ── TWO SPEC FORMS, AND ONLY TWO ─────────────────────────────────────────────────
|
|
10
|
+
* paperlint:<name> a preset shipped in the package's venues directory
|
|
11
|
+
* ./x.jsonc, ../x a file of the project's own, relative to the file that names it
|
|
12
|
+
* Anything else — an npm package name, a bare name — is refused by name. npm presets are not
|
|
13
|
+
* supported yet; a bare name is almost always a shipped name missing its prefix, and the message says so.
|
|
14
|
+
*
|
|
15
|
+
* ── THE CHAIN ─────────────────────────────────────────────────────────────────────
|
|
16
|
+
* At most `MAX_PRESET_DEPTH` files, root to leaf. A cycle (relative paths can form one) is refused
|
|
17
|
+
* naming the chain. The chain is merged block by block:
|
|
18
|
+
*
|
|
19
|
+
* tex union — a child never removes a package its parent needs
|
|
20
|
+
* format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
|
|
21
|
+
* rules per rule id, the child wins
|
|
22
|
+
* template the child wins; so does `name`
|
|
23
|
+
*
|
|
24
|
+
* ── THE LABEL ─────────────────────────────────────────────────────────────────────
|
|
25
|
+
* Messages, the facts file and the build plan need a word for the venue. It is the most derived
|
|
26
|
+
* preset's `name` when one sets it, else the spec's file name without its extension
|
|
27
|
+
* (`paperlint:agenticdev` → `agenticdev`, `./venues/usenix-sec.jsonc` → `usenix-sec`). Display
|
|
28
|
+
* only: nothing is ever resolved by it.
|
|
29
|
+
*/
|
|
30
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
31
|
+
import {
|
|
32
|
+
mergeRequirements,
|
|
33
|
+
NO_FORMAT,
|
|
34
|
+
NO_REQUIREMENTS,
|
|
35
|
+
parsePreset,
|
|
36
|
+
profileFileOf,
|
|
37
|
+
venueNames,
|
|
38
|
+
type PresetFile,
|
|
39
|
+
type TexRequirements,
|
|
40
|
+
type VenueFormat,
|
|
41
|
+
} from "./tex-requirements.ts";
|
|
42
|
+
import type { AbsolutePath } from "./domain/paths.ts";
|
|
43
|
+
import { err, ok, type Result } from "./domain/result.ts";
|
|
44
|
+
import type { Files } from "./ports/files.ts";
|
|
45
|
+
import {
|
|
46
|
+
readPaperSettings,
|
|
47
|
+
type PaperSettings,
|
|
48
|
+
type SettingsProblem,
|
|
49
|
+
} from "./paper-settings.ts";
|
|
50
|
+
import { CONFIG_FILE } from "../lib/paper-config.mjs";
|
|
51
|
+
|
|
52
|
+
/** The prefix of a shipped preset's spec. */
|
|
53
|
+
export const SHIPPED_PREFIX = "paperlint:";
|
|
54
|
+
/** The longest chain of presets, the paper's own target included. */
|
|
55
|
+
export const MAX_PRESET_DEPTH = 4;
|
|
56
|
+
|
|
57
|
+
export interface PresetDeps {
|
|
58
|
+
readonly files: Files;
|
|
59
|
+
/** The package's venues directory: the shipped presets and the schema every preset is checked against. */
|
|
60
|
+
readonly venuesDir: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A resolved chain, merged. */
|
|
64
|
+
export interface Preset {
|
|
65
|
+
/** The word for this venue in messages. */
|
|
66
|
+
readonly label: string;
|
|
67
|
+
/** Every file of the chain, root first. */
|
|
68
|
+
readonly chain: readonly string[];
|
|
69
|
+
readonly template: string | null;
|
|
70
|
+
readonly tex: TexRequirements;
|
|
71
|
+
readonly format: VenueFormat;
|
|
72
|
+
readonly rules: Readonly<Record<string, unknown>>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Why a spec does not resolve. */
|
|
76
|
+
export type PresetProblem =
|
|
77
|
+
| { readonly kind: "unsupported"; readonly spec: string }
|
|
78
|
+
| {
|
|
79
|
+
readonly kind: "not-found";
|
|
80
|
+
readonly spec: string;
|
|
81
|
+
readonly file: string;
|
|
82
|
+
readonly shipped: readonly string[];
|
|
83
|
+
}
|
|
84
|
+
| { readonly kind: "cycle"; readonly chain: readonly string[] }
|
|
85
|
+
| { readonly kind: "too-deep"; readonly chain: readonly string[] }
|
|
86
|
+
| { readonly kind: "broken"; readonly file: string; readonly why: string };
|
|
87
|
+
|
|
88
|
+
/** The display label of a spec: the file name without its extension. Pure. */
|
|
89
|
+
export function labelOf(spec: string): string {
|
|
90
|
+
const name = spec.startsWith(SHIPPED_PREFIX)
|
|
91
|
+
? spec.slice(SHIPPED_PREFIX.length)
|
|
92
|
+
: basename(spec);
|
|
93
|
+
return name.slice(0, name.length - extname(name).length) || name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isRelative = (spec: string): boolean =>
|
|
97
|
+
spec.startsWith("./") || spec.startsWith("../");
|
|
98
|
+
|
|
99
|
+
/** A spec → the file it names, or why it names none. Pure. */
|
|
100
|
+
function fileOf(
|
|
101
|
+
spec: string,
|
|
102
|
+
from: string,
|
|
103
|
+
venuesDir: string,
|
|
104
|
+
): Result<string, PresetProblem> {
|
|
105
|
+
if (isRelative(spec)) return ok(resolve(dirname(from), spec));
|
|
106
|
+
if (!spec.startsWith(SHIPPED_PREFIX))
|
|
107
|
+
return err({ kind: "unsupported", spec });
|
|
108
|
+
const file = profileFileOf(spec.slice(SHIPPED_PREFIX.length));
|
|
109
|
+
return file === null
|
|
110
|
+
? err({ kind: "not-found", spec, file: spec, shipped: [] })
|
|
111
|
+
: ok(join(venuesDir, file));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One file of the chain: read and parsed, or why not. */
|
|
115
|
+
function readPreset(
|
|
116
|
+
spec: string,
|
|
117
|
+
from: string,
|
|
118
|
+
deps: PresetDeps,
|
|
119
|
+
): Result<{ file: string; preset: PresetFile }, PresetProblem> {
|
|
120
|
+
const where = fileOf(spec, from, deps.venuesDir);
|
|
121
|
+
if (!where.ok) return where;
|
|
122
|
+
const bytes = deps.files.readBytes(where.value as AbsolutePath);
|
|
123
|
+
if (bytes === null)
|
|
124
|
+
return err({ kind: "not-found", spec, file: where.value, shipped: [] });
|
|
125
|
+
try {
|
|
126
|
+
const text = new TextDecoder().decode(bytes);
|
|
127
|
+
return ok({
|
|
128
|
+
file: where.value,
|
|
129
|
+
preset: parsePreset(text, where.value, deps.venuesDir),
|
|
130
|
+
});
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return err({
|
|
133
|
+
kind: "broken",
|
|
134
|
+
file: where.value,
|
|
135
|
+
why: (e as Error).message.split("\n").join(" "),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The chain's files, leaf first, each parsed. */
|
|
141
|
+
function chainOf(
|
|
142
|
+
spec: string,
|
|
143
|
+
from: string,
|
|
144
|
+
deps: PresetDeps,
|
|
145
|
+
): Result<{ file: string; preset: PresetFile }[], PresetProblem> {
|
|
146
|
+
const out: { file: string; preset: PresetFile }[] = [];
|
|
147
|
+
let next: { spec: string; from: string } | null = { spec, from };
|
|
148
|
+
while (next !== null) {
|
|
149
|
+
const r = readPreset(next.spec, next.from, deps);
|
|
150
|
+
if (!r.ok) return r;
|
|
151
|
+
const files = out.map((x) => x.file);
|
|
152
|
+
if (files.includes(r.value.file))
|
|
153
|
+
return err({ kind: "cycle", chain: [...files, r.value.file] });
|
|
154
|
+
if (out.length === MAX_PRESET_DEPTH)
|
|
155
|
+
return err({ kind: "too-deep", chain: [...files, r.value.file] });
|
|
156
|
+
out.push(r.value);
|
|
157
|
+
const up: string | null = r.value.preset.extends;
|
|
158
|
+
next = up === null ? null : { spec: up, from: r.value.file };
|
|
159
|
+
}
|
|
160
|
+
return ok(out);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A child's format over its parent's: per key the child wins, kinds by name. Pure. */
|
|
164
|
+
export function mergeFormat(
|
|
165
|
+
parent: VenueFormat,
|
|
166
|
+
child: VenueFormat,
|
|
167
|
+
): VenueFormat {
|
|
168
|
+
const keys = Object.keys(NO_FORMAT).filter((k) => k !== "kinds") as Exclude<
|
|
169
|
+
keyof VenueFormat,
|
|
170
|
+
"kinds"
|
|
171
|
+
>[];
|
|
172
|
+
const scalars = Object.fromEntries(
|
|
173
|
+
keys.map((k) => [k, child[k] ?? parent[k]]),
|
|
174
|
+
) as Omit<VenueFormat, "kinds">;
|
|
175
|
+
return { ...scalars, kinds: new Map([...parent.kinds, ...child.kinds]) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The chain, root first, merged into one preset. Pure. */
|
|
179
|
+
function merged(
|
|
180
|
+
spec: string,
|
|
181
|
+
rootFirst: readonly PresetFile[],
|
|
182
|
+
files: readonly string[],
|
|
183
|
+
): Preset {
|
|
184
|
+
const base = {
|
|
185
|
+
name: null as string | null,
|
|
186
|
+
template: null as string | null,
|
|
187
|
+
tex: NO_REQUIREMENTS,
|
|
188
|
+
format: NO_FORMAT,
|
|
189
|
+
rules: {} as Readonly<Record<string, unknown>>,
|
|
190
|
+
};
|
|
191
|
+
const m = rootFirst.reduce(
|
|
192
|
+
(acc, p) => ({
|
|
193
|
+
name: p.name ?? acc.name,
|
|
194
|
+
template: p.template ?? acc.template,
|
|
195
|
+
tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
|
|
196
|
+
format: mergeFormat(acc.format, p.format),
|
|
197
|
+
rules: { ...acc.rules, ...p.rules },
|
|
198
|
+
}),
|
|
199
|
+
base,
|
|
200
|
+
);
|
|
201
|
+
return {
|
|
202
|
+
label: m.name ?? labelOf(spec),
|
|
203
|
+
chain: files,
|
|
204
|
+
template: m.template,
|
|
205
|
+
tex: m.tex,
|
|
206
|
+
format: m.format,
|
|
207
|
+
rules: m.rules,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `spec`, as written in `fromFile` (a paper's `paperlint.json`), → the merged preset, or why it
|
|
213
|
+
* does not resolve. Reads through `deps.files` only; never throws.
|
|
214
|
+
*/
|
|
215
|
+
export function resolvePreset(
|
|
216
|
+
spec: string,
|
|
217
|
+
fromFile: string,
|
|
218
|
+
deps: PresetDeps,
|
|
219
|
+
): Result<Preset, PresetProblem> {
|
|
220
|
+
const chain = chainOf(spec, fromFile, deps);
|
|
221
|
+
if (!chain.ok)
|
|
222
|
+
return chain.error.kind === "not-found"
|
|
223
|
+
? err({ ...chain.error, shipped: shippedPresets(deps.venuesDir) })
|
|
224
|
+
: chain;
|
|
225
|
+
const rootFirst = [...chain.value].reverse();
|
|
226
|
+
return ok(
|
|
227
|
+
merged(
|
|
228
|
+
spec,
|
|
229
|
+
rootFirst.map((x) => x.preset),
|
|
230
|
+
rootFirst.map((x) => x.file),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The shipped presets' names (each is spelled `paperlint:<name>`). */
|
|
236
|
+
export function shippedPresets(venuesDir: string): string[] {
|
|
237
|
+
return venueNames(venuesDir);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One line for a problem, naming what to change. Pure. */
|
|
241
|
+
export function presetProblemText(p: PresetProblem): string {
|
|
242
|
+
switch (p.kind) {
|
|
243
|
+
case "unsupported":
|
|
244
|
+
return `"extends": "${p.spec}" — a preset is \`paperlint:<name>\` (shipped) or a path starting with ./ or ../ (your own); npm presets: not yet supported. Did you mean "${SHIPPED_PREFIX}${p.spec}"?`;
|
|
245
|
+
case "not-found":
|
|
246
|
+
return `"extends": "${p.spec}" names no preset (${p.file}); shipped presets (${SHIPPED_PREFIX}<name>): ${p.shipped.join(", ")}`;
|
|
247
|
+
case "cycle":
|
|
248
|
+
return `the presets extend each other in a cycle: ${p.chain.join(" → ")}`;
|
|
249
|
+
case "too-deep":
|
|
250
|
+
return `the preset chain is longer than ${String(MAX_PRESET_DEPTH)}: ${p.chain.join(" → ")}`;
|
|
251
|
+
case "broken":
|
|
252
|
+
return `the preset ${p.file} does not parse: ${p.why}`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── a paper's preset ────────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/** What one paper declares and the preset it resolves to — one answer for every reader. */
|
|
259
|
+
export type PaperPreset =
|
|
260
|
+
/** No `paperlint.json`, or one without `extends`: no venue checks, the base TeX set. */
|
|
261
|
+
| { readonly kind: "none"; readonly settings: PaperSettings | null }
|
|
262
|
+
| {
|
|
263
|
+
readonly kind: "resolved";
|
|
264
|
+
readonly settings: PaperSettings;
|
|
265
|
+
readonly preset: Preset;
|
|
266
|
+
}
|
|
267
|
+
/** `paperlint.json` does not parse. */
|
|
268
|
+
| { readonly kind: "settings-problem"; readonly problem: SettingsProblem }
|
|
269
|
+
/** `extends` does not resolve. */
|
|
270
|
+
| {
|
|
271
|
+
readonly kind: "preset-problem";
|
|
272
|
+
readonly settings: PaperSettings;
|
|
273
|
+
readonly problem: PresetProblem;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** A paper directory → its settings and its resolved preset. Reads through `deps.files`; never throws. */
|
|
277
|
+
export function paperPreset(paperDir: string, deps: PresetDeps): PaperPreset {
|
|
278
|
+
const read = readPaperSettings(deps.files, paperDir);
|
|
279
|
+
if (!read.ok) return { kind: "settings-problem", problem: read.error };
|
|
280
|
+
const settings = read.value;
|
|
281
|
+
if (settings?.extends == null) return { kind: "none", settings };
|
|
282
|
+
const r = resolvePreset(settings.extends, join(paperDir, CONFIG_FILE), deps);
|
|
283
|
+
return r.ok
|
|
284
|
+
? { kind: "resolved", settings, preset: r.value }
|
|
285
|
+
: { kind: "preset-problem", settings, problem: r.error };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The one-line reason a paper's settings or preset cannot be used, or null when they can. */
|
|
289
|
+
export function paperPresetProblem(
|
|
290
|
+
paperDir: string,
|
|
291
|
+
p: PaperPreset,
|
|
292
|
+
): string | null {
|
|
293
|
+
if (p.kind === "preset-problem")
|
|
294
|
+
return `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`;
|
|
295
|
+
if (p.kind !== "settings-problem") return null;
|
|
296
|
+
return `${join(paperDir, CONFIG_FILE)}: ${p.problem.why}`;
|
|
297
|
+
}
|
|
@@ -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;
|