paperlint 11.1.2 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +18 -8
  3. package/dist/build.js.map +1 -1
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +2 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/latex-log.d.ts +61 -26
  8. package/dist/latex-log.d.ts.map +1 -1
  9. package/dist/latex-log.js +59 -6
  10. package/dist/latex-log.js.map +1 -1
  11. package/dist/latex-loop.d.ts +21 -6
  12. package/dist/latex-loop.d.ts.map +1 -1
  13. package/dist/latex-loop.js +32 -12
  14. package/dist/latex-loop.js.map +1 -1
  15. package/dist/submission-confirm-hook.d.ts +27 -19
  16. package/dist/submission-confirm-hook.d.ts.map +1 -1
  17. package/dist/submission-confirm-hook.js +302 -95
  18. package/dist/submission-confirm-hook.js.map +1 -1
  19. package/docs/configuration.md +30 -1
  20. package/docs/e2e.md +1 -0
  21. package/docs/layout.md +22 -22
  22. package/docs/submission.md +32 -8
  23. package/fixtures/build-e2e/lost-character/PIPELINE-STATUS.md +3 -0
  24. package/fixtures/build-e2e/lost-character/expect.json +15 -0
  25. package/fixtures/build-e2e/lost-character/paper.tex +8 -0
  26. package/package.json +1 -1
  27. package/skills/submission-portal/SKILL.md +18 -12
  28. package/skills/submission-portal/SKILL.md.spec.ts +17 -11
  29. package/src/build.test.ts +46 -3
  30. package/src/build.ts +20 -8
  31. package/src/cli.test.ts +3 -3
  32. package/src/cli.ts +2 -2
  33. package/src/latex-log.harness.ts +145 -14
  34. package/src/latex-log.ts +105 -16
  35. package/src/latex-loop.harness.ts +70 -34
  36. package/src/latex-loop.ts +51 -30
  37. package/src/submission-confirm-hook.test.ts +493 -60
  38. package/src/submission-confirm-hook.ts +448 -116
package/docs/layout.md CHANGED
@@ -136,28 +136,28 @@ A check runs at one of four moments:
136
136
  The lint-only job skips what needs a build (pages, fonts, references).
137
137
  - **On demand** — a skill, when you ask for what it does. A skill stops nothing; it writes.
138
138
 
139
- | what | kind | when it runs | what it stops |
140
- | ------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
- | `paper-folder-layout` | hook | before an agent creates a file (Edit, Write) | a new entry the layout does not allow, at a paper's root or the top of its `repro/`; never an existing file |
142
- | `paper-edit-guard` | hook | before an agent's shell command | a shell command that writes or moves a paper's source, which would skip the hooks that run after an edit |
143
- | `submission-confirm` | hook | before an agent's shell command | nothing by itself: Claude Code asks the person before a submission is sent (`--save`, `--submit`, or straight to HotCRP) |
144
- | `paper-skills-nudge` | hook | after an agent edits a paper's source | nothing: it shows the agent the pre-submit checklist |
145
- | `paper-status-gates` | hook | after an agent edits a paper's source | nothing: it prints the paper's readiness and its unrun gates, for a person reading the transcript |
146
- | [`paper/allowed-files-only`](rules/paper/allowed-files-only.md) | rule | every `paperlint lint`, and CI | a stray file or folder at a paper's root (error) |
147
- | [`repro/allowed-files-only`](rules/repro/allowed-files-only.md) | rule | every `paperlint lint`, and CI | a loose entry at the top of `repro/`, a folder that is not a study's, a study folder without `README.md` (error) |
148
- | [`repro/study-registered`](rules/repro/study-registered.md) | rule | every `paperlint lint`, and CI | a study folder without its row in `repro/studies.json`, a row that does not match, a measurement or plan that does not hold, an index out of sync (error) |
149
- | [`repro/study-plan-name`](rules/repro/study-plan-name.md) | rule | every `paperlint lint`, and CI | nothing: it warns when a study's plan is still named `prereg.json` |
150
- | [`repro/outputs-before-decision`](rules/repro/outputs-before-decision.md) | rule | every `paperlint lint`, and CI (with the whole history) | an output of a study decided before its run that git shows was not made after the decision |
151
- | `paperlint studies gate` | command | before a decide-first study's run | the run, until the decision is committed |
152
- | [`study/declare-sample`](rules/study/declare-sample.md) | rule | every `paperlint lint`, and CI, once a paper turns it on | a study's `prereg.json` without its population, strata, screens, coverage or the author's check (experimental, off by default) |
153
- | [`paper/folder-venue-leftover`](rules/paper/folder-venue-leftover.md) | rule | every `paperlint lint`, and CI | nothing: it warns when the folder is named after another venue |
154
- | `review/frontmatter`, `sibling/frontmatter` | rule | every `paperlint lint`, and CI | a review whose findings break the schema (error); a sibling card that does not say how much was read (warning) |
155
- | `paper/stages`, `paper/source` | rule | every `paperlint lint`, and CI | a stage whose frozen PDF or source is missing or changed (error) |
156
- | the rules on the source (`tex/*`, `paper/*`, `bib/*`) | rule | every `paperlint lint`, and CI | what each one's [row](rules.md) says |
157
- | the rules on what `paperlint build` measured (`pdf/*`, `format/page-limit`, the references) | rule | every `paperlint lint` after a build; CI's full job | a PDF over the page limit, in the wrong font or format, or with a bad reference |
158
- | `study-accepted-papers` | skill | when you ask; stage 2 (modes A, B) and stage 5 (modes C, D) | nothing: it fills the venue's corpus, and writes the strong-accept scorecard in `PIPELINE-STATUS.md` |
159
- | `build-benchmark` | skill | when you ask; stage 3 | nothing: it registers the study with `paperlint studies new`, writes its design, fills its `plan.json`, and updates its row when it ends |
160
- | `build-label-page` | skill | when you ask; stage 3 | nothing: it builds the labelling page, and exports the answers into the study's `data/` |
139
+ | what | kind | when it runs | what it stops |
140
+ | ------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
+ | `paper-folder-layout` | hook | before an agent creates a file (Edit, Write) | a new entry the layout does not allow, at a paper's root or the top of its `repro/`; never an existing file |
142
+ | `paper-edit-guard` | hook | before an agent's shell command | a shell command that writes or moves a paper's source, which would skip the hooks that run after an edit |
143
+ | `submission-confirm` | hook | before an agent's shell command | a submission send (`--save`, `--submit`), or a command naming a portal's host, that the person has not said yes to in a question naming its PDF's sha256 (or the host and the command) |
144
+ | `paper-skills-nudge` | hook | after an agent edits a paper's source | nothing: it shows the agent the pre-submit checklist |
145
+ | `paper-status-gates` | hook | after an agent edits a paper's source | nothing: it prints the paper's readiness and its unrun gates, for a person reading the transcript |
146
+ | [`paper/allowed-files-only`](rules/paper/allowed-files-only.md) | rule | every `paperlint lint`, and CI | a stray file or folder at a paper's root (error) |
147
+ | [`repro/allowed-files-only`](rules/repro/allowed-files-only.md) | rule | every `paperlint lint`, and CI | a loose entry at the top of `repro/`, a folder that is not a study's, a study folder without `README.md` (error) |
148
+ | [`repro/study-registered`](rules/repro/study-registered.md) | rule | every `paperlint lint`, and CI | a study folder without its row in `repro/studies.json`, a row that does not match, a measurement or plan that does not hold, an index out of sync (error) |
149
+ | [`repro/study-plan-name`](rules/repro/study-plan-name.md) | rule | every `paperlint lint`, and CI | nothing: it warns when a study's plan is still named `prereg.json` |
150
+ | [`repro/outputs-before-decision`](rules/repro/outputs-before-decision.md) | rule | every `paperlint lint`, and CI (with the whole history) | an output of a study decided before its run that git shows was not made after the decision |
151
+ | `paperlint studies gate` | command | before a decide-first study's run | the run, until the decision is committed |
152
+ | [`study/declare-sample`](rules/study/declare-sample.md) | rule | every `paperlint lint`, and CI, once a paper turns it on | a study's `prereg.json` without its population, strata, screens, coverage or the author's check (experimental, off by default) |
153
+ | [`paper/folder-venue-leftover`](rules/paper/folder-venue-leftover.md) | rule | every `paperlint lint`, and CI | nothing: it warns when the folder is named after another venue |
154
+ | `review/frontmatter`, `sibling/frontmatter` | rule | every `paperlint lint`, and CI | a review whose findings break the schema (error); a sibling card that does not say how much was read (warning) |
155
+ | `paper/stages`, `paper/source` | rule | every `paperlint lint`, and CI | a stage whose frozen PDF or source is missing or changed (error) |
156
+ | the rules on the source (`tex/*`, `paper/*`, `bib/*`) | rule | every `paperlint lint`, and CI | what each one's [row](rules.md) says |
157
+ | the rules on what `paperlint build` measured (`pdf/*`, `format/page-limit`, the references) | rule | every `paperlint lint` after a build; CI's full job | a PDF over the page limit, in the wrong font or format, or with a bad reference |
158
+ | `study-accepted-papers` | skill | when you ask; stage 2 (modes A, B) and stage 5 (modes C, D) | nothing: it fills the venue's corpus, and writes the strong-accept scorecard in `PIPELINE-STATUS.md` |
159
+ | `build-benchmark` | skill | when you ask; stage 3 | nothing: it registers the study with `paperlint studies new`, writes its design, fills its `plan.json`, and updates its row when it ends |
160
+ | `build-label-page` | skill | when you ask; stage 3 | nothing: it builds the labelling page, and exports the answers into the study's `data/` |
161
161
 
162
162
  The hooks are wired by `paperlint init` ([`install.md`](install.md)). A rule's level and options:
163
163
  [`rules.md`](rules.md).
@@ -111,14 +111,38 @@ The command refuses one thing by itself: `--save` when no build facts describe t
111
111
  of other bytes) exits 2 and sends nothing. Rebuild (`paperlint build`) and run the dry run again.
112
112
  Everything else, including over the limit, is the person's call.
113
113
 
114
- **The `submission-confirm` hook** (installed by `paperlint init`) makes Claude Code ask the person
115
- before any `submission new|update` with `--save` or `--submit`, and before any command that names a
116
- portal's host (a HotCRP-hosted site, or a host a venue preset declares), whatever program it runs.
117
- The question names, for each send in the command, the paper, the PDF and its sha256, and the body
118
- pages of the last build. No flag turns it off; it asks even in auto mode. It does not see an upload
119
- from a script file that names the host only inside the file. The hook catches an agent that runs a
120
- send without asking; it is not a security boundary against an agent that deliberately disguises a
121
- send.
114
+ **The `submission-confirm` hook** (installed by `paperlint init`) stops any `submission new|update`
115
+ with `--save` or `--submit` until the person has said yes to that exact PDF in that exact folder. The flow:
116
+
117
+ 1. Run the dry run (the same command without `--save`) and read its venue check.
118
+ 2. Ask the person with Claude Code's question tool (`AskUserQuestion`), with the options "Yes, send
119
+ it" and "No". The question names the paper folder (its full path, as the hook prints it), the PDF and the
120
+ first 12 characters of its sha256, the body pages, and quotes the dry run's venue check.
121
+ 3. On a yes, run the send.
122
+
123
+ Without a yes, the hook stops the send and tells the agent what to ask, with the folder and the sha256 filled in.
124
+ It reads the answer from the session's transcript. The question must contain the folder's full path
125
+ and the PDF's sha256, so a yes to an older build, another PDF, or the same bytes in another folder
126
+ (the same manuscript for another venue) does not count. One yes lets one send through; the next send
127
+ needs a new question. That holds inside one command too: each send in a chain (`… --save && … --save`)
128
+ takes a yes of its own, so one yes naming two shas, or two sends of the same PDF, covers only one. A send whose PDF cannot be found or told for certain is stopped, and so is
129
+ any send when the transcript cannot be read.
130
+
131
+ A command that names a portal's host (a HotCRP-hosted site, or a host a venue preset declares),
132
+ whatever program it runs, may send around the venue check. It runs only after a yes to a question
133
+ that contains that host and the command's first 60 characters; the hook prints both when it stops
134
+ the command, the start compared as written (the host in any case). One yes lets one such command through, and a command that also sends a paper needs a separate yes for each. If the venue presets cannot be read, only
135
+ HotCRP-hosted sites and paperlint sends are recognised, and the hook says so when it stops one;
136
+ other commands run as usual. The hook does not see an upload from a script file that names the
137
+ host only inside the file.
138
+
139
+ The agent writes the question, so a deliberately misleading question still passes. The hook
140
+ catches an agent that sends without asking, or asks about the wrong PDF; it is not a security
141
+ boundary against an agent that deliberately disguises a send.
142
+
143
+ Why a question and not Claude Code's own permission prompt: in the Claude mobile app that prompt
144
+ shows only the command line, not which paper or PDF it sends. The question shows everything, on
145
+ every client.
122
146
 
123
147
  ## What it does not do
124
148
 
@@ -0,0 +1,3 @@
1
+ # Fixture
2
+
3
+ Stage: draft
@@ -0,0 +1,15 @@
1
+ {
2
+ "$comment": "pdflatex exits 0 and writes a PDF without a character the font has no glyph for; the build fails on the log line (FAILING_WARNINGS in src/latex-log.ts).",
3
+ "build": {
4
+ "outcome": "failed"
5
+ },
6
+ "says": [
7
+ "✗ compile: pdflatex exited 0, but its final log carries a warning that fails the build",
8
+ "lost-character — the font has no glyph for this character, so pdflatex left it out of the PDF:",
9
+ "Missing character: There is no",
10
+ "in font cmr10!"
11
+ ],
12
+ "saysNot": [
13
+ "✓"
14
+ ]
15
+ }
@@ -0,0 +1,8 @@
1
+ % A paper whose font has no glyph for one character. pdflatex prints "Missing character: There is no
2
+ % ... in font cmr10!" to the log (LaTeX sets \tracinglostchars=2), exits 0 and writes a PDF without
3
+ % it -- the way an abstract once printed 7 x 10^13 for 7 x 10^-13. `paperlint build` must fail, quote
4
+ % the log line, and leave no paper.pdf.
5
+ \documentclass{article}
6
+ \begin{document}
7
+ A rate of $7\times 10^{13}$ and a character cmr10 cannot draw: \char"FF.
8
+ \end{document}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "11.1.2",
3
+ "version": "12.0.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ description: "Create, read or change a paper's submission on the venue's HotCRP
4
4
  allowed-tools: [Read, Grep, Glob, Bash, Edit, AskUserQuestion]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:3fa4bf1d06de4d7a compiled from skills/submission-portal/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:8b1352dd55af2d2b compiled from skills/submission-portal/SKILL.md.spec.ts -->
8
8
 
9
9
  # submission-portal — create, read and update the submission on the portal, then prove it landed
10
10
 
@@ -62,25 +62,31 @@ A missing key or token stops the command with exit 2 and a line naming what to s
62
62
  `\begin{abstract}`) and each `field` it sends. Read them with the author; an abstract the
63
63
  command cannot read, or one that reads wrong, is given as a plain-text file with
64
64
  `--abstract <file>`.
65
- 2. **Send it.** Show the author the dry run's venue check (its lint findings, or "venue rules: no
66
- errors") and what goes (title, authors, abstract, each field), then run
67
- `npx paperlint submission new papers/my-paper --save` — the `submission-confirm` hook makes
68
- Claude Code ask the author before it runs. Then the printed "still to do" list with the author.
65
+ 2. **Ask, then send.** Ask the author with `AskUserQuestion` — options "Yes, send it" and "No" —
66
+ naming the paper folder (its full path, as the hook prints it), the PDF and its sha256, the body
67
+ pages, and quoting the dry run's venue
68
+ check (its lint findings, or "venue rules at their default options: no errors"), plus what goes
69
+ (title, authors, abstract, each field). On a yes, run
70
+ `npx paperlint submission new papers/my-paper --save`. Then the printed "still to do" list
71
+ with the author.
69
72
  3. **Read it.** `npx paperlint submission show papers/my-paper` — status, the PDF the portal holds,
70
73
  and `match`: whether it is the local build. Rebuild first if the local PDF is stale.
71
74
  4. **Dry run the change.** `npx paperlint submission update papers/my-paper` (`--abstract <file>`,
72
75
  `--submit` to mark it submitted); it sends the paper's `"fields"` too. Exit 1 means the portal
73
76
  would refuse it, or did not take a topic — report why, stop.
74
- 5. **Send it.** Show the author the dry run's venue check and what changes, then run the same
75
- command with `--save`; the `submission-confirm` hook makes Claude Code ask the author before
76
- any send. A refusal (exit 2) means the last build is not of this PDF: rebuild, dry run again.
77
+ 5. **Ask, then send.** Ask the author with `AskUserQuestion` as in step 2: the question names the
78
+ paper folder (full path), the PDF's sha256 and quotes the dry run's venue check, and says what changes. On a yes, run the same
79
+ command with `--save`. Where the `submission-confirm` hook is installed, a send without that
80
+ yes is stopped, and the hook says what to ask, with the folder and the sha256 filled in; ask exactly that. One
81
+ yes covers one send: to send again, or to send a second paper in the same command, ask again. A refusal by the command itself (exit 2) means
82
+ the last build is not of this PDF: rebuild, dry run again.
77
83
  6. **Prove it landed.** `show` again: `match` must read `yes — the portal holds this build`, and
78
84
  the status must be what was intended. Anything else is not done — say so.
79
85
 
80
86
  🔴 `--save` creates or changes a real submission that reviewers will see: never without the
81
87
  author's own yes, never "to test", never because a dry run passed. Before every `--save`, ask the
82
- author with `AskUserQuestion`, showing the dry run's venue check; where the `submission-confirm`
83
- hook is installed, Claude Code also asks before the command runs.
88
+ author with `AskUserQuestion`, naming the paper folder, the PDF's sha256 and quoting the dry run's venue check; where
89
+ the `submission-confirm` hook is installed, it stops any send without that yes.
84
90
 
85
91
  ```bash
86
92
  npx paperlint build papers/my-paper
@@ -88,11 +94,11 @@ npx paperlint submission topics papers/my-paper
88
94
  # … AskUserQuestion: which topics; the chosen ones into "fields" …
89
95
  # only while the paper has no submission id:
90
96
  npx paperlint submission new papers/my-paper
91
- # … the author reads the dry run and says yes …
97
+ # … AskUserQuestion naming the folder, the PDF's sha256 and the venue check; the author says yes …
92
98
  npx paperlint submission new papers/my-paper --save
93
99
  npx paperlint submission show papers/my-paper
94
100
  npx paperlint submission update papers/my-paper --submit
95
- # … the author reads the dry run and says yes …
101
+ # … AskUserQuestion naming the folder, the PDF's sha256 and the venue check; the author says yes …
96
102
  npx paperlint submission update papers/my-paper --submit --save
97
103
  npx paperlint submission show papers/my-paper
98
104
  ```
@@ -66,25 +66,31 @@ A missing key or token stops the command with exit 2 and a line naming what to s
66
66
  \`\\begin{abstract}\`) and each \`field\` it sends. Read them with the author; an abstract the
67
67
  command cannot read, or one that reads wrong, is given as a plain-text file with
68
68
  \`--abstract <file>\`.
69
- 2. **Send it.** Show the author the dry run's venue check (its lint findings, or "venue rules: no
70
- errors") and what goes (title, authors, abstract, each field), then run
71
- \`npx paperlint submission new papers/my-paper --save\` — the \`submission-confirm\` hook makes
72
- Claude Code ask the author before it runs. Then the printed "still to do" list with the author.
69
+ 2. **Ask, then send.** Ask the author with \`AskUserQuestion\` — options "Yes, send it" and "No" —
70
+ naming the paper folder (its full path, as the hook prints it), the PDF and its sha256, the body
71
+ pages, and quoting the dry run's venue
72
+ check (its lint findings, or "venue rules at their default options: no errors"), plus what goes
73
+ (title, authors, abstract, each field). On a yes, run
74
+ \`npx paperlint submission new papers/my-paper --save\`. Then the printed "still to do" list
75
+ with the author.
73
76
  3. **Read it.** \`npx paperlint submission show papers/my-paper\` — status, the PDF the portal holds,
74
77
  and \`match\`: whether it is the local build. Rebuild first if the local PDF is stale.
75
78
  4. **Dry run the change.** \`npx paperlint submission update papers/my-paper\` (\`--abstract <file>\`,
76
79
  \`--submit\` to mark it submitted); it sends the paper's \`"fields"\` too. Exit 1 means the portal
77
80
  would refuse it, or did not take a topic — report why, stop.
78
- 5. **Send it.** Show the author the dry run's venue check and what changes, then run the same
79
- command with \`--save\`; the \`submission-confirm\` hook makes Claude Code ask the author before
80
- any send. A refusal (exit 2) means the last build is not of this PDF: rebuild, dry run again.
81
+ 5. **Ask, then send.** Ask the author with \`AskUserQuestion\` as in step 2: the question names the
82
+ paper folder (full path), the PDF's sha256 and quotes the dry run's venue check, and says what changes. On a yes, run the same
83
+ command with \`--save\`. Where the \`submission-confirm\` hook is installed, a send without that
84
+ yes is stopped, and the hook says what to ask, with the folder and the sha256 filled in; ask exactly that. One
85
+ yes covers one send: to send again, or to send a second paper in the same command, ask again. A refusal by the command itself (exit 2) means
86
+ the last build is not of this PDF: rebuild, dry run again.
81
87
  6. **Prove it landed.** \`show\` again: \`match\` must read \`yes — the portal holds this build\`, and
82
88
  the status must be what was intended. Anything else is not done — say so.
83
89
 
84
90
  🔴 \`--save\` creates or changes a real submission that reviewers will see: never without the
85
91
  author's own yes, never "to test", never because a dry run passed. Before every \`--save\`, ask the
86
- author with \`AskUserQuestion\`, showing the dry run's venue check; where the \`submission-confirm\`
87
- hook is installed, Claude Code also asks before the command runs.
92
+ author with \`AskUserQuestion\`, naming the paper folder, the PDF's sha256 and quoting the dry run's venue check; where
93
+ the \`submission-confirm\` hook is installed, it stops any send without that yes.
88
94
 
89
95
  \`\`\`bash
90
96
  npx paperlint build papers/my-paper
@@ -92,11 +98,11 @@ npx paperlint submission topics papers/my-paper
92
98
  # … AskUserQuestion: which topics; the chosen ones into "fields" …
93
99
  # only while the paper has no submission id:
94
100
  npx paperlint submission new papers/my-paper
95
- # … the author reads the dry run and says yes …
101
+ # … AskUserQuestion naming the folder, the PDF's sha256 and the venue check; the author says yes …
96
102
  npx paperlint submission new papers/my-paper --save
97
103
  npx paperlint submission show papers/my-paper
98
104
  npx paperlint submission update papers/my-paper --submit
99
- # … the author reads the dry run and says yes …
105
+ # … AskUserQuestion naming the folder, the PDF's sha256 and the venue check; the author says yes …
100
106
  npx paperlint submission update papers/my-paper --submit --save
101
107
  npx paperlint submission show papers/my-paper
102
108
  \`\`\`
package/src/build.test.ts CHANGED
@@ -265,12 +265,55 @@ test("compile: a document whose .aux never settles did not converge", async () =
265
265
  assert.match(line, /^ {2}✗ compile: pdflatex did not converge\n/);
266
266
  });
267
267
 
268
- test("compile: warnings the final log still reports are named in the note", async () => {
269
- const { r } = await build(paper(), {
268
+ test("🔴 compile: pdflatex exits 0 but its log says a character was dropped — the build fails, quoting the line, and the PDF goes", async () => {
269
+ const dir = paper();
270
+ // The log as pdflatex writes it: raw UTF-8 bytes, which the build reads as latin1 and shows as UTF-8.
271
+ const { r, line } = await build(dir, {
272
+ log: [
273
+ "(./paper.aux)",
274
+ "Missing character: There is no ⁻ in font cmr10!",
275
+ "Overfull \\hbox (1.2pt too wide) in paragraph at lines 4--5",
276
+ "Missing character: There is no ⁻ in font cmr10!",
277
+ "",
278
+ ].join("\n"),
279
+ });
280
+ assert.equal(r.status, "failed");
281
+ assert.equal(
282
+ line,
283
+ [
284
+ " ✗ compile: pdflatex exited 0, but its final log carries a warning that fails the build",
285
+ " lost-character — the font has no glyph for this character, so pdflatex left it out of the PDF:",
286
+ " Missing character: There is no ⁻ in font cmr10! (2 times)",
287
+ ` full log: ${join(dir, "paper.log").replace(root, "<root>")}`,
288
+ " paper.pdf removed — a stale PDF must not pass for this build",
289
+ ].join("\n"),
290
+ );
291
+ assert.equal(existsSync(join(dir, "paper.pdf")), false);
292
+ });
293
+
294
+ test("compile: undefined references in the final log fail the build — no longer a note on a green one", async () => {
295
+ const { r, line } = await build(paper(), {
270
296
  log: "LaTeX Warning: There were undefined references.\n",
271
297
  });
298
+ assert.equal(r.status, "failed");
299
+ assert.match(
300
+ line,
301
+ /\n {6}undefined-references — .*:\n {8}LaTeX Warning: There were undefined references\.\n/,
302
+ );
303
+ });
304
+
305
+ test("compile: warnings off the failing list — overfull boxes, a font substitution, one undefined reference — leave the build green", async () => {
306
+ const { r } = await build(paper(), {
307
+ log: [
308
+ "Overfull \\hbox (1.2pt too wide) in paragraph at lines 4--5",
309
+ "LaTeX Font Warning: Font shape `OT1/cmr/bx/sc' undefined",
310
+ "(Font) using `OT1/cmr/bx/n' instead on input line 3.",
311
+ "LaTeX Warning: Reference `sec:x' on page 1 undefined on input line 4.",
312
+ "",
313
+ ].join("\n"),
314
+ });
272
315
  assert.equal(r.status, "built");
273
- assert.match(r.notes?.[0] ?? "", /— ⚠️ the final log still reports /);
316
+ assert.doesNotMatch(r.notes?.[0] ?? "", /⚠️/);
274
317
  });
275
318
 
276
319
  test("references: without a reader the step says it was not wired; with one and no checker, the record says not checked", async () => {
package/src/build.ts CHANGED
@@ -83,6 +83,7 @@ import {
83
83
  auxBib,
84
84
  bibtexExcerpt,
85
85
  errorExcerpt,
86
+ failingWarnings,
86
87
  logMarkers,
87
88
  unwrapLog,
88
89
  } from "./latex-log.ts";
@@ -90,6 +91,7 @@ import {
90
91
  nextStep,
91
92
  summarize,
92
93
  type BibInput,
94
+ type FailCause,
93
95
  type Hashes,
94
96
  type Observation,
95
97
  type Step,
@@ -401,6 +403,10 @@ function latexPass(
401
403
  markers: logMarkers(log),
402
404
  bib: bibInput(ctx.paperDir),
403
405
  fls: readOr(join(ctx.paperDir, `${JOB}.fls`), "utf8"),
406
+ failing: failingWarnings(log).map((h) => ({
407
+ ...h,
408
+ quotes: h.quotes.map((q) => ({ ...q, text: fromLatin1(q.text) })),
409
+ })),
404
410
  errorLines:
405
411
  exitCode === 0
406
412
  ? []
@@ -494,6 +500,18 @@ export function compile(ctx: BuildContext): {
494
500
 
495
501
  const NO_PDF = `pdflatex exited 0 but wrote no ${JOB}.pdf — does the document have any pages?`;
496
502
 
503
+ /** The head of a failed compile: which program, and how it ended. */
504
+ function whereFailed(bin: string, cause: FailCause): string {
505
+ switch (cause.kind) {
506
+ case "exit":
507
+ return `${bin} exited with ${String(cause.code)}`;
508
+ case "no-convergence":
509
+ return `${bin} did not converge`;
510
+ case "log-warnings":
511
+ return `${bin} exited 0, but its final log carries a warning that fails the build`;
512
+ }
513
+ }
514
+
497
515
  const plural = (n: number, one: string, many: string): string =>
498
516
  `${String(n)} ${n === 1 ? one : many}`;
499
517
 
@@ -517,10 +535,7 @@ export const compileStep: BuildStep = {
517
535
  const { end, latex, bibtex, run } = compile(ctx);
518
536
  if (end.kind === "fail") {
519
537
  const bin = end.step === "latex" ? "pdflatex" : "bibtex";
520
- const where =
521
- end.cause.kind === "exit"
522
- ? `${bin} exited with ${String(end.cause.code)}`
523
- : `${bin} did not converge`;
538
+ const where = whereFailed(bin, end.cause);
524
539
  const log = join(
525
540
  ctx.paperDir,
526
541
  `${JOB}.${end.step === "latex" ? "log" : "blg"}`,
@@ -545,12 +560,9 @@ export const compileStep: BuildStep = {
545
560
  ok: false,
546
561
  lines: [NO_PDF, `full log: ${join(ctx.paperDir, `${JOB}.log`)}`],
547
562
  };
548
- const warn = end.warnings.length
549
- ? ` — ⚠️ the final log still reports ${end.warnings.join(", ")}`
550
- : "";
551
563
  return {
552
564
  ok: true,
553
- note: `${plural(latex, "pdflatex pass", "pdflatex passes")}, ${plural(bibtex, "bibtex run", "bibtex runs")}${warn}; ${recordingNote(ctx.recordSources(ctx.paperDir, run))}`,
565
+ note: `${plural(latex, "pdflatex pass", "pdflatex passes")}, ${plural(bibtex, "bibtex run", "bibtex runs")}; ${recordingNote(ctx.recordSources(ctx.paperDir, run))}`,
554
566
  };
555
567
  },
556
568
  };
package/src/cli.test.ts CHANGED
@@ -242,7 +242,7 @@ test("runHook paper-folder-layout: in-process, on the real disk — a refusal on
242
242
  );
243
243
  });
244
244
 
245
- test("runHook submission-confirm: in-process — an ask on stdout for a send, nothing for anything else, and an ask when its input cannot be read", () => {
245
+ test("runHook submission-confirm: in-process — a refusal on stdout for a send with no yes, nothing for anything else, and a refusal when its input cannot be read", () => {
246
246
  const hook = (stdin: () => string) => {
247
247
  const out: string[] = [];
248
248
  const code = runHook("submission-confirm", {
@@ -260,7 +260,7 @@ test("runHook submission-confirm: in-process — an ask on stdout for a send, no
260
260
  assert.equal(send.code, 0);
261
261
  assert.match(
262
262
  send.out.join(""),
263
- /^\{"hookSpecificOutput":\{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"This sends the paper to the venue's portal/,
263
+ /^\{"hookSpecificOutput":\{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Not run: this sends the paper to the venue's portal/,
264
264
  );
265
265
  assert.deepEqual(hook(command("npx paperlint submission show nope")), {
266
266
  code: 0,
@@ -272,7 +272,7 @@ test("runHook submission-confirm: in-process — an ask on stdout for a send, no
272
272
  assert.equal(unreadable.code, 0);
273
273
  assert.match(
274
274
  unreadable.out.join(""),
275
- /"permissionDecision":"ask","permissionDecisionReason":"paperlint could not read this command/,
275
+ /"permissionDecision":"deny","permissionDecisionReason":"Not run: paperlint could not read this command/,
276
276
  );
277
277
  });
278
278
 
package/src/cli.ts CHANGED
@@ -1247,7 +1247,7 @@ const IN_PROCESS_HOOKS: Readonly<Record<string, (io: HookIo) => number>> = {
1247
1247
  }
1248
1248
  return 0;
1249
1249
  },
1250
- // Never advisory-silent: an input it cannot read is asked about, not let through.
1250
+ // Never advisory-silent: an input it cannot read is refused, not let through.
1251
1251
  "submission-confirm": ({ stdin, out }) => {
1252
1252
  const said = ((): ReturnType<typeof submissionConfirm> => {
1253
1253
  try {
@@ -1717,7 +1717,7 @@ function projectPresets(cwd: string): readonly Preset[] {
1717
1717
 
1718
1718
  /**
1719
1719
  * The host of every venue portal paperlint knows of from `cwd` — the shipped presets', and those the
1720
- * project's papers extend: what `submission-confirm` asks about when a request goes straight there.
1720
+ * project's papers extend: what `submission-confirm` refuses when a request goes straight there.
1721
1721
  */
1722
1722
  export function portalHosts(cwd: string): readonly string[] {
1723
1723
  return [...resolveShipped(PRESET_DEPS), ...projectPresets(cwd)].flatMap(