paperlint 2.0.0 → 2.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/.github/workflows/ci.yml +4 -4
- package/CLAUDE.md +3 -3
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +1 -1
- package/README.md +81 -24
- package/action.harness.mjs +10 -10
- package/action.mutations.mjs +3 -3
- package/action.yml +11 -11
- package/bin/{rpp.mjs → paperlint.mjs} +2 -2
- package/dist/adapters/banal/invocation.js +3 -3
- package/dist/adapters/banal/invocation.js.map +1 -1
- package/dist/adapters/banal/run.d.ts +1 -1
- package/dist/adapters/banal/run.js +1 -1
- package/dist/adapters/banal/run.js.map +1 -1
- package/dist/adapters/banal/settings.d.ts +4 -4
- package/dist/adapters/banal/settings.d.ts.map +1 -1
- package/dist/adapters/banal/settings.js +4 -4
- package/dist/adapters/banal/settings.js.map +1 -1
- package/dist/adapters/banal/xml.js +1 -1
- package/dist/adapters/banal/xml.js.map +1 -1
- package/dist/adapters/curl/download.io.d.ts.map +1 -1
- package/dist/adapters/curl/download.io.js +2 -2
- package/dist/adapters/curl/download.io.js.map +1 -1
- package/dist/build-engine.d.ts +1 -1
- package/dist/build-engine.d.ts.map +1 -1
- package/dist/build-engine.js +6 -3
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +5 -4
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +29 -30
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +174 -98
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +21 -6
- package/dist/doctor.js.map +1 -1
- package/dist/engine.d.ts +2 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +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 -18
- package/dist/hooks-settings.d.ts.map +1 -1
- package/dist/hooks-settings.js +70 -42
- package/dist/hooks-settings.js.map +1 -1
- package/dist/init.d.ts +13 -10
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +74 -42
- package/dist/init.js.map +1 -1
- package/dist/link-skills.js +2 -2
- 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 +17 -2
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-settings.d.ts +68 -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/presets.d.ts +84 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +186 -0
- package/dist/presets.js.map +1 -0
- package/dist/rules-config.d.ts +8 -1
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js +13 -8
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.d.ts.map +1 -1
- package/dist/structure.js +2 -1
- 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/toolchain.d.ts +2 -2
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +8 -8
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +4 -4
- 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 +387 -0
- package/dist/venue-rules.js.map +1 -0
- package/docs/configuration.md +73 -17
- package/docs/e2e.md +3 -4
- package/docs/install.md +11 -3
- package/docs/optional-rules.md +26 -25
- package/docs/rules.md +132 -3
- package/docs/toolchain.md +5 -5
- package/eslint-rules/paper-typography.mjs +1 -1
- package/eslint-rules/paper-typography.mutations.mjs +1 -1
- package/eslint-rules/papers.mjs +6 -2
- package/eslint-rules/pdf-last-page-balance.harness.mjs +5 -3
- package/eslint-rules/pdf-last-page-balance.mjs +23 -2
- package/fixtures/build-e2e/acmart/paperlint.json +1 -0
- package/fixtures/build-e2e/guards/paper.tex +1 -1
- package/fixtures/pdf-facts/README.md +1 -1
- package/fixtures/real-markdown-paper/baseline.json +1 -1
- package/fixtures/real-markdown-paper/baseline.mjs +2 -2
- package/fixtures/toolchain-mirror/install-tl +2 -2
- package/fixtures/toolchain-mirror/stub-pdflatex +1 -1
- package/fixtures/toolchain-mirror/stub-tlmgr +1 -1
- package/hooks/hooks.harness.mjs +1 -1
- package/lib/paper-config.d.mts +7 -0
- package/lib/paper-config.harness.mjs +3 -3
- package/lib/paper-config.mjs +34 -3
- package/lib/skill-trigger-cases.harness.mjs +1 -1
- package/package.json +4 -4
- package/plugin/hooks/hooks.json +3 -3
- package/scripts/check.mjs +1 -1
- package/scripts/harness-api.frozen.json +1 -1
- package/scripts/harness-api.test.ts +2 -2
- package/scripts/layer-legacy.frozen.json +1 -1
- package/scripts/mutation-batteries-frozen.harness.mjs +1 -1
- package/scripts/mutation-batteries.frozen.json +2 -2
- package/scripts/release-config.test.ts +1 -1
- package/skills/find-venue/SKILL.md +5 -1
- package/skills/find-venue/SKILL.md.spec.ts +4 -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/description-language.eval.mjs +1 -1
- package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +1 -1
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +2 -2
- package/skills/paper-pipeline/pipeline-firing.eval.mjs +1 -1
- package/skills/paper-pipeline/pipeline-language.eval.mjs +1 -1
- package/skills/paper-pipeline/scripts/consumer.d.mts +1 -0
- package/skills/paper-pipeline/scripts/consumer.harness.mjs +10 -4
- package/skills/paper-pipeline/scripts/consumer.mjs +8 -1
- package/skills/paper-pipeline/scripts/consumer.mutations.mjs +1 -1
- package/skills/render-paper/SKILL.md +3 -3
- package/skills/render-paper/SKILL.md.spec.ts +2 -2
- package/skills/render-paper/extract-pdf-facts.harness.mjs +6 -6
- package/skills/render-paper/extract-pdf-facts.mjs +4 -4
- 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 +120 -30
- package/src/adapters/banal/index.test.ts +1 -1
- package/src/adapters/banal/invocation.test.ts +3 -3
- package/src/adapters/banal/invocation.ts +3 -3
- package/src/adapters/banal/locate.test.ts +4 -4
- package/src/adapters/banal/run.ts +2 -2
- package/src/adapters/banal/settings.test.ts +12 -6
- package/src/adapters/banal/settings.ts +9 -5
- package/src/adapters/banal/xml.test.ts +1 -1
- package/src/adapters/banal/xml.ts +1 -1
- package/src/adapters/curl/download.io.ts +4 -2
- package/src/adapters/curl/download.test.ts +3 -1
- package/src/adapters/node/files.test.ts +1 -1
- package/src/adapters/node/process.test.ts +2 -2
- package/src/adapters/node/workspace.test.ts +4 -4
- package/src/build-engine.harness.mjs +2 -2
- package/src/build-engine.ts +11 -3
- package/src/build.harness.mjs +27 -22
- package/src/build.mutations.mjs +3 -2
- package/src/build.ts +6 -5
- package/src/cli.harness.mjs +72 -131
- package/src/cli.mutations.mjs +7 -32
- package/src/cli.ts +204 -121
- package/src/doctor.harness.mjs +9 -25
- package/src/doctor.ts +28 -5
- package/src/engine.harness.mjs +4 -2
- package/src/engine.ts +2 -2
- package/src/facts-file.test.ts +54 -7
- package/src/facts-file.ts +145 -24
- package/src/hooks-settings.harness.mjs +24 -15
- package/src/hooks-settings.mutations.mjs +2 -4
- package/src/hooks-settings.ts +92 -49
- package/src/init.ts +88 -52
- package/src/latex-log.harness.mjs +1 -1
- package/src/link-skills.harness.mjs +3 -1
- package/src/link-skills.mutations.mjs +1 -1
- package/src/link-skills.ts +2 -2
- package/src/new-paper.harness.mjs +10 -7
- package/src/new-paper.test.ts +145 -0
- package/src/new-paper.ts +22 -2
- package/src/paper-settings-commands.test.ts +311 -0
- package/src/paper-settings.test.ts +220 -0
- package/src/paper-settings.ts +206 -0
- package/src/pdf-facts.harness.mjs +1 -1
- package/src/presets.test.ts +236 -0
- package/src/presets.ts +307 -0
- package/src/rules-config.ts +14 -9
- package/src/structure.harness.mjs +4 -4
- package/src/structure.ts +2 -1
- package/src/tex-requirements.harness.mjs +12 -18
- package/src/tex-requirements.ts +179 -29
- package/src/toolchain.harness.mjs +34 -25
- package/src/toolchain.ts +14 -8
- package/src/types.ts +4 -4
- package/src/venue-rules.test.ts +545 -0
- package/src/venue-rules.ts +573 -0
- package/templates/paper/paperlint.json +4 -0
- package/fixtures/build-e2e/acmart/venue.json +0 -1
- package/plugin/.claude-plugin/plugin.json +0 -8
package/src/presets.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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 {
|
|
51
|
+
LEGACY_PAPER_SETTINGS_FILE,
|
|
52
|
+
LEGACY_PAPER_SETTINGS_MESSAGE,
|
|
53
|
+
PAPER_SETTINGS_FILE,
|
|
54
|
+
} from "../lib/paper-config.mjs";
|
|
55
|
+
|
|
56
|
+
/** The prefix of a shipped preset's spec. */
|
|
57
|
+
export const SHIPPED_PREFIX = "paperlint:";
|
|
58
|
+
/** The longest chain of presets, the paper's own target included. */
|
|
59
|
+
export const MAX_PRESET_DEPTH = 4;
|
|
60
|
+
|
|
61
|
+
export interface PresetDeps {
|
|
62
|
+
readonly files: Files;
|
|
63
|
+
/** The package's venues directory: the shipped presets and the schema every preset is checked against. */
|
|
64
|
+
readonly venuesDir: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A resolved chain, merged. */
|
|
68
|
+
export interface Preset {
|
|
69
|
+
/** The word for this venue in messages. */
|
|
70
|
+
readonly label: string;
|
|
71
|
+
/** Every file of the chain, root first. */
|
|
72
|
+
readonly chain: readonly string[];
|
|
73
|
+
readonly template: string | null;
|
|
74
|
+
readonly tex: TexRequirements;
|
|
75
|
+
readonly format: VenueFormat;
|
|
76
|
+
readonly rules: Readonly<Record<string, unknown>>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Why a spec does not resolve. */
|
|
80
|
+
export type PresetProblem =
|
|
81
|
+
| { readonly kind: "unsupported"; readonly spec: string }
|
|
82
|
+
| {
|
|
83
|
+
readonly kind: "not-found";
|
|
84
|
+
readonly spec: string;
|
|
85
|
+
readonly file: string;
|
|
86
|
+
readonly shipped: readonly string[];
|
|
87
|
+
}
|
|
88
|
+
| { readonly kind: "cycle"; readonly chain: readonly string[] }
|
|
89
|
+
| { readonly kind: "too-deep"; readonly chain: readonly string[] }
|
|
90
|
+
| { readonly kind: "broken"; readonly file: string; readonly why: string };
|
|
91
|
+
|
|
92
|
+
/** The display label of a spec: the file name without its extension. Pure. */
|
|
93
|
+
export function labelOf(spec: string): string {
|
|
94
|
+
const name = spec.startsWith(SHIPPED_PREFIX)
|
|
95
|
+
? spec.slice(SHIPPED_PREFIX.length)
|
|
96
|
+
: basename(spec);
|
|
97
|
+
return name.slice(0, name.length - extname(name).length) || name;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const isRelative = (spec: string): boolean =>
|
|
101
|
+
spec.startsWith("./") || spec.startsWith("../");
|
|
102
|
+
|
|
103
|
+
/** A spec → the file it names, or why it names none. Pure. */
|
|
104
|
+
function fileOf(
|
|
105
|
+
spec: string,
|
|
106
|
+
from: string,
|
|
107
|
+
venuesDir: string,
|
|
108
|
+
): Result<string, PresetProblem> {
|
|
109
|
+
if (isRelative(spec)) return ok(resolve(dirname(from), spec));
|
|
110
|
+
if (!spec.startsWith(SHIPPED_PREFIX))
|
|
111
|
+
return err({ kind: "unsupported", spec });
|
|
112
|
+
const file = profileFileOf(spec.slice(SHIPPED_PREFIX.length));
|
|
113
|
+
return file === null
|
|
114
|
+
? err({ kind: "not-found", spec, file: spec, shipped: [] })
|
|
115
|
+
: ok(join(venuesDir, file));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One file of the chain: read and parsed, or why not. */
|
|
119
|
+
function readPreset(
|
|
120
|
+
spec: string,
|
|
121
|
+
from: string,
|
|
122
|
+
deps: PresetDeps,
|
|
123
|
+
): Result<{ file: string; preset: PresetFile }, PresetProblem> {
|
|
124
|
+
const where = fileOf(spec, from, deps.venuesDir);
|
|
125
|
+
if (!where.ok) return where;
|
|
126
|
+
const bytes = deps.files.readBytes(where.value as AbsolutePath);
|
|
127
|
+
if (bytes === null)
|
|
128
|
+
return err({ kind: "not-found", spec, file: where.value, shipped: [] });
|
|
129
|
+
try {
|
|
130
|
+
const text = new TextDecoder().decode(bytes);
|
|
131
|
+
return ok({
|
|
132
|
+
file: where.value,
|
|
133
|
+
preset: parsePreset(text, where.value, deps.venuesDir),
|
|
134
|
+
});
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return err({
|
|
137
|
+
kind: "broken",
|
|
138
|
+
file: where.value,
|
|
139
|
+
why: (e as Error).message.split("\n").join(" "),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The chain's files, leaf first, each parsed. */
|
|
145
|
+
function chainOf(
|
|
146
|
+
spec: string,
|
|
147
|
+
from: string,
|
|
148
|
+
deps: PresetDeps,
|
|
149
|
+
): Result<{ file: string; preset: PresetFile }[], PresetProblem> {
|
|
150
|
+
const out: { file: string; preset: PresetFile }[] = [];
|
|
151
|
+
let next: { spec: string; from: string } | null = { spec, from };
|
|
152
|
+
while (next !== null) {
|
|
153
|
+
const r = readPreset(next.spec, next.from, deps);
|
|
154
|
+
if (!r.ok) return r;
|
|
155
|
+
const files = out.map((x) => x.file);
|
|
156
|
+
if (files.includes(r.value.file))
|
|
157
|
+
return err({ kind: "cycle", chain: [...files, r.value.file] });
|
|
158
|
+
if (out.length === MAX_PRESET_DEPTH)
|
|
159
|
+
return err({ kind: "too-deep", chain: [...files, r.value.file] });
|
|
160
|
+
out.push(r.value);
|
|
161
|
+
const up: string | null = r.value.preset.extends;
|
|
162
|
+
next = up === null ? null : { spec: up, from: r.value.file };
|
|
163
|
+
}
|
|
164
|
+
return ok(out);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A child's format over its parent's: per key the child wins, kinds by name. Pure. */
|
|
168
|
+
export function mergeFormat(
|
|
169
|
+
parent: VenueFormat,
|
|
170
|
+
child: VenueFormat,
|
|
171
|
+
): VenueFormat {
|
|
172
|
+
const keys = Object.keys(NO_FORMAT).filter((k) => k !== "kinds") as Exclude<
|
|
173
|
+
keyof VenueFormat,
|
|
174
|
+
"kinds"
|
|
175
|
+
>[];
|
|
176
|
+
const scalars = Object.fromEntries(
|
|
177
|
+
keys.map((k) => [k, child[k] ?? parent[k]]),
|
|
178
|
+
) as Omit<VenueFormat, "kinds">;
|
|
179
|
+
return { ...scalars, kinds: new Map([...parent.kinds, ...child.kinds]) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The chain, root first, merged into one preset. Pure. */
|
|
183
|
+
function merged(
|
|
184
|
+
spec: string,
|
|
185
|
+
rootFirst: readonly PresetFile[],
|
|
186
|
+
files: readonly string[],
|
|
187
|
+
): Preset {
|
|
188
|
+
const base = {
|
|
189
|
+
name: null as string | null,
|
|
190
|
+
template: null as string | null,
|
|
191
|
+
tex: NO_REQUIREMENTS,
|
|
192
|
+
format: NO_FORMAT,
|
|
193
|
+
rules: {} as Readonly<Record<string, unknown>>,
|
|
194
|
+
};
|
|
195
|
+
const m = rootFirst.reduce(
|
|
196
|
+
(acc, p) => ({
|
|
197
|
+
name: p.name ?? acc.name,
|
|
198
|
+
template: p.template ?? acc.template,
|
|
199
|
+
tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
|
|
200
|
+
format: mergeFormat(acc.format, p.format),
|
|
201
|
+
rules: { ...acc.rules, ...p.rules },
|
|
202
|
+
}),
|
|
203
|
+
base,
|
|
204
|
+
);
|
|
205
|
+
return {
|
|
206
|
+
label: m.name ?? labelOf(spec),
|
|
207
|
+
chain: files,
|
|
208
|
+
template: m.template,
|
|
209
|
+
tex: m.tex,
|
|
210
|
+
format: m.format,
|
|
211
|
+
rules: m.rules,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `spec`, as written in `fromFile` (a paper's `paperlint.json`), → the merged preset, or why it
|
|
217
|
+
* does not resolve. Reads through `deps.files` only; never throws.
|
|
218
|
+
*/
|
|
219
|
+
export function resolvePreset(
|
|
220
|
+
spec: string,
|
|
221
|
+
fromFile: string,
|
|
222
|
+
deps: PresetDeps,
|
|
223
|
+
): Result<Preset, PresetProblem> {
|
|
224
|
+
const chain = chainOf(spec, fromFile, deps);
|
|
225
|
+
if (!chain.ok)
|
|
226
|
+
return chain.error.kind === "not-found"
|
|
227
|
+
? err({ ...chain.error, shipped: shippedPresets(deps.venuesDir) })
|
|
228
|
+
: chain;
|
|
229
|
+
const rootFirst = [...chain.value].reverse();
|
|
230
|
+
return ok(
|
|
231
|
+
merged(
|
|
232
|
+
spec,
|
|
233
|
+
rootFirst.map((x) => x.preset),
|
|
234
|
+
rootFirst.map((x) => x.file),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The shipped presets' names (each is spelled `paperlint:<name>`). */
|
|
240
|
+
export function shippedPresets(venuesDir: string): string[] {
|
|
241
|
+
return venueNames(venuesDir);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One line for a problem, naming what to change. Pure. */
|
|
245
|
+
export function presetProblemText(p: PresetProblem): string {
|
|
246
|
+
switch (p.kind) {
|
|
247
|
+
case "unsupported":
|
|
248
|
+
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}"?`;
|
|
249
|
+
case "not-found":
|
|
250
|
+
return `"extends": "${p.spec}" names no preset (${p.file}); shipped presets (${SHIPPED_PREFIX}<name>): ${p.shipped.join(", ")}`;
|
|
251
|
+
case "cycle":
|
|
252
|
+
return `the presets extend each other in a cycle: ${p.chain.join(" → ")}`;
|
|
253
|
+
case "too-deep":
|
|
254
|
+
return `the preset chain is longer than ${String(MAX_PRESET_DEPTH)}: ${p.chain.join(" → ")}`;
|
|
255
|
+
case "broken":
|
|
256
|
+
return `the preset ${p.file} does not parse: ${p.why}`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── a paper's preset ────────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/** What one paper declares and the preset it resolves to — one answer for every reader. */
|
|
263
|
+
export type PaperPreset =
|
|
264
|
+
/** No `paperlint.json`, or one without `extends`: no venue checks, the base TeX set. */
|
|
265
|
+
| { readonly kind: "none"; readonly settings: PaperSettings | null }
|
|
266
|
+
| {
|
|
267
|
+
readonly kind: "resolved";
|
|
268
|
+
readonly settings: PaperSettings;
|
|
269
|
+
readonly preset: Preset;
|
|
270
|
+
}
|
|
271
|
+
/** `paperlint.json` does not parse, or only a pre-2.1.0 `venue.json` is there. */
|
|
272
|
+
| { readonly kind: "settings-problem"; readonly problem: SettingsProblem }
|
|
273
|
+
/** `extends` does not resolve. */
|
|
274
|
+
| {
|
|
275
|
+
readonly kind: "preset-problem";
|
|
276
|
+
readonly settings: PaperSettings;
|
|
277
|
+
readonly problem: PresetProblem;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** A paper directory → its settings and its resolved preset. Reads through `deps.files`; never throws. */
|
|
281
|
+
export function paperPreset(paperDir: string, deps: PresetDeps): PaperPreset {
|
|
282
|
+
const read = readPaperSettings(deps.files, paperDir);
|
|
283
|
+
if (!read.ok) return { kind: "settings-problem", problem: read.error };
|
|
284
|
+
const settings = read.value;
|
|
285
|
+
if (settings?.extends == null) return { kind: "none", settings };
|
|
286
|
+
const r = resolvePreset(
|
|
287
|
+
settings.extends,
|
|
288
|
+
join(paperDir, PAPER_SETTINGS_FILE),
|
|
289
|
+
deps,
|
|
290
|
+
);
|
|
291
|
+
return r.ok
|
|
292
|
+
? { kind: "resolved", settings, preset: r.value }
|
|
293
|
+
: { kind: "preset-problem", settings, problem: r.error };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The one-line reason a paper's settings or preset cannot be used, or null when they can. */
|
|
297
|
+
export function paperPresetProblem(
|
|
298
|
+
paperDir: string,
|
|
299
|
+
p: PaperPreset,
|
|
300
|
+
): string | null {
|
|
301
|
+
if (p.kind === "preset-problem")
|
|
302
|
+
return `${join(paperDir, PAPER_SETTINGS_FILE)}: ${presetProblemText(p.problem)}`;
|
|
303
|
+
if (p.kind !== "settings-problem") return null;
|
|
304
|
+
return p.problem.kind === "legacy"
|
|
305
|
+
? `${join(paperDir, LEGACY_PAPER_SETTINGS_FILE)}: ${LEGACY_PAPER_SETTINGS_MESSAGE}`
|
|
306
|
+
: `${join(paperDir, PAPER_SETTINGS_FILE)}: ${p.problem.why}`;
|
|
307
|
+
}
|
package/src/rules-config.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* "rules": [ { "files": ["papers/agenticdev-2026/**"],
|
|
10
10
|
* "rules": { "pdf/last-page-balance": ["error", { "tolerancePt": 120 }] } } ]
|
|
11
11
|
*
|
|
12
|
-
* The blocks are appended AFTER
|
|
12
|
+
* The blocks are appended AFTER paperlint's built-in config, so a later block wins, exactly as in ESLint.
|
|
13
13
|
* `files` and `ignores` are resolved relative to the directory of the file that holds the settings,
|
|
14
14
|
* as ESLint resolves them relative to its config file: each block gets that directory as ESLint's
|
|
15
15
|
* own `basePath`, so paperlint matches nothing itself.
|
|
@@ -65,24 +65,29 @@ function parseEntry(v: unknown): RuleEntry | null {
|
|
|
65
65
|
return v as RuleEntry;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* A `rules` object — rule id → severity or `[severity, ...options]` — with only the rules paperlint
|
|
70
|
+
* ships. Read from the package.json blocks and from a paper's `paperlint.json`.
|
|
71
|
+
*
|
|
72
|
+
* @param where the key path to name in messages, e.g. `package.json → "paperlint".rules[0].rules`
|
|
73
|
+
*/
|
|
74
|
+
export function parseRuleEntries(
|
|
70
75
|
v: unknown,
|
|
71
|
-
|
|
76
|
+
where: string,
|
|
72
77
|
shipped: ReadonlySet<string>,
|
|
73
78
|
): Parsed<Record<string, RuleEntry>> {
|
|
74
79
|
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
75
|
-
return bad(`${
|
|
80
|
+
return bad(`${where} must be an object of rule id → severity`);
|
|
76
81
|
const out: Record<string, RuleEntry> = {};
|
|
77
82
|
for (const [id, raw] of Object.entries(v)) {
|
|
78
83
|
if (!shipped.has(id))
|
|
79
84
|
return bad(
|
|
80
|
-
`${
|
|
85
|
+
`${where}: "${id}" is not a rule paperlint ships — known: ${[...shipped].sort().join(", ")}`,
|
|
81
86
|
);
|
|
82
87
|
const entry = parseEntry(raw);
|
|
83
88
|
if (entry === null)
|
|
84
89
|
return bad(
|
|
85
|
-
`${
|
|
90
|
+
`${where}["${id}"]: ${JSON.stringify(raw)} is not a severity — use "off", "warn" or "error", or ["error", { options }]`,
|
|
86
91
|
);
|
|
87
92
|
out[id] = entry;
|
|
88
93
|
}
|
|
@@ -121,7 +126,7 @@ function parseBlock(
|
|
|
121
126
|
);
|
|
122
127
|
const globs = badGlobs(o, at);
|
|
123
128
|
if (globs) return bad(globs);
|
|
124
|
-
const rules =
|
|
129
|
+
const rules = parseRuleEntries(o["rules"], `${at}.rules`, ctx.shipped);
|
|
125
130
|
if (!rules.ok) return rules;
|
|
126
131
|
return {
|
|
127
132
|
ok: true,
|
|
@@ -159,7 +164,7 @@ export function parseRuleBlocks(
|
|
|
159
164
|
|
|
160
165
|
/**
|
|
161
166
|
* The rule ids a config defines through its own plugins, as `<plugin>/<rule>`. Plugins passed in
|
|
162
|
-
* `foreign` (a dependency's plugin, such as `@eslint/markdown`) are not
|
|
167
|
+
* `foreign` (a dependency's plugin, such as `@eslint/markdown`) are not paperlint's and are skipped.
|
|
163
168
|
*/
|
|
164
169
|
export function shippedRuleIds(
|
|
165
170
|
config: readonly unknown[],
|
|
@@ -28,7 +28,7 @@ const check = (label, cond) => {
|
|
|
28
28
|
n++;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
const root = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
31
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-struct-")));
|
|
32
32
|
const papers = join(root, "papers");
|
|
33
33
|
const paper = (name, files) => {
|
|
34
34
|
const dir = join(papers, name);
|
|
@@ -132,9 +132,9 @@ try {
|
|
|
132
132
|
!STRUCTURE_DEFAULTS.require.includes("paper.pdf"),
|
|
133
133
|
);
|
|
134
134
|
check(
|
|
135
|
-
"and `
|
|
136
|
-
STRUCTURE_DEFAULTS.markers.includes("
|
|
137
|
-
!STRUCTURE_DEFAULTS.require.includes("
|
|
135
|
+
"and `paperlint.json` counts as a marker but not a requirement",
|
|
136
|
+
STRUCTURE_DEFAULTS.markers.includes("paperlint.json") &&
|
|
137
|
+
!STRUCTURE_DEFAULTS.require.includes("paperlint.json"),
|
|
138
138
|
);
|
|
139
139
|
|
|
140
140
|
// ── one schema for both halves ────────────────────────────────────────────────────────
|
package/src/structure.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { readdirSync, existsSync } from "node:fs";
|
|
26
26
|
import { join, relative, basename, isAbsolute, sep } from "node:path";
|
|
27
27
|
import type { StructureConfig, StructureFinding } from "./types.ts";
|
|
28
|
+
import { PAPER_SETTINGS_FILE } from "../lib/paper-config.mjs";
|
|
28
29
|
|
|
29
30
|
/** The requirements after the consumer's config is laid over the defaults. */
|
|
30
31
|
type Rules = Required<StructureConfig>;
|
|
@@ -37,7 +38,7 @@ type Rules = Required<StructureConfig>;
|
|
|
37
38
|
* byte-compared by the `paper/stages` rule anyway.
|
|
38
39
|
*/
|
|
39
40
|
export const STRUCTURE_DEFAULTS = {
|
|
40
|
-
markers: ["PIPELINE-STATUS.md", "paper.tex", "paper.md",
|
|
41
|
+
markers: ["PIPELINE-STATUS.md", "paper.tex", "paper.md", PAPER_SETTINGS_FILE],
|
|
41
42
|
require: ["PIPELINE-STATUS.md"],
|
|
42
43
|
requireOneOf: [["paper.tex", "paper.md"]],
|
|
43
44
|
ignore: [],
|
|
@@ -103,7 +103,9 @@ for (const v of acm) {
|
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
105
|
// Venues on ONE template must not drift apart: a second list is how the old installer's three
|
|
106
|
-
// dictionaries diverged.
|
|
106
|
+
// dictionaries diverged. Since the ACM venues extend the `acm-sigconf` family, only the family
|
|
107
|
+
// declares the template's packages, so this holds by construction; the check stays for any two
|
|
108
|
+
// standalone presets that name the same template.
|
|
107
109
|
const byTemplate = new Map();
|
|
108
110
|
for (const v of venues) {
|
|
109
111
|
const t = templateOf(v);
|
|
@@ -116,7 +118,7 @@ for (const [t, vs] of byTemplate)
|
|
|
116
118
|
);
|
|
117
119
|
|
|
118
120
|
// ── 2. the schema rejects ───────────────────────────────────────────────────────────────
|
|
119
|
-
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
121
|
+
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-texreq-h-")));
|
|
120
122
|
copyFileSync(join(VENUES, R.SCHEMA_FILE), join(tmp, R.SCHEMA_FILE));
|
|
121
123
|
const bad = (text) => throws(() => R.parseProfile(text, "bad.jsonc", tmp));
|
|
122
124
|
const BAD = [
|
|
@@ -173,30 +175,22 @@ rmSync(tmp, { recursive: true, force: true });
|
|
|
173
175
|
const r = R.requirementsFor(null, VENUES);
|
|
174
176
|
// Guards: saying WHY a paper runs on the base set.
|
|
175
177
|
check(
|
|
176
|
-
"no venue
|
|
177
|
-
r.source.includes("no venue.json") &&
|
|
178
|
+
"no venue preset → the base set, and the source says so",
|
|
179
|
+
r.source.includes("no venue preset in paperlint.json") &&
|
|
178
180
|
"hyperref" in r.tex.packages &&
|
|
179
181
|
!("acmart" in r.tex.packages),
|
|
180
182
|
r.source,
|
|
181
183
|
);
|
|
182
184
|
}
|
|
185
|
+
// Which preset a paper resolves to — a typo, the base file, a missing one — is src/presets.ts's
|
|
186
|
+
// question now, tested in src/presets.test.ts; this function only adds a resolved preset's packages.
|
|
183
187
|
{
|
|
184
|
-
const r = R.requirementsFor(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
r.source.includes("venue nowhere has no profile") &&
|
|
188
|
-
!("acmart" in r.tex.packages),
|
|
189
|
-
r.source,
|
|
188
|
+
const r = R.requirementsFor(
|
|
189
|
+
{ label: acm[0], tex: read(`${acm[0]}.jsonc`) },
|
|
190
|
+
VENUES,
|
|
190
191
|
);
|
|
191
|
-
}
|
|
192
|
-
check(
|
|
193
|
-
"the base file is not a venue: `tex-base` gets the base set with the reason",
|
|
194
|
-
R.requirementsFor("tex-base", VENUES).source.includes("has no profile"),
|
|
195
|
-
);
|
|
196
|
-
{
|
|
197
|
-
const r = R.requirementsFor(acm[0], VENUES);
|
|
198
192
|
check(
|
|
199
|
-
"a
|
|
193
|
+
"a resolved preset → its packages ON TOP OF the base set, and the source names its label",
|
|
200
194
|
r.source === `venue ${acm[0]}` &&
|
|
201
195
|
"acmart" in r.tex.packages &&
|
|
202
196
|
"hyperref" in r.tex.packages &&
|