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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<paper>/paperlint.json` — ONE PAPER'S SETTINGS, parsed at the boundary.
|
|
3
|
+
*
|
|
4
|
+
* The middle of three levels, each named after the tool (lib/paper-config.mjs):
|
|
5
|
+
*
|
|
6
|
+
* package.json → "paperlint" the project
|
|
7
|
+
* <paper>/paperlint.json this paper: { extends, kind, pdf, rules }
|
|
8
|
+
* a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
|
|
9
|
+
*
|
|
10
|
+
* Every reader — the build, the facts writer, the venue rules, `paperlint lint` — goes through
|
|
11
|
+
* `readPaperSettings`, so there is one answer to "what does this paper declare".
|
|
12
|
+
*
|
|
13
|
+
* 🔴 STRICT. An unknown key is an error naming the known ones: `"venu": "aisec"` would otherwise
|
|
14
|
+
* read as "no preset", and every venue check would be silently off.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 THE OLD NAME IS NOT READ. Before 2.1.0 the file was `venue.json`. A fallback would keep it
|
|
17
|
+
* working forever and leave two names for one file; instead a `venue.json` with no `paperlint.json`
|
|
18
|
+
* is an error that names `npx paperlint init`, which moves it (`migrationOf` plans that move).
|
|
19
|
+
*/
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
LEGACY_PAPER_SETTINGS_FILE,
|
|
23
|
+
PAPER_SETTINGS_FILE,
|
|
24
|
+
PAPER_SETTINGS_KEYS,
|
|
25
|
+
} from "../lib/paper-config.mjs";
|
|
26
|
+
import type { AbsolutePath } from "./domain/paths.ts";
|
|
27
|
+
import { err, ok, type Result } from "./domain/result.ts";
|
|
28
|
+
import type { Files } from "./ports/files.ts";
|
|
29
|
+
import {
|
|
30
|
+
parseRuleEntries,
|
|
31
|
+
type Parsed,
|
|
32
|
+
type RuleEntry,
|
|
33
|
+
} from "./rules-config.ts";
|
|
34
|
+
|
|
35
|
+
/** What one paper declares. Every absent field is null. */
|
|
36
|
+
export interface PaperSettings {
|
|
37
|
+
/** The venue preset the paper is judged against: `paperlint:<name>` or a relative path. */
|
|
38
|
+
readonly extends: string | null;
|
|
39
|
+
/** The kind of paper (`short`, `research`, …), whose page limit applies. */
|
|
40
|
+
readonly kind: string | null;
|
|
41
|
+
/** The built PDF relative to the paper, when it is not `paper.pdf`. */
|
|
42
|
+
readonly pdf: string | null;
|
|
43
|
+
/** Rule overrides for this paper alone, NOT yet checked against the shipped rules. */
|
|
44
|
+
readonly rules: Readonly<Record<string, unknown>> | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Why a paper's settings cannot be read. */
|
|
48
|
+
export type SettingsProblem =
|
|
49
|
+
| { readonly kind: "broken"; readonly why: string }
|
|
50
|
+
| { readonly kind: "legacy" };
|
|
51
|
+
|
|
52
|
+
const KNOWN = Object.keys(PAPER_SETTINGS_KEYS);
|
|
53
|
+
const STRING_FIELDS = ["extends", "kind", "pdf"] as const;
|
|
54
|
+
|
|
55
|
+
const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
|
|
56
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
57
|
+
|
|
58
|
+
/** A string field: absent is null, anything but a non-empty string is the error. */
|
|
59
|
+
function stringField(
|
|
60
|
+
d: Readonly<Record<string, unknown>>,
|
|
61
|
+
k: (typeof STRING_FIELDS)[number],
|
|
62
|
+
): Result<string | null, string> {
|
|
63
|
+
const v = d[k];
|
|
64
|
+
// `"extends": null` is what `paperlint new` writes: no venue chosen yet.
|
|
65
|
+
if (v === undefined || (k === "extends" && v === null)) return ok(null);
|
|
66
|
+
return typeof v === "string" && v !== ""
|
|
67
|
+
? ok(v)
|
|
68
|
+
: err(`"${k}" must be a non-empty string, got ${JSON.stringify(v)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `extends`, `kind` and `pdf`, each null when absent. */
|
|
72
|
+
function stringFields(
|
|
73
|
+
d: Readonly<Record<string, unknown>>,
|
|
74
|
+
): Result<Pick<PaperSettings, (typeof STRING_FIELDS)[number]>, string> {
|
|
75
|
+
const out: Record<string, string | null> = {};
|
|
76
|
+
for (const k of STRING_FIELDS) {
|
|
77
|
+
const f = stringField(d, k);
|
|
78
|
+
if (!f.ok) return f;
|
|
79
|
+
out[k] = f.value;
|
|
80
|
+
}
|
|
81
|
+
return ok({
|
|
82
|
+
extends: out["extends"] ?? null,
|
|
83
|
+
kind: out["kind"] ?? null,
|
|
84
|
+
pdf: out["pdf"] ?? null,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
|
|
89
|
+
export function parsePaperSettings(
|
|
90
|
+
json: unknown,
|
|
91
|
+
): Result<PaperSettings, string> {
|
|
92
|
+
if (!isObject(json)) return err("must be a JSON object");
|
|
93
|
+
const unknown = Object.keys(json).filter((k) => !KNOWN.includes(k));
|
|
94
|
+
if (unknown.length > 0)
|
|
95
|
+
return err(`unknown key "${unknown[0]}" — known keys: ${KNOWN.join(", ")}`);
|
|
96
|
+
const fields = stringFields(json);
|
|
97
|
+
if (!fields.ok) return fields;
|
|
98
|
+
const rules = json["rules"];
|
|
99
|
+
if (rules !== undefined && !isObject(rules))
|
|
100
|
+
return err(`"rules" must be an object of rule id → severity`);
|
|
101
|
+
return ok({
|
|
102
|
+
...fields.value,
|
|
103
|
+
rules: rules ?? null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const at = (p: string): AbsolutePath => p as AbsolutePath;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A paper's settings: null when it has no `paperlint.json`; the problem when the file does not parse
|
|
111
|
+
* or only the pre-2.1.0 `venue.json` is there. Reads through `files` only; never throws.
|
|
112
|
+
*/
|
|
113
|
+
export function readPaperSettings(
|
|
114
|
+
files: Files,
|
|
115
|
+
paperDir: string,
|
|
116
|
+
): Result<PaperSettings | null, SettingsProblem> {
|
|
117
|
+
const bytes = files.readBytes(at(join(paperDir, PAPER_SETTINGS_FILE)));
|
|
118
|
+
if (bytes === null)
|
|
119
|
+
return files.readBytes(at(join(paperDir, LEGACY_PAPER_SETTINGS_FILE))) ===
|
|
120
|
+
null
|
|
121
|
+
? ok(null)
|
|
122
|
+
: err({ kind: "legacy" });
|
|
123
|
+
let json: unknown;
|
|
124
|
+
try {
|
|
125
|
+
json = JSON.parse(new TextDecoder().decode(bytes));
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return err({ kind: "broken", why: `not JSON (${(e as Error).message})` });
|
|
128
|
+
}
|
|
129
|
+
const parsed = parsePaperSettings(json);
|
|
130
|
+
return parsed.ok ? parsed : err({ kind: "broken", why: parsed.error });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── the move from venue.json ────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/** What `paperlint init` does with one paper's files. */
|
|
136
|
+
export type Migration =
|
|
137
|
+
/** No `venue.json`: nothing to do. */
|
|
138
|
+
| { readonly kind: "none" }
|
|
139
|
+
/** Only `venue.json`: write `text` as `paperlint.json`, delete `venue.json`. */
|
|
140
|
+
| { readonly kind: "move"; readonly text: string }
|
|
141
|
+
/** Both, and `paperlint.json` already says the same: the old one is a leftover, delete it. */
|
|
142
|
+
| { readonly kind: "drop-legacy" }
|
|
143
|
+
/** Both, and they differ: refuse — there is no way to know which one the author means. */
|
|
144
|
+
| { readonly kind: "conflict" }
|
|
145
|
+
/** `venue.json` is not a JSON object: refuse, naming why. */
|
|
146
|
+
| { readonly kind: "broken"; readonly why: string };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A `venue.json` object → the `paperlint.json` object that says the same, in one move:
|
|
150
|
+
* `"venue": "aisec"` becomes `"extends": "paperlint:aisec"` (the only presets that existed were the
|
|
151
|
+
* shipped ones), and the `"_"` some files used as a comment becomes `"$comment"`. Every other key
|
|
152
|
+
* is carried as it is — an unknown one is then refused by name, as in any `paperlint.json`. Pure.
|
|
153
|
+
*/
|
|
154
|
+
export function fromLegacy(
|
|
155
|
+
legacy: Readonly<Record<string, unknown>>,
|
|
156
|
+
): Record<string, unknown> {
|
|
157
|
+
const out: Record<string, unknown> = {};
|
|
158
|
+
for (const [k, v] of Object.entries(legacy)) {
|
|
159
|
+
if (k === "venue" && typeof v === "string")
|
|
160
|
+
out["extends"] = `paperlint:${v}`;
|
|
161
|
+
else if (k === "_") out["$comment"] = v;
|
|
162
|
+
else out[k] = v;
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const jsonOf = (b: Uint8Array): unknown => {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(new TextDecoder().decode(b));
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The move, planned from the two files' bytes (null = absent). Pure. */
|
|
176
|
+
export function migrationOf(
|
|
177
|
+
legacy: Uint8Array | null,
|
|
178
|
+
current: Uint8Array | null,
|
|
179
|
+
): Migration {
|
|
180
|
+
if (legacy === null) return { kind: "none" };
|
|
181
|
+
const old = jsonOf(legacy);
|
|
182
|
+
if (!isObject(old)) return { kind: "broken", why: "it is not a JSON object" };
|
|
183
|
+
const next = fromLegacy(old);
|
|
184
|
+
if (current === null)
|
|
185
|
+
return { kind: "move", text: `${JSON.stringify(next, null, 2)}\n` };
|
|
186
|
+
return JSON.stringify(jsonOf(current)) === JSON.stringify(next)
|
|
187
|
+
? { kind: "drop-legacy" }
|
|
188
|
+
: { kind: "conflict" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── the paper's rule overrides ──────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `rules` → the parsed entries (rule id → severity or `[severity, options]`), or the error naming
|
|
195
|
+
* the file. Null when the paper sets none. The block they go into is built where the config is
|
|
196
|
+
* (`paperRuleBlocks` in cli.ts), which also knows which files a paper's rules may reach.
|
|
197
|
+
*/
|
|
198
|
+
export function paperRules(
|
|
199
|
+
paperDir: string,
|
|
200
|
+
settings: PaperSettings,
|
|
201
|
+
shipped: ReadonlySet<string>,
|
|
202
|
+
): Parsed<Record<string, RuleEntry> | null> {
|
|
203
|
+
if (settings.rules === null) return { ok: true, value: null };
|
|
204
|
+
const where = `${join(paperDir, PAPER_SETTINGS_FILE)} → "rules"`;
|
|
205
|
+
return parseRuleEntries(settings.rules, where, shipped);
|
|
206
|
+
}
|
|
@@ -116,7 +116,7 @@ const named = (facts, name) =>
|
|
|
116
116
|
JSON.stringify(r),
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
|
-
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "
|
|
119
|
+
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-pdf-facts-")));
|
|
120
120
|
try {
|
|
121
121
|
const notPdf = join(tmp, "paper.pdf");
|
|
122
122
|
writeFileSync(notPdf, "%PDF-stale\n");
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Venue presets: what `"extends"` in a paper's `paperlint.json` resolves to. Two spec forms —
|
|
3
|
+
* `paperlint:<name>` (shipped) and a relative path (the project's own) — a chain of at most four
|
|
4
|
+
* presets, merged block by block, and a display label derived from the spec.
|
|
5
|
+
*
|
|
6
|
+
* The shipped presets are read from the package's venues directory, so a shipped file that stops
|
|
7
|
+
* resolving shows here.
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
13
|
+
import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
|
|
14
|
+
import {
|
|
15
|
+
MAX_PRESET_DEPTH,
|
|
16
|
+
labelOf,
|
|
17
|
+
presetProblemText,
|
|
18
|
+
resolvePreset,
|
|
19
|
+
} from "./presets.ts";
|
|
20
|
+
|
|
21
|
+
const VENUES = packageVenuesDir();
|
|
22
|
+
const shipped = Object.fromEntries(
|
|
23
|
+
readdirSync(VENUES)
|
|
24
|
+
.filter((f) => f.endsWith(".jsonc") || f.endsWith(".json"))
|
|
25
|
+
.map((f) => [join(VENUES, f), readFileSync(join(VENUES, f))]),
|
|
26
|
+
);
|
|
27
|
+
const PAPER_FILE = "/work/papers/p/paperlint.json";
|
|
28
|
+
const deps = (extra: Record<string, string> = {}) => ({
|
|
29
|
+
files: memoryFiles({ ...shipped, ...extra }),
|
|
30
|
+
venuesDir: VENUES,
|
|
31
|
+
});
|
|
32
|
+
const resolve = (spec: string, extra: Record<string, string> = {}) =>
|
|
33
|
+
resolvePreset(spec, PAPER_FILE, deps(extra));
|
|
34
|
+
|
|
35
|
+
describe("shipped presets", () => {
|
|
36
|
+
it("paperlint:agenticdev → agenticdev over acm-sigconf: the family's format, the venue's kinds", () => {
|
|
37
|
+
const r = resolve("paperlint:agenticdev");
|
|
38
|
+
expect(r.ok).toBe(true);
|
|
39
|
+
if (!r.ok) return;
|
|
40
|
+
expect(r.value.chain.map((f) => f.split("/").pop())).toEqual([
|
|
41
|
+
"acm-sigconf.jsonc",
|
|
42
|
+
"agenticdev.jsonc",
|
|
43
|
+
]);
|
|
44
|
+
expect(r.value.format.columns).toBe(2);
|
|
45
|
+
expect(r.value.format.fontsText).toBe("LinLibertine");
|
|
46
|
+
expect([...r.value.format.kinds.keys()]).toEqual(["short", "full", "demo"]);
|
|
47
|
+
expect(r.value.template).toBe("acmart");
|
|
48
|
+
expect("acmart" in r.value.tex.packages).toBe(true);
|
|
49
|
+
expect(r.value.label).toBe("agenticdev");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("a paper may extend a family directly: format and fonts, and no kinds", () => {
|
|
53
|
+
const r = resolve("paperlint:acm-sigconf");
|
|
54
|
+
expect(r.ok && r.value.format.kinds.size).toBe(0);
|
|
55
|
+
expect(r.ok && r.value.format.pageWidthIn).toBe(8.5);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("realm (ACL) stands alone: A4, no parent", () => {
|
|
59
|
+
const r = resolve("paperlint:realm");
|
|
60
|
+
expect(r.ok && r.value.chain.length).toBe(1);
|
|
61
|
+
expect(r.ok && r.value.format.pageWidthIn).toBe(8.27);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("agenticdev turns pdf/last-page-balance on — its producer asks for balanced columns", () => {
|
|
65
|
+
const r = resolve("paperlint:agenticdev");
|
|
66
|
+
expect(r.ok && r.value.rules["pdf/last-page-balance"]).toEqual([
|
|
67
|
+
"error",
|
|
68
|
+
{ tolerancePt: 120 },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("a project's own preset, by relative path", () => {
|
|
74
|
+
const usenix = JSON.stringify({
|
|
75
|
+
extends: "paperlint:acm-sigconf",
|
|
76
|
+
name: "USENIX Security",
|
|
77
|
+
format: {
|
|
78
|
+
columns: 1,
|
|
79
|
+
kinds: { full: { body_pages_max: 13, ref_pages_max: 0 } },
|
|
80
|
+
},
|
|
81
|
+
tex: { packages: { usenix: ["usenix.sty"] } },
|
|
82
|
+
rules: { "pdf/body-size": "off" },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("./ is relative to the file that says it; merged per block, child wins", () => {
|
|
86
|
+
const r = resolve("../../venues/usenix-sec.jsonc", {
|
|
87
|
+
"/work/venues/usenix-sec.jsonc": usenix,
|
|
88
|
+
});
|
|
89
|
+
expect(r.ok).toBe(true);
|
|
90
|
+
if (!r.ok) return;
|
|
91
|
+
// format: per key, child wins; the rest comes from the family
|
|
92
|
+
expect(r.value.format.columns).toBe(1);
|
|
93
|
+
expect(r.value.format.pageWidthIn).toBe(8.5);
|
|
94
|
+
// tex: union, never subtract
|
|
95
|
+
expect("usenix" in r.value.tex.packages).toBe(true);
|
|
96
|
+
expect("acmart" in r.value.tex.packages).toBe(true);
|
|
97
|
+
// rules: per id
|
|
98
|
+
expect(r.value.rules["pdf/body-size"]).toBe("off");
|
|
99
|
+
// label: the preset's `name` wins over the file name
|
|
100
|
+
expect(r.value.label).toBe("USENIX Security");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("without `name`, the label is the file name without its extension", () => {
|
|
104
|
+
const r = resolve("./venues/usenix-sec.jsonc", {
|
|
105
|
+
"/work/papers/p/venues/usenix-sec.jsonc": JSON.stringify({
|
|
106
|
+
extends: "paperlint:acm-sigconf",
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
expect(r.ok && r.value.label).toBe("usenix-sec");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("a child kind replaces that kind wholesale; other kinds are kept", () => {
|
|
113
|
+
const r = resolve("./mine.jsonc", {
|
|
114
|
+
"/work/papers/p/mine.jsonc": JSON.stringify({
|
|
115
|
+
extends: "paperlint:agenticdev",
|
|
116
|
+
format: { kinds: { short: { body_pages_max: 6 } } },
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
expect(r.ok).toBe(true);
|
|
120
|
+
if (!r.ok) return;
|
|
121
|
+
expect(r.value.format.kinds.get("short")).toEqual({
|
|
122
|
+
bodyPagesMax: 6,
|
|
123
|
+
refPagesMax: null,
|
|
124
|
+
});
|
|
125
|
+
expect(r.value.format.kinds.get("full")?.bodyPagesMax).toBe(10);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("rules: the leaf wins over its parent, per rule id", () => {
|
|
129
|
+
const r = resolve("./mine.jsonc", {
|
|
130
|
+
"/work/papers/p/mine.jsonc": JSON.stringify({
|
|
131
|
+
extends: "paperlint:agenticdev",
|
|
132
|
+
rules: { "pdf/last-page-balance": "off" },
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
expect(r.ok && r.value.rules["pdf/last-page-balance"]).toBe("off");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("what does not resolve, and says why", () => {
|
|
140
|
+
it.each([
|
|
141
|
+
[
|
|
142
|
+
"an npm package name",
|
|
143
|
+
"@acme/venues",
|
|
144
|
+
"unsupported",
|
|
145
|
+
/npm presets: not yet supported/,
|
|
146
|
+
],
|
|
147
|
+
["a bare name", "agenticdev", "unsupported", /paperlint:agenticdev/],
|
|
148
|
+
[
|
|
149
|
+
"a shipped name with a typo",
|
|
150
|
+
"paperlint:agenticdve",
|
|
151
|
+
"not-found",
|
|
152
|
+
/acm-sigconf, agenticdev, aisec, realm/,
|
|
153
|
+
],
|
|
154
|
+
["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
|
|
155
|
+
[
|
|
156
|
+
"a relative file that is not there",
|
|
157
|
+
"./nope.jsonc",
|
|
158
|
+
"not-found",
|
|
159
|
+
/nope\.jsonc/,
|
|
160
|
+
],
|
|
161
|
+
])("%s", (_, spec, kind, text) => {
|
|
162
|
+
const r = resolve(spec);
|
|
163
|
+
expect(r.ok).toBe(false);
|
|
164
|
+
if (r.ok) return;
|
|
165
|
+
expect(r.error.kind).toBe(kind);
|
|
166
|
+
expect(presetProblemText(r.error)).toMatch(text);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("chains that do not resolve", () => {
|
|
171
|
+
it("a cycle is refused, naming the chain", () => {
|
|
172
|
+
const r = resolve("./a.jsonc", {
|
|
173
|
+
"/work/papers/p/a.jsonc": JSON.stringify({ extends: "./b.jsonc" }),
|
|
174
|
+
"/work/papers/p/b.jsonc": JSON.stringify({ extends: "./a.jsonc" }),
|
|
175
|
+
});
|
|
176
|
+
expect(r.ok).toBe(false);
|
|
177
|
+
if (r.ok) return;
|
|
178
|
+
expect(r.error.kind).toBe("cycle");
|
|
179
|
+
expect(presetProblemText(r.error)).toMatch(
|
|
180
|
+
/a\.jsonc → .*b\.jsonc → .*a\.jsonc/,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it(`a chain longer than ${String(MAX_PRESET_DEPTH)} is refused, printing it`, () => {
|
|
185
|
+
const files: Record<string, string> = {};
|
|
186
|
+
for (let i = 1; i <= 5; i++)
|
|
187
|
+
files[`/work/papers/p/p${String(i)}.jsonc`] = JSON.stringify({
|
|
188
|
+
extends: i < 5 ? `./p${String(i + 1)}.jsonc` : "paperlint:acm-sigconf",
|
|
189
|
+
});
|
|
190
|
+
const r = resolve("./p1.jsonc", files);
|
|
191
|
+
expect(r.ok).toBe(false);
|
|
192
|
+
if (r.ok) return;
|
|
193
|
+
expect(r.error.kind).toBe("too-deep");
|
|
194
|
+
expect(presetProblemText(r.error)).toMatch(/p1\.jsonc → .*p4\.jsonc/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("a chain of exactly the limit resolves", () => {
|
|
198
|
+
const r = resolve("./p1.jsonc", {
|
|
199
|
+
"/work/papers/p/p1.jsonc": JSON.stringify({ extends: "./p2.jsonc" }),
|
|
200
|
+
"/work/papers/p/p2.jsonc": JSON.stringify({
|
|
201
|
+
extends: "paperlint:agenticdev",
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
expect(r.ok && r.value.chain.length).toBe(MAX_PRESET_DEPTH);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("a preset that fails the schema is named with its file", () => {
|
|
208
|
+
const r = resolve("./bad.jsonc", {
|
|
209
|
+
"/work/papers/p/bad.jsonc": JSON.stringify({
|
|
210
|
+
extends: "paperlint:acm-sigconf",
|
|
211
|
+
colums: 2,
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
expect(r.ok).toBe(false);
|
|
215
|
+
if (r.ok) return;
|
|
216
|
+
expect(r.error.kind).toBe("broken");
|
|
217
|
+
expect(presetProblemText(r.error)).toMatch(/bad\.jsonc/);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("a preset with no `extends` must carry `tex` (it is a root)", () => {
|
|
221
|
+
const r = resolve("./root.jsonc", {
|
|
222
|
+
"/work/papers/p/root.jsonc": JSON.stringify({ format: { columns: 1 } }),
|
|
223
|
+
});
|
|
224
|
+
expect(r.ok).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("labelOf — display only, never used to resolve", () => {
|
|
229
|
+
it.each([
|
|
230
|
+
["paperlint:agenticdev", "agenticdev"],
|
|
231
|
+
["./venues/usenix-sec.jsonc", "usenix-sec"],
|
|
232
|
+
["../shared/acl.json", "acl"],
|
|
233
|
+
])("%s → %s", (spec, label) => {
|
|
234
|
+
expect(labelOf(spec)).toBe(label);
|
|
235
|
+
});
|
|
236
|
+
});
|