paperlint 11.1.0 → 11.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/dependabot-automerge.yml +20 -6
- package/.github/workflows/release.yml +10 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +83 -7
- package/dist/cli.js.map +1 -1
- package/dist/submission-confirm-hook.d.ts +59 -0
- package/dist/submission-confirm-hook.d.ts.map +1 -0
- package/dist/submission-confirm-hook.js +230 -0
- package/dist/submission-confirm-hook.js.map +1 -0
- package/dist/submission.d.ts +13 -1
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +55 -7
- package/dist/submission.js.map +1 -1
- package/dist/venue-rules.d.ts +33 -0
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +84 -9
- package/dist/venue-rules.js.map +1 -1
- package/docs/install.md +4 -4
- package/docs/layout.md +1 -0
- package/docs/submission.md +33 -1
- package/package.json +1 -1
- package/plugin/hooks/hooks.json +4 -0
- package/scripts/refresh-deadlines/main.test.ts +5 -2
- package/scripts/refresh-deadlines/refresh.test.ts +15 -14
- package/skills/submission-portal/SKILL.md +11 -10
- package/skills/submission-portal/SKILL.md.spec.ts +10 -9
- package/src/cli.test.ts +56 -0
- package/src/cli.ts +100 -6
- package/src/cycle-rules.test.ts +14 -6
- package/src/hooks-settings.harness.ts +6 -6
- package/src/presets.test.ts +34 -24
- package/src/submission-confirm-hook.test.ts +399 -0
- package/src/submission-confirm-hook.ts +330 -0
- package/src/submission-fields-run.test.ts +1 -1
- package/src/submission-new.test.ts +29 -2
- package/src/submission-show.test.ts +9 -0
- package/src/submission-update.test.ts +153 -3
- package/src/submission.ts +85 -10
- package/src/venue-rules.test.ts +15 -0
- package/src/venue-rules.ts +126 -10
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `submission-confirm` hook — no send to a venue's portal without the person's own yes.
|
|
3
|
+
* PreToolUse on `Bash` (`plugin/hooks/hooks.json`): a command that sends a submission — `paperlint
|
|
4
|
+
* submission new|update` with `--save` or `--submit`, or a request straight at a HotCRP site — gets
|
|
5
|
+
* `"permissionDecision": "ask"`, and Claude Code puts the question to the person, with the reason
|
|
6
|
+
* below, in its permission prompt. Anything else: no output.
|
|
7
|
+
*
|
|
8
|
+
* ── WHY "ask", AND WHY NOTHING TURNS IT OFF ──────────────────────────────────────
|
|
9
|
+
* The hooks reference (code.claude.com/docs/en/hooks): "`ask` prompts the user to confirm"; "A
|
|
10
|
+
* hook's `ask` also forces a permission prompt in auto mode"; the reason is "shown to the user in
|
|
11
|
+
* the permission prompt"; and decisions combine deny > defer > ask > allow, so an `allow` rule in
|
|
12
|
+
* the settings does not outvote it. An agent cannot answer the prompt, and no flag or variable of
|
|
13
|
+
* this package skips it — that is the point: the send is the person's decision, taken on the dry
|
|
14
|
+
* run's venue check (paperlint#264). The hook never denies (the person may send over a limit) and
|
|
15
|
+
* never allows (that would skip the person).
|
|
16
|
+
*
|
|
17
|
+
* ── WHAT IT SEES, AND WHAT IT IS NOT ──────────────────────────────────────────────
|
|
18
|
+
* A send is judged on the command as the shell reads it (`shellLeaves`: each simple command across
|
|
19
|
+
* `&&`, `;`, `|`, words unquoted, the commands inside a `bash -c "…"` string too), never on the raw
|
|
20
|
+
* text: `submission`, `new|update` and `--save|--submit` in one command's words, or a portal's host
|
|
21
|
+
* in any word (a HotCRP-hosted site, or a host a venue preset declares), whatever program it runs.
|
|
22
|
+
* A false ask costs one click; a missed send cannot be taken back. NOT SEEN: an upload from a script
|
|
23
|
+
* file that names the host only inside the file, or words built at run time (`$(…)`).
|
|
24
|
+
*
|
|
25
|
+
* The hook catches an agent that runs a send without asking; it is not a security boundary against
|
|
26
|
+
* an agent that deliberately disguises a send.
|
|
27
|
+
*
|
|
28
|
+
* The reason is built from the same parse and the CLI's own argument parser (`readSend`), one block
|
|
29
|
+
* per send; a send whose PDF cannot be told for certain is still asked about, and says so.
|
|
30
|
+
*
|
|
31
|
+
* ── IT FAILS TOWARD THE QUESTION ─────────────────────────────────────────────────
|
|
32
|
+
* The reason is built from files (the PDF, the build's facts); whatever goes wrong there, the ask
|
|
33
|
+
* still goes out, saying what could not be read. A payload that is not JSON is asked about.
|
|
34
|
+
*
|
|
35
|
+
* ── WHY IN-PROCESS, NOT A VIGILES PROGRAM ────────────────────────────────────────
|
|
36
|
+
* It reads the build's facts with this package's own parser and hashes the PDF; a shipped vigiles
|
|
37
|
+
* hook may import nothing but `vigiles/hook` (`src/folder-layout-hook.ts` says the rest). So
|
|
38
|
+
* `paperlint hook submission-confirm` runs it in-process (`runHook`, src/cli.ts).
|
|
39
|
+
*/
|
|
40
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
41
|
+
import { callerPath } from "./caller-path.ts";
|
|
42
|
+
import { bodyEnd } from "./domain/body-pages.ts";
|
|
43
|
+
import { fieldOf } from "./domain/record.ts";
|
|
44
|
+
import { sha256Hex } from "./domain/sha256.ts";
|
|
45
|
+
import { messageOf } from "./domain/text.ts";
|
|
46
|
+
import { factsPath, parseFactsText, type ReadFacts } from "./facts-file.ts";
|
|
47
|
+
import type { Files } from "./ports/files.ts";
|
|
48
|
+
import { paperPreset, type PresetDeps } from "./presets.ts";
|
|
49
|
+
import { paperPdf } from "./submission.ts";
|
|
50
|
+
|
|
51
|
+
/** One simple command of a command line, as a shell parser reads it (vigiles' `leafCommandsNormalized`). */
|
|
52
|
+
interface ShellLeaf {
|
|
53
|
+
/** The program's basename: `/usr/bin/curl` → `curl`. */
|
|
54
|
+
readonly head: string;
|
|
55
|
+
/** The words after the program, quotes removed. */
|
|
56
|
+
readonly args: readonly string[];
|
|
57
|
+
/** Whether any of the flags `names` is on it (`--save`, `--save=x`). */
|
|
58
|
+
hasFlag(...names: readonly string[]): boolean;
|
|
59
|
+
/** The directory a wrapper (`env -C`, `sudo -D`) runs it in; null for none. */
|
|
60
|
+
readonly chdir: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What the hook reads: files, the shipped presets, and the portal hosts the project's venues declare. */
|
|
64
|
+
export type ConfirmDeps = PresetDeps & {
|
|
65
|
+
/** The hosts of every venue portal paperlint knows of from `cwd`: shipped and the project's own. */
|
|
66
|
+
readonly portalHosts: (cwd: string) => readonly string[];
|
|
67
|
+
/** Every simple command of a command line, across `&&`, `;`, `|`; none when it does not parse. */
|
|
68
|
+
readonly shellLeaves: (command: string) => readonly ShellLeaf[];
|
|
69
|
+
/**
|
|
70
|
+
* The words after `submission`, read by the command's own argument parser: the paper and the
|
|
71
|
+
* `--pdf` the send names (null for the defaults), or null when they do not parse cleanly.
|
|
72
|
+
*/
|
|
73
|
+
readonly readSend: (words: readonly string[]) => Send | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** What Claude Code reads from a PreToolUse hook's stdout to ask the person first, with the reason. */
|
|
77
|
+
export interface AskOutput {
|
|
78
|
+
readonly hookSpecificOutput: {
|
|
79
|
+
readonly hookEventName: "PreToolUse";
|
|
80
|
+
readonly permissionDecision: "ask";
|
|
81
|
+
readonly permissionDecisionReason: string;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ask = (reason: string): AskOutput => ({
|
|
86
|
+
hookSpecificOutput: {
|
|
87
|
+
hookEventName: "PreToolUse",
|
|
88
|
+
permissionDecision: "ask",
|
|
89
|
+
permissionDecisionReason: reason,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const LAST = "Allow only if you have read the dry run's venue check.";
|
|
94
|
+
|
|
95
|
+
/** The domain of HotCRP's hosted sites; a self-hosted portal is known by the host its preset declares. */
|
|
96
|
+
const HOTCRP_HOSTED = "hotcrp.com";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every simple command the shell would run: the command line's own, and those inside a string
|
|
100
|
+
* argument (`bash -c "…"`), parsed the same way.
|
|
101
|
+
*/
|
|
102
|
+
function allLeaves(
|
|
103
|
+
command: string,
|
|
104
|
+
parse: ConfirmDeps["shellLeaves"],
|
|
105
|
+
): readonly ShellLeaf[] {
|
|
106
|
+
const top = parse(command);
|
|
107
|
+
return [
|
|
108
|
+
...top,
|
|
109
|
+
...top.flatMap((l) =>
|
|
110
|
+
l.args.filter((a) => /\s/u.test(a)).flatMap((a) => allLeaves(a, parse)),
|
|
111
|
+
),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A `paperlint submission new|update` with `--save` or `--submit`, in the words of one command. */
|
|
116
|
+
function isSend(l: ShellLeaf): boolean {
|
|
117
|
+
const at = l.args.indexOf("submission");
|
|
118
|
+
return (
|
|
119
|
+
at >= 0 &&
|
|
120
|
+
l.args.slice(at + 1).some((w) => w === "new" || w === "update") &&
|
|
121
|
+
l.hasFlag("save", "submit")
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One `paperlint submission new|update` send: the paper folder and `--pdf` it names; null for the default. */
|
|
126
|
+
export interface Send {
|
|
127
|
+
readonly paper: string | null;
|
|
128
|
+
readonly pdf: string | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Programs that change the directory the rest of the command line runs in. */
|
|
132
|
+
const MOVES: ReadonlySet<string> = new Set(["cd", "pushd", "popd"]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Every send among the simple commands of a command line: its paper and PDF as the command's own
|
|
136
|
+
* argument parser reads them, or null where they cannot be told for certain — the words do not
|
|
137
|
+
* parse, or something before it may have changed the directory.
|
|
138
|
+
*/
|
|
139
|
+
function sendsIn(
|
|
140
|
+
leaves: readonly ShellLeaf[],
|
|
141
|
+
readSend: ConfirmDeps["readSend"],
|
|
142
|
+
): readonly (Send | null)[] {
|
|
143
|
+
return leaves.flatMap((l, n) => {
|
|
144
|
+
const at = l.args.findIndex(
|
|
145
|
+
(x, i) =>
|
|
146
|
+
x === "submission" && /^(?:new|update)$/u.test(l.args[i + 1] ?? ""),
|
|
147
|
+
);
|
|
148
|
+
if (at < 0 || !l.hasFlag("save", "submit")) return [];
|
|
149
|
+
const moved = leaves
|
|
150
|
+
.slice(0, n + 1)
|
|
151
|
+
.some((x) => MOVES.has(x.head) || x.chdir !== null);
|
|
152
|
+
return [moved ? null : readSend(l.args.slice(at + 1))];
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What the commands send: a submission through paperlint, anything that names a venue's portal — a
|
|
158
|
+
* HotCRP-hosted site, or a host a venue preset declares (`hosts`, read only when a word has a dot),
|
|
159
|
+
* whatever program it runs, as a plain case-insensitive substring of a word — or nothing. Judged on
|
|
160
|
+
* the shell's words only, never on the raw text.
|
|
161
|
+
*/
|
|
162
|
+
export function sendKind(
|
|
163
|
+
leaves: readonly ShellLeaf[],
|
|
164
|
+
hosts: () => readonly string[],
|
|
165
|
+
): "paperlint" | "direct" | null {
|
|
166
|
+
if (leaves.some(isSend)) return "paperlint";
|
|
167
|
+
const words = leaves
|
|
168
|
+
.flatMap((l) => [l.head, ...l.args])
|
|
169
|
+
.map((w) => w.toLowerCase())
|
|
170
|
+
.filter((w) => w.includes("."));
|
|
171
|
+
if (words.length === 0) return null;
|
|
172
|
+
const known = [HOTCRP_HOSTED, ...hosts()]
|
|
173
|
+
.map((h) => h.toLowerCase())
|
|
174
|
+
.filter((h) => h !== "");
|
|
175
|
+
return words.some((w) => known.some((h) => w.includes(h))) ? "direct" : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The body pages the build counted: up to the references or appendix, or banal's count. */
|
|
179
|
+
function bodyPages(f: ReadFacts): string {
|
|
180
|
+
const end = bodyEnd(f.text.pages, {
|
|
181
|
+
bib: f.text.bibAnchorPage,
|
|
182
|
+
appendix: f.text.appendixAnchorPage,
|
|
183
|
+
});
|
|
184
|
+
if (end.kind === "found")
|
|
185
|
+
return `body pages: ${String(end.bodyPages)} (up to the ${end.by} on page ${String(end.page)})`;
|
|
186
|
+
return f.geometry === null
|
|
187
|
+
? "body pages: not counted (the build found no references heading and measured no geometry)"
|
|
188
|
+
: `body pages: ${String(f.geometry.body_pages)} (as banal counts them)`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What the build's facts say about the PDF to be sent, in lines. */
|
|
192
|
+
function factsLines(
|
|
193
|
+
paperDir: string,
|
|
194
|
+
sha: string | null,
|
|
195
|
+
files: Files,
|
|
196
|
+
): readonly string[] {
|
|
197
|
+
const bytes = files.readBytes(callerPath(factsPath(paperDir)));
|
|
198
|
+
if (bytes === null)
|
|
199
|
+
return [
|
|
200
|
+
"no build facts (_build/paper.facts.json): the paper was not built, so the venue check did not see this PDF",
|
|
201
|
+
];
|
|
202
|
+
const f = parseFactsText(new TextDecoder().decode(bytes));
|
|
203
|
+
if (!f.ok)
|
|
204
|
+
return [
|
|
205
|
+
`could not read the build facts (_build/paper.facts.json): ${f.error.kind === "schema" ? `schema ${f.error.got}` : f.error.why}`,
|
|
206
|
+
];
|
|
207
|
+
const stale =
|
|
208
|
+
sha !== f.value.sha
|
|
209
|
+
? [
|
|
210
|
+
"!! THE LAST BUILD IS NOT OF THIS PDF — the check did not see it. Rebuild (`paperlint build`) and run the dry run again.",
|
|
211
|
+
]
|
|
212
|
+
: [];
|
|
213
|
+
return [...stale, bodyPages(f.value)];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One send, described: the paper, the PDF as the command resolves it, what the build saw. */
|
|
217
|
+
function sendLines(
|
|
218
|
+
send: Send,
|
|
219
|
+
cwd: string,
|
|
220
|
+
deps: PresetDeps,
|
|
221
|
+
): readonly string[] {
|
|
222
|
+
const paperDir = resolve(cwd, send.paper ?? ".");
|
|
223
|
+
const p = paperPreset(paperDir, deps);
|
|
224
|
+
const own = paperPdf(
|
|
225
|
+
paperDir,
|
|
226
|
+
p.kind === "settings-problem" ? null : p.settings,
|
|
227
|
+
);
|
|
228
|
+
const pdf = send.pdf === null ? own : resolve(cwd, send.pdf);
|
|
229
|
+
const bytes = deps.files.readBytes(callerPath(pdf));
|
|
230
|
+
const sha = bytes === null ? null : sha256Hex(bytes);
|
|
231
|
+
return [
|
|
232
|
+
`paper folder: ${send.paper ?? `${basename(paperDir)} (the current folder)`}`,
|
|
233
|
+
`PDF: ${send.pdf ?? join(send.paper ?? ".", relative(paperDir, own))}${sha === null ? " — NOT FOUND" : `, sha256 ${sha.slice(0, 12)}`}`,
|
|
234
|
+
...factsLines(paperDir, sha, deps.files),
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A send whose PDF cannot be told for certain. */
|
|
239
|
+
const UNRESOLVED =
|
|
240
|
+
"could not resolve which PDF this send uploads — check the dry run";
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Why the person is asked, for `paperlint submission` sends: each send's paper, PDF and build — every
|
|
244
|
+
* send in the command, one block each. Words the shell would expand (`$…`, backticks) are not
|
|
245
|
+
* guessed at: such a command is described as unresolved.
|
|
246
|
+
*/
|
|
247
|
+
export function paperlintReason(
|
|
248
|
+
command: string,
|
|
249
|
+
leaves: readonly ShellLeaf[],
|
|
250
|
+
cwd: string,
|
|
251
|
+
deps: ConfirmDeps,
|
|
252
|
+
): string {
|
|
253
|
+
const sends = /[`$]/u.test(command) ? [] : sendsIn(leaves, deps.readSend);
|
|
254
|
+
const one = (x: Send | null): readonly string[] =>
|
|
255
|
+
x === null ? [UNRESOLVED] : sendLines(x, cwd, deps);
|
|
256
|
+
const blocks =
|
|
257
|
+
sends.length === 0
|
|
258
|
+
? [UNRESOLVED]
|
|
259
|
+
: sends.length === 1
|
|
260
|
+
? sends.flatMap(one)
|
|
261
|
+
: sends.flatMap((x, n) => [
|
|
262
|
+
`send ${String(n + 1)} of ${String(sends.length)}:`,
|
|
263
|
+
...one(x),
|
|
264
|
+
]);
|
|
265
|
+
return [SENDS, ...blocks, LAST].join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The ask when the hook's input cannot be read at all: it cannot tell, so the person decides. */
|
|
269
|
+
export const UNREADABLE_INPUT: AskOutput = ask(
|
|
270
|
+
`paperlint could not read this command, so it cannot tell whether it sends a paper to a venue's portal. ${LAST}`,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const SENDS =
|
|
274
|
+
"This sends the paper to the venue's portal (paperlint submission with --save or --submit).";
|
|
275
|
+
|
|
276
|
+
const DIRECT = `This command names a venue's submission portal, so it may send to it outside paperlint and its venue check. ${LAST}`;
|
|
277
|
+
|
|
278
|
+
/** The payload, parsed; null when it is not JSON. */
|
|
279
|
+
function parsed(stdin: string): unknown {
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(stdin);
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The hook on one payload, as Claude Code sends it on stdin: the ask, or null for a command that
|
|
289
|
+
* sends nothing. Never throws: what it cannot read, it asks about.
|
|
290
|
+
*/
|
|
291
|
+
export function submissionConfirm(
|
|
292
|
+
stdin: string,
|
|
293
|
+
deps: ConfirmDeps,
|
|
294
|
+
): AskOutput | null {
|
|
295
|
+
const payload = parsed(stdin);
|
|
296
|
+
if (payload === null) return UNREADABLE_INPUT;
|
|
297
|
+
const raw = fieldOf(fieldOf(payload, "tool_input"), "command");
|
|
298
|
+
const command = typeof raw === "string" ? raw : "";
|
|
299
|
+
const given = fieldOf(payload, "cwd");
|
|
300
|
+
const cwd =
|
|
301
|
+
typeof given === "string" && isAbsolute(given) ? given : resolve(".");
|
|
302
|
+
const kind = kindOf(command, cwd, deps);
|
|
303
|
+
if (kind === null) return null;
|
|
304
|
+
if (kind !== "paperlint" && kind !== "direct") return ask(kind.failed);
|
|
305
|
+
if (kind === "direct") return ask(DIRECT);
|
|
306
|
+
try {
|
|
307
|
+
return ask(paperlintReason(command, deps.shellLeaves(command), cwd, deps));
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return ask(
|
|
310
|
+
`${SENDS} paperlint could not say what will be sent: ${messageOf(e)}. ${LAST}`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** What the command sends, or — when reading it failed — the reason to ask anyway. */
|
|
316
|
+
function kindOf(
|
|
317
|
+
command: string,
|
|
318
|
+
cwd: string,
|
|
319
|
+
deps: ConfirmDeps,
|
|
320
|
+
): ReturnType<typeof sendKind> | { readonly failed: string } {
|
|
321
|
+
try {
|
|
322
|
+
return sendKind(allLeaves(command, deps.shellLeaves), () =>
|
|
323
|
+
deps.portalHosts(cwd),
|
|
324
|
+
);
|
|
325
|
+
} catch (e) {
|
|
326
|
+
return {
|
|
327
|
+
failed: `paperlint could not tell what this command sends (${messageOf(e)}). ${LAST}`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -48,7 +48,7 @@ test("update: the paper's fields are checked and sent with the change, and print
|
|
|
48
48
|
{ save: false },
|
|
49
49
|
],
|
|
50
50
|
]);
|
|
51
|
-
expect(r.out.slice(
|
|
51
|
+
expect(r.out.slice(2, 5)).toStrictEqual([
|
|
52
52
|
'field topics: ["Group: Topic A"]',
|
|
53
53
|
'field pc_conflicts: {"pc@example.org":"collaborator"}',
|
|
54
54
|
'field ACMCorAut: "ada@example.org"',
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
newFiles,
|
|
23
23
|
WANT,
|
|
24
24
|
ON_THE_SITE,
|
|
25
|
+
NO_ERRORS,
|
|
25
26
|
} from "../test/submission-fixtures.ts";
|
|
26
27
|
|
|
27
28
|
test("new: a dry run by default — title from \\title, abstract from \\begin{abstract} with the author's macro expanded, the declared authors, the PDF", async () => {
|
|
@@ -32,6 +33,7 @@ test("new: a dry run by default — title from \\title, abstract from \\begin{ab
|
|
|
32
33
|
expect(r.seen[0]?.calls).toStrictEqual([["create", WANT, { save: false }]]);
|
|
33
34
|
expect(r.out).toStrictEqual([
|
|
34
35
|
CHECK("papers/p/paper.pdf", "the call for papers, https://example.org/cfp"),
|
|
36
|
+
NO_ERRORS,
|
|
35
37
|
"title Agents That Lie",
|
|
36
38
|
"author 1 Ada Lovelace <ada@example.org>, Analytical",
|
|
37
39
|
"author 2 Bob Bobson <bob@example.org>",
|
|
@@ -61,7 +63,7 @@ test("new --save: the id the portal chose is written into paperlint.json, and `s
|
|
|
61
63
|
const r = await run({ sub: "new", save: true }, { files: fs });
|
|
62
64
|
expect(r.code, r.errs.join("\n")).toBe(0);
|
|
63
65
|
expect(r.seen[0]?.calls).toStrictEqual([["create", WANT, { save: true }]]);
|
|
64
|
-
expect(r.out.slice(
|
|
66
|
+
expect(r.out.slice(7, 8)).toStrictEqual(["created submission 42"]);
|
|
65
67
|
expect(r.out.slice(-2)).toStrictEqual([
|
|
66
68
|
`recorded "submission": { "id": 42 } in papers/p/paperlint.json`,
|
|
67
69
|
`next npx paperlint submission show papers/p — it should say "match yes"`,
|
|
@@ -499,7 +501,7 @@ test("new: no paper.tex — the PDF's Title stands in; without one either, the f
|
|
|
499
501
|
},
|
|
500
502
|
);
|
|
501
503
|
expect(titled.code, titled.errs.join("\n")).toBe(0);
|
|
502
|
-
expect(titled.out[
|
|
504
|
+
expect(titled.out[2]).toBe("title From The PDF");
|
|
503
505
|
const noAbstract = await run(
|
|
504
506
|
{ sub: "new" },
|
|
505
507
|
{ files: newFiles({ tex: null, facts: facts({ title: "From The PDF" }) }) },
|
|
@@ -567,3 +569,28 @@ test("a refusal at a place no file of the paper holds is named at paper.tex", as
|
|
|
567
569
|
"the abstract, paper.tex: \\x — paperlint sends no text it would have to guess; give the abstract with --abstract <file>, a plain-text file",
|
|
568
570
|
]);
|
|
569
571
|
});
|
|
572
|
+
|
|
573
|
+
test("new prints the venue rules' findings on the PDF it creates the submission with; --save refuses facts that do not describe it, and creates nothing", async () => {
|
|
574
|
+
const dry = await run({ sub: "new" }, { files: newFiles({}) });
|
|
575
|
+
expect(dry.out[1]).toBe(NO_ERRORS);
|
|
576
|
+
const stale = newFiles({ facts: facts({ sha: "b".repeat(64) }) });
|
|
577
|
+
const saved = await run({ sub: "new", save: true }, { files: stale });
|
|
578
|
+
expect([saved.code, saved.seen[0]?.calls]).toStrictEqual([2, []]);
|
|
579
|
+
expect(saved.errs).toStrictEqual([
|
|
580
|
+
"refused: no build facts describe this PDF (none, unreadable, or of other bytes), so the venue rules did not see it — rebuild (`paperlint build`), run the dry run again, and read its venue check; nothing was sent",
|
|
581
|
+
]);
|
|
582
|
+
// Where lint reports it: the paper.tex line of \documentclass.
|
|
583
|
+
expect(saved.out.slice(1, 4)).toStrictEqual([
|
|
584
|
+
"venue rules at their default options:",
|
|
585
|
+
`${PAPER}/paper.tex`,
|
|
586
|
+
" 1:2 error _build/paper.facts.json describes a paper.pdf with another SHA-256 than the PDF being sent, so the venue checks would judge other bytes — rebuild the paper (`paperlint build`) from the source of the PDF you send pdf/fresh",
|
|
587
|
+
]);
|
|
588
|
+
expect(
|
|
589
|
+
JSON.parse(
|
|
590
|
+
new TextDecoder().decode(
|
|
591
|
+
stale.readBytes(absolutePath(`${PAPER}/paperlint.json`)) ??
|
|
592
|
+
new Uint8Array(),
|
|
593
|
+
),
|
|
594
|
+
),
|
|
595
|
+
).toStrictEqual({ extends: "../../venue.jsonc", authors: AUTHORS });
|
|
596
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** `paperlint submission show`: what the portal holds, whether it is the local build, the deadlines in force. */
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { expect, test } from "vitest";
|
|
3
4
|
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
4
5
|
import { err, ok } from "./domain/result.ts";
|
|
@@ -20,6 +21,12 @@ import {
|
|
|
20
21
|
run,
|
|
21
22
|
STEPS,
|
|
22
23
|
} from "../test/submission-fixtures.ts";
|
|
24
|
+
import { frozenText } from "../test/frozen-presets.ts";
|
|
25
|
+
|
|
26
|
+
/** msr FROZEN (test/frozen-presets.ts): the shipped file's deadlines are the scheduled refresh's output. */
|
|
27
|
+
const frozenMsr = {
|
|
28
|
+
[join(presetsDir(), "msr.jsonc")]: frozenText("msr.jsonc"),
|
|
29
|
+
};
|
|
23
30
|
|
|
24
31
|
test("show: the portal from the preset, the id from the paper, the token from the site's variable", async () => {
|
|
25
32
|
const r = await run({});
|
|
@@ -130,6 +137,7 @@ test("show sends nothing, so it asks for no check", async () => {
|
|
|
130
137
|
|
|
131
138
|
test("show prints the current attempt's deadlines in force: the preset's portal readings, the paper's call readings and override over them", () => {
|
|
132
139
|
const fs = memoryFiles({
|
|
140
|
+
...frozenMsr,
|
|
133
141
|
"/w/package.json": "{}",
|
|
134
142
|
[`${PAPER}/paperlint.json`]: JSON.stringify({
|
|
135
143
|
cycles: [
|
|
@@ -188,6 +196,7 @@ test("show prints the current attempt's deadlines in force: the preset's portal
|
|
|
188
196
|
|
|
189
197
|
test("show, in the flat form: the deadlines in force are the preset's readings alone", () => {
|
|
190
198
|
const flat = memoryFiles({
|
|
199
|
+
...frozenMsr,
|
|
191
200
|
"/w/package.json": "{}",
|
|
192
201
|
[`${PAPER}/paperlint.json`]: JSON.stringify({
|
|
193
202
|
extends: "paperlint:msr",
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
fakePortal,
|
|
13
13
|
CHECK,
|
|
14
14
|
run,
|
|
15
|
+
NO_ERRORS,
|
|
16
|
+
facts,
|
|
15
17
|
} from "../test/submission-fixtures.ts";
|
|
16
18
|
|
|
17
19
|
test("update: a dry run by default — the paper's PDF sent, nothing saved, exit 0", async () => {
|
|
@@ -32,6 +34,7 @@ test("update: a dry run by default — the paper's PDF sent, nothing saved, exit
|
|
|
32
34
|
]);
|
|
33
35
|
expect(r.out).toStrictEqual([
|
|
34
36
|
CHECK("papers/p/paper.pdf", "the call for papers, https://example.org/cfp"),
|
|
37
|
+
NO_ERRORS,
|
|
35
38
|
"dry run — the portal checked the change and kept nothing; --save sends it for real",
|
|
36
39
|
"HTTP 200",
|
|
37
40
|
"valid yes",
|
|
@@ -69,8 +72,9 @@ test("update --save --submit --abstract --pdf: all of it sent, and said to be sa
|
|
|
69
72
|
],
|
|
70
73
|
]);
|
|
71
74
|
// Guards: the PDF that is sent is the one named, and --save is told to check it too.
|
|
72
|
-
expect(r.out.slice(0,
|
|
75
|
+
expect(r.out.slice(0, 3)).toStrictEqual([
|
|
73
76
|
CHECK("other.pdf", "the call for papers, https://example.org/cfp"),
|
|
77
|
+
NO_ERRORS,
|
|
74
78
|
"saved",
|
|
75
79
|
]);
|
|
76
80
|
});
|
|
@@ -109,7 +113,7 @@ test("update: valid:false, or a non-2xx status, exits 1 with the portal's messag
|
|
|
109
113
|
{ portal: fakePortal({ update: invalid }) },
|
|
110
114
|
);
|
|
111
115
|
expect(r1.code).toBe(1);
|
|
112
|
-
expect(r1.out.slice(
|
|
116
|
+
expect(r1.out.slice(4)).toStrictEqual([
|
|
113
117
|
"valid NO",
|
|
114
118
|
"changes —",
|
|
115
119
|
`local PDF papers/p/paper.pdf sha2-${HEX}`,
|
|
@@ -150,7 +154,7 @@ test("update --save that the portal withheld says so", async () => {
|
|
|
150
154
|
{ sub: "update", save: true },
|
|
151
155
|
{ portal: fakePortal({ update: withheld }) },
|
|
152
156
|
);
|
|
153
|
-
expect(r.out[
|
|
157
|
+
expect(r.out[2]).toBe("SAVE WITHHELD by the portal — nothing changed");
|
|
154
158
|
});
|
|
155
159
|
|
|
156
160
|
test("update: no PDF to send, or no abstract file, is refused before the portal is asked", async () => {
|
|
@@ -164,3 +168,149 @@ test("update: no PDF to send, or no abstract file, is refused before the portal
|
|
|
164
168
|
expect(r2.code).toBe(2);
|
|
165
169
|
expect(r2.errs).toStrictEqual(["--abstract: no file at nope.txt"]);
|
|
166
170
|
});
|
|
171
|
+
|
|
172
|
+
// ── the venue check before a send (paperlint#264) ─────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** A paper of kind `short` (6 body pages) at a venue over AIDC, not blind, built 8 body pages long. */
|
|
175
|
+
const OVER = files({
|
|
176
|
+
venue: { extends: "paperlint:aidc", blind: false },
|
|
177
|
+
paper: { extends: "../../venue.jsonc", kind: "short", submission: { id: 7 } },
|
|
178
|
+
facts: facts({ body: 8 }),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** What `new` and `update` refuse a `--save` with when no build facts describe the PDF. */
|
|
182
|
+
const REFUSAL =
|
|
183
|
+
"refused: no build facts describe this PDF (none, unreadable, or of other bytes), so the venue rules did not see it — rebuild (`paperlint build`), run the dry run again, and read its venue check; nothing was sent";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The block printed for one finding on the made-up paper (no paper.tex, so line 1): what the check
|
|
187
|
+
* is — the venue rules at their default options, not the project's overrides — then lint's report.
|
|
188
|
+
*/
|
|
189
|
+
const LINT_BLOCK = (level: "error" | "warning", text: string): string[] => [
|
|
190
|
+
"venue rules at their default options:",
|
|
191
|
+
`${PAPER}/paper.tex`,
|
|
192
|
+
` 1:2 ${level} ${text}`,
|
|
193
|
+
"",
|
|
194
|
+
`✖ 1 problem (${level === "error" ? "1 error, 0 warnings" : "0 errors, 1 warning"})`,
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
test("update over the page limit: the venue rules' findings on that PDF, as lint prints them, before the portal is asked; a dry run still runs", async () => {
|
|
198
|
+
const r = await run({ sub: "update" }, { files: OVER });
|
|
199
|
+
expect(r.code).toBe(0);
|
|
200
|
+
expect(r.out.slice(1, 7)).toStrictEqual([
|
|
201
|
+
...LINT_BLOCK(
|
|
202
|
+
"error",
|
|
203
|
+
"body pages (up to the references on page 9): 8, over the limit 6 for venue/short — a desk reject; cut the text format/page-limit",
|
|
204
|
+
),
|
|
205
|
+
"dry run — the portal checked the change and kept nothing; --save sends it for real",
|
|
206
|
+
]);
|
|
207
|
+
expect(r.seen[0]?.calls).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("update --save over the limit: the same findings, and the send goes ahead — the person decides on them, not the command", async () => {
|
|
211
|
+
const r = await run({ sub: "update", save: true }, { files: OVER });
|
|
212
|
+
expect([r.code, r.errs, r.out[2], r.out[6]]).toStrictEqual([
|
|
213
|
+
0,
|
|
214
|
+
[],
|
|
215
|
+
`${PAPER}/paper.tex`,
|
|
216
|
+
"saved",
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("no geometry measured: pdf/measured says the page checks did not run, so the check never reads as no errors", async () => {
|
|
221
|
+
const r = await run(
|
|
222
|
+
{ sub: "update" },
|
|
223
|
+
{ files: files({ facts: facts({ measured: false }) }) },
|
|
224
|
+
);
|
|
225
|
+
expect(r.out.slice(1, 6)).toStrictEqual(
|
|
226
|
+
LINT_BLOCK(
|
|
227
|
+
"warning",
|
|
228
|
+
"the build measured no page geometry (banal was not found or failed), so page size, columns, page limits and font sizes were NOT checked — run `paperlint toolchain`, then `paperlint build` pdf/measured",
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("update --save with facts about other bytes, or none: refused with exit 2 and nothing sent; the dry run prints the finding and goes on", async () => {
|
|
234
|
+
const later = files({
|
|
235
|
+
extra: { [`${PAPER}/paper.pdf`]: new TextEncoder().encode("%PDF later") },
|
|
236
|
+
});
|
|
237
|
+
const saved = await run({ sub: "update", save: true }, { files: later });
|
|
238
|
+
expect([saved.code, saved.errs, saved.seen[0]?.calls]).toStrictEqual([
|
|
239
|
+
2,
|
|
240
|
+
[REFUSAL],
|
|
241
|
+
[],
|
|
242
|
+
]);
|
|
243
|
+
const dry = await run({ sub: "update" }, { files: later });
|
|
244
|
+
expect([dry.code, dry.errs, dry.seen[0]?.calls.length]).toStrictEqual([
|
|
245
|
+
0,
|
|
246
|
+
[],
|
|
247
|
+
1,
|
|
248
|
+
]);
|
|
249
|
+
expect(dry.out.slice(1, 6)).toStrictEqual(
|
|
250
|
+
LINT_BLOCK(
|
|
251
|
+
"error",
|
|
252
|
+
"_build/paper.facts.json describes a paper.pdf with another SHA-256 than the PDF being sent, so the venue checks would judge other bytes — rebuild the paper (`paperlint build`) from the source of the PDF you send pdf/fresh",
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
const unbuilt = await run(
|
|
256
|
+
{ sub: "update", save: true, submit: true },
|
|
257
|
+
{ files: files({ facts: null }) },
|
|
258
|
+
);
|
|
259
|
+
expect([unbuilt.code, unbuilt.errs, unbuilt.seen[0]?.calls]).toStrictEqual([
|
|
260
|
+
2,
|
|
261
|
+
[REFUSAL],
|
|
262
|
+
[],
|
|
263
|
+
]);
|
|
264
|
+
const broken = await run(
|
|
265
|
+
{ sub: "update", save: true },
|
|
266
|
+
{ files: files({ facts: "{" }) },
|
|
267
|
+
);
|
|
268
|
+
expect([broken.code, broken.errs]).toStrictEqual([2, [REFUSAL]]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("facts whose SHA-256 is the sent bytes' hold, wherever the PDF they name is — even gone from disk", async () => {
|
|
272
|
+
const gone = files({ facts: facts({ pdf: "gone.pdf" }) });
|
|
273
|
+
const r = await run({ sub: "update", save: true }, { files: gone });
|
|
274
|
+
expect([r.code, r.errs, r.out[1]]).toStrictEqual([0, [], NO_ERRORS]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
/** A paper whose current cycle is still porting to the venue's template, with the facts given. */
|
|
278
|
+
const porting = (factsText?: string | null) =>
|
|
279
|
+
files({
|
|
280
|
+
paper: {
|
|
281
|
+
cycles: [
|
|
282
|
+
{
|
|
283
|
+
id: "venue-2027",
|
|
284
|
+
venue: { kind: "preset", extends: "../../venue.jsonc" },
|
|
285
|
+
opened: "2026-09-09",
|
|
286
|
+
phase: "porting",
|
|
287
|
+
submission: { id: 7 },
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
...(factsText === undefined ? {} : { facts: factsText }),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a cycle still porting to the venue's template: no venue rule runs, and the check says so rather than no errors", async () => {
|
|
295
|
+
const r = await run({ sub: "update" }, { files: porting() });
|
|
296
|
+
expect(r.out[1]).toBe(
|
|
297
|
+
"venue rules: none run — the current cycle's phase is \"porting\", so the venue's format does not apply yet",
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("porting skips the venue rules, not the freshness: --save with facts about other bytes, or none, is still refused", async () => {
|
|
302
|
+
const stale = await run(
|
|
303
|
+
{ sub: "update", save: true },
|
|
304
|
+
{ files: porting(facts({ sha: "b".repeat(64) })) },
|
|
305
|
+
);
|
|
306
|
+
expect([stale.code, stale.errs, stale.seen[0]?.calls]).toStrictEqual([
|
|
307
|
+
2,
|
|
308
|
+
[REFUSAL],
|
|
309
|
+
[],
|
|
310
|
+
]);
|
|
311
|
+
const unbuilt = await run(
|
|
312
|
+
{ sub: "update", save: true },
|
|
313
|
+
{ files: porting(null) },
|
|
314
|
+
);
|
|
315
|
+
expect([unbuilt.code, unbuilt.errs]).toStrictEqual([2, [REFUSAL]]);
|
|
316
|
+
});
|