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.
Files changed (41) hide show
  1. package/.github/workflows/dependabot-automerge.yml +20 -6
  2. package/.github/workflows/release.yml +10 -0
  3. package/dist/cli.d.ts +21 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +83 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/submission-confirm-hook.d.ts +59 -0
  8. package/dist/submission-confirm-hook.d.ts.map +1 -0
  9. package/dist/submission-confirm-hook.js +230 -0
  10. package/dist/submission-confirm-hook.js.map +1 -0
  11. package/dist/submission.d.ts +13 -1
  12. package/dist/submission.d.ts.map +1 -1
  13. package/dist/submission.js +55 -7
  14. package/dist/submission.js.map +1 -1
  15. package/dist/venue-rules.d.ts +33 -0
  16. package/dist/venue-rules.d.ts.map +1 -1
  17. package/dist/venue-rules.js +84 -9
  18. package/dist/venue-rules.js.map +1 -1
  19. package/docs/install.md +4 -4
  20. package/docs/layout.md +1 -0
  21. package/docs/submission.md +33 -1
  22. package/package.json +1 -1
  23. package/plugin/hooks/hooks.json +4 -0
  24. package/scripts/refresh-deadlines/main.test.ts +5 -2
  25. package/scripts/refresh-deadlines/refresh.test.ts +15 -14
  26. package/skills/submission-portal/SKILL.md +11 -10
  27. package/skills/submission-portal/SKILL.md.spec.ts +10 -9
  28. package/src/cli.test.ts +56 -0
  29. package/src/cli.ts +100 -6
  30. package/src/cycle-rules.test.ts +14 -6
  31. package/src/hooks-settings.harness.ts +6 -6
  32. package/src/presets.test.ts +34 -24
  33. package/src/submission-confirm-hook.test.ts +399 -0
  34. package/src/submission-confirm-hook.ts +330 -0
  35. package/src/submission-fields-run.test.ts +1 -1
  36. package/src/submission-new.test.ts +29 -2
  37. package/src/submission-show.test.ts +9 -0
  38. package/src/submission-update.test.ts +153 -3
  39. package/src/submission.ts +85 -10
  40. package/src/venue-rules.test.ts +15 -0
  41. 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(1, 4)).toStrictEqual([
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(6, 7)).toStrictEqual(["created submission 42"]);
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[1]).toBe("title From The PDF");
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, 2)).toStrictEqual([
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(3)).toStrictEqual([
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[1]).toBe("SAVE WITHHELD by the portal — nothing changed");
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
+ });