paperlint 11.1.3 → 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.
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +18 -8
- package/dist/build.js.map +1 -1
- package/dist/latex-log.d.ts +61 -26
- package/dist/latex-log.d.ts.map +1 -1
- package/dist/latex-log.js +59 -6
- package/dist/latex-log.js.map +1 -1
- package/dist/latex-loop.d.ts +21 -6
- package/dist/latex-loop.d.ts.map +1 -1
- package/dist/latex-loop.js +32 -12
- package/dist/latex-loop.js.map +1 -1
- package/docs/configuration.md +30 -1
- package/docs/e2e.md +1 -0
- package/fixtures/build-e2e/lost-character/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/lost-character/expect.json +15 -0
- package/fixtures/build-e2e/lost-character/paper.tex +8 -0
- package/package.json +1 -1
- package/src/build.test.ts +46 -3
- package/src/build.ts +20 -8
- package/src/latex-log.harness.ts +145 -14
- package/src/latex-log.ts +105 -16
- package/src/latex-loop.harness.ts +70 -34
- package/src/latex-loop.ts +51 -30
package/docs/configuration.md
CHANGED
|
@@ -399,7 +399,8 @@ papers/my-paper
|
|
|
399
399
|
keys or a `.bib` file changed. After that, one **final** pass defines `\finalpass`, which arms
|
|
400
400
|
the reference guards in `paper-guards.tex`: an undefined `\ref` or `\cite` fails the build
|
|
401
401
|
there instead of printing `??`. A document that still changes after five passes fails, naming
|
|
402
|
-
the file that kept changing.
|
|
402
|
+
the file that kept changing. The final pass's log is then read for the warnings that fail the
|
|
403
|
+
build (below). After a green compile the step also writes
|
|
403
404
|
`_build/sources.json`, the record of what TeX read: every file of the paper directory its passes
|
|
404
405
|
opened (from the `.fls` that `-recorder` makes), whether each came before or after
|
|
405
406
|
`\begin{document}`, the `.bib` files TeX itself wrote, the databases and keys bibtex read, and a
|
|
@@ -452,6 +453,34 @@ without a new one:
|
|
|
452
453
|
paper.pdf removed — a stale PDF must not pass for this build
|
|
453
454
|
```
|
|
454
455
|
|
|
456
|
+
**Some warnings fail the build although pdflatex exits 0**, because the PDF it wrote is wrong. They
|
|
457
|
+
are one list, `FAILING_WARNINGS` in [`src/latex-log.ts`](../src/latex-log.ts), and only the log of
|
|
458
|
+
the **final** pass is read — before the rerun every reference is undefined by construction:
|
|
459
|
+
|
|
460
|
+
| id | the log line | why it fails |
|
|
461
|
+
| ------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
462
|
+
| `lost-character` | `Missing character: There is no <char> in font <font>!` | the font has no glyph for the character, so the PDF lacks it (`10^-13` → `10^13`) |
|
|
463
|
+
| `undefined-references` | `LaTeX Warning: There were undefined references.` | the PDF prints `??` for a `\ref`, or `[?]` for a `\cite` without natbib |
|
|
464
|
+
| `undefined-citations` | `Package natbib Warning: There were undefined citations.` | the PDF prints `[?]` for a `\cite` (natbib) |
|
|
465
|
+
| `multiply-defined-labels` | `LaTeX Warning: There were multiply-defined labels.` | a `\ref` may point at the wrong one of two labels |
|
|
466
|
+
|
|
467
|
+
Every other warning — overfull and underfull boxes, font substitutions, "Rerun to get …" — leaves
|
|
468
|
+
the build as it was. The failure quotes each distinct line once, with how many times the log has it
|
|
469
|
+
(here `\char"FF` in `cmr10`, measured on TeX Live 2026; TeX writes the character as one raw byte,
|
|
470
|
+
which is no UTF-8 character, so it shows as `�`):
|
|
471
|
+
|
|
472
|
+
```
|
|
473
|
+
✗ compile: pdflatex exited 0, but its final log carries a warning that fails the build
|
|
474
|
+
lost-character — the font has no glyph for this character, so pdflatex left it out of the PDF:
|
|
475
|
+
Missing character: There is no � in font cmr10! (2 times)
|
|
476
|
+
full log: papers/my-paper/paper.log
|
|
477
|
+
paper.pdf removed — a stale PDF must not pass for this build
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
⚠️ TeX writes the `Missing character` line only while `\tracinglostchars` is above 0. LaTeX sets it
|
|
481
|
+
to 2 (TeX Live 2026); a paper that sets it to 0 hides the line, and this check sees nothing. At 3
|
|
482
|
+
and above TeX makes it an error, and the build fails on pdflatex's exit code instead.
|
|
483
|
+
|
|
455
484
|
**A paper with no `paper.tex` is a FAILURE, not a skip.** "Nothing to build" and "built" must never
|
|
456
485
|
look alike: the corpus this came from once let a paper reach its venue without a single paper job
|
|
457
486
|
having run on it, because a missing build read as nothing to do.
|
package/docs/e2e.md
CHANGED
|
@@ -98,6 +98,7 @@ A new venue fixture is a new folder: its paper, its `paperlint.json`, its `expec
|
|
|
98
98
|
| `broken` | a failing build names pdflatex and its exit code, quotes the error line and its `l.NNN` context, and deletes the stale `paper.pdf` planted before the run |
|
|
99
99
|
| `no-source` | a paper with no `paper.tex` is named separately, and the run as a whole is a failure |
|
|
100
100
|
| `empty` | a document with no pages: pdflatex exits 0 and writes no PDF, and that is a failure, not a green `paper.pdf` |
|
|
101
|
+
| `lost-character` | pdflatex exits 0 and writes a PDF without a character its font cannot draw (`\char"FF` in cmr10); the build fails, quotes the `Missing character` line from the log and leaves no `paper.pdf` |
|
|
101
102
|
| `aidc` | a real `[conference,compsoc]{IEEEtran}` paper builds on paperlint's TeX Live in NimbusRomNo9L, `tex/template`, `tex/required-section` and the `pdf/*` rules find nothing, and `tex/venue-leftover` warns once, on its planted line. It declares `identity` and names nobody, so `anonymity/identity` is silent on a real build, and `format/page-limit` finds the IEEEtran «References» heading |
|
|
102
103
|
| `anon-*` | six AIDC papers, each planting the declared (invented) name on ONE path, and `anonymity/identity` reports exactly one finding naming where: `author` the author block · `ack` an acknowledgments paragraph · `bib` a self-citation, printed by `IEEEtran.bst` as «A. Example» · `pdfauthor` `\hypersetup{pdfauthor=…}`, on no page · `href` a link whose target is the author's account while its text is not (the anonymous mirror beside it is not reported) · `macro` the name behind `\newcommand` |
|
|
103
104
|
| `msr` | a real `[10pt,conference]{IEEEtran}` paper whose ten pages of main text include an appendix before the references, then two pages of only references: `format/page-limit`, `tex/template` and `anonymity/identity` find nothing. banal's count of the same PDF (body 9, references 3) would have failed it |
|
|
@@ -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
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:
|
|
269
|
-
const
|
|
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.
|
|
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")}
|
|
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/latex-log.harness.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* marker that TeX broke at column 79.
|
|
7
7
|
*/
|
|
8
8
|
import { createChecker } from "../lib/check.ts";
|
|
9
|
-
import type { LogMarker } from "./latex-log.ts";
|
|
9
|
+
import type { FailingWarningId, LogMarker } from "./latex-log.ts";
|
|
10
10
|
|
|
11
11
|
const {
|
|
12
12
|
unwrapLog,
|
|
@@ -16,6 +16,8 @@ const {
|
|
|
16
16
|
bibtexExcerpt,
|
|
17
17
|
auxBib,
|
|
18
18
|
MAX_PRINT_LINE,
|
|
19
|
+
FAILING_WARNINGS,
|
|
20
|
+
failingWarnings,
|
|
19
21
|
} = await import("./latex-log.ts");
|
|
20
22
|
|
|
21
23
|
const check = createChecker();
|
|
@@ -75,16 +77,14 @@ const firstPass = [
|
|
|
75
77
|
];
|
|
76
78
|
const m = logMarkers(firstPass);
|
|
77
79
|
check(
|
|
78
|
-
"every documented marker is recognised",
|
|
79
|
-
(
|
|
80
|
-
[
|
|
80
|
+
"every documented marker is recognised, and the end-of-run summaries are not markers (they fail the build: FAILING_WARNINGS)",
|
|
81
|
+
JSON.stringify(m) ===
|
|
82
|
+
JSON.stringify([
|
|
81
83
|
"rerun-requested",
|
|
82
84
|
"labels-changed",
|
|
83
85
|
"rerunfilecheck",
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
] satisfies LogMarker[]
|
|
87
|
-
).every((k) => m.includes(k)),
|
|
86
|
+
] satisfies LogMarker[]),
|
|
87
|
+
m,
|
|
88
88
|
);
|
|
89
89
|
check(
|
|
90
90
|
"a clean log carries no markers",
|
|
@@ -93,11 +93,6 @@ check(
|
|
|
93
93
|
"Output written on paper.pdf (1 page, 1234 bytes).",
|
|
94
94
|
]).length === 0,
|
|
95
95
|
);
|
|
96
|
-
// Guards: matching the documented summary line rather than the word 'undefined'.
|
|
97
|
-
check(
|
|
98
|
-
"a single reference warning is not the end-of-run summary",
|
|
99
|
-
!logMarkers(firstPass.slice(0, 1)).includes("undefined-references"),
|
|
100
|
-
);
|
|
101
96
|
|
|
102
97
|
// ── error excerpt ───────────────────────────────────────────────────────────────────────
|
|
103
98
|
const undefinedCs = [
|
|
@@ -246,6 +241,142 @@ check(
|
|
|
246
241
|
);
|
|
247
242
|
}
|
|
248
243
|
|
|
244
|
+
// ── warnings that fail the build ────────────────────────────────────────────────────────
|
|
245
|
+
// Every entry of FAILING_WARNINGS has its two halves here — log lines it must find, and lines it
|
|
246
|
+
// must not — and `satisfies Record<FailingWarningId, …>` makes a table entry without a row here a
|
|
247
|
+
// type error; the first check below makes it a red run as well.
|
|
248
|
+
// The lost-character line is copied from a real TeX Live 2026 run (2026-10-26): `\char"FF` in
|
|
249
|
+
// cmr10 under `\documentclass{article}`, which sets `\tracinglostchars=2`; pdflatex exited 0.
|
|
250
|
+
// The log is read as latin1, so the byte 0xFF is the one character "\xff".
|
|
251
|
+
const LOST = "Missing character: There is no \xff in font cmr10!";
|
|
252
|
+
const FAILING_CASES = {
|
|
253
|
+
"lost-character": {
|
|
254
|
+
fires: [
|
|
255
|
+
LOST,
|
|
256
|
+
"Missing character: There is no \xe2 in font LinLibertineT-tlf-ot1!",
|
|
257
|
+
],
|
|
258
|
+
quiet: [
|
|
259
|
+
"Overfull \\hbox (1.2pt too wide) in paragraph at lines 4--5",
|
|
260
|
+
"LaTeX Font Warning: Font shape `OT1/cmr/bx/sc' undefined",
|
|
261
|
+
"LaTeX Warning: Label(s) may have changed. Rerun to get cross-references right.",
|
|
262
|
+
"LaTeX Warning: There were undefined references.",
|
|
263
|
+
// The words inside an unrelated longer line — an overfull box quoting the text it set.
|
|
264
|
+
"[]\\OT1/cmr/m/n/10 Missing character: There is no x in font cmr10!",
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
// The three end-of-run summaries, worded as latex.ltx and natbib.sty print them (TeX Live 2026);
|
|
268
|
+
// LaTeX core has no "undefined citations" summary — an undefined \cite counts as a reference. Guards: matching the
|
|
269
|
+
// summary, not the word "undefined" — one reference warning per \ref is printed on EVERY pass
|
|
270
|
+
// before the rerun, and is not the summary.
|
|
271
|
+
"undefined-references": {
|
|
272
|
+
fires: ["LaTeX Warning: There were undefined references."],
|
|
273
|
+
quiet: [
|
|
274
|
+
"LaTeX Warning: Reference `sec:intro' on page 1 undefined on input line 4.",
|
|
275
|
+
"Package natbib Warning: There were undefined citations.",
|
|
276
|
+
"LaTeX Warning: Label(s) may have changed. Rerun to get cross-references right.",
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
"undefined-citations": {
|
|
280
|
+
fires: ["Package natbib Warning: There were undefined citations."],
|
|
281
|
+
quiet: [
|
|
282
|
+
"LaTeX Warning: Citation `knuth84' on page 1 undefined on input line 7.",
|
|
283
|
+
"Package natbib Warning: Citation `knuth84' on page 1 undefined on input line 7.",
|
|
284
|
+
"LaTeX Warning: There were undefined references.",
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
"multiply-defined-labels": {
|
|
288
|
+
fires: ["LaTeX Warning: There were multiply-defined labels."],
|
|
289
|
+
quiet: [
|
|
290
|
+
"LaTeX Warning: Label `sec:intro' multiply defined.",
|
|
291
|
+
"LaTeX Warning: There were undefined references.",
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
} satisfies Record<
|
|
295
|
+
FailingWarningId,
|
|
296
|
+
{ readonly fires: readonly string[]; readonly quiet: readonly string[] }
|
|
297
|
+
>;
|
|
298
|
+
check(
|
|
299
|
+
"every failing warning in the table has a row of cases, and every row is in the table",
|
|
300
|
+
JSON.stringify(FAILING_WARNINGS.map((w) => w.id).sort()) ===
|
|
301
|
+
JSON.stringify(Object.keys(FAILING_CASES).sort()),
|
|
302
|
+
);
|
|
303
|
+
for (const [id, c] of Object.entries(FAILING_CASES)) {
|
|
304
|
+
for (const line of c.fires)
|
|
305
|
+
check(
|
|
306
|
+
`${id}: fires on ${JSON.stringify(line)}`,
|
|
307
|
+
failingWarnings(["(./paper.aux)", line, ""]).some((h) => h.id === id),
|
|
308
|
+
() => failingWarnings(["(./paper.aux)", line, ""]),
|
|
309
|
+
);
|
|
310
|
+
for (const line of c.quiet)
|
|
311
|
+
check(
|
|
312
|
+
`${id}: quiet on ${JSON.stringify(line)}`,
|
|
313
|
+
!failingWarnings(["(./paper.aux)", line, ""]).some((h) => h.id === id),
|
|
314
|
+
() => failingWarnings(["(./paper.aux)", line, ""]),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
check(
|
|
318
|
+
"a clean log carries no failing warning",
|
|
319
|
+
failingWarnings([
|
|
320
|
+
"This is pdfTeX",
|
|
321
|
+
"Output written on paper.pdf (1 page, 1234 bytes).",
|
|
322
|
+
]).length === 0,
|
|
323
|
+
);
|
|
324
|
+
{
|
|
325
|
+
const log = [
|
|
326
|
+
LOST,
|
|
327
|
+
"(./paper.aux)",
|
|
328
|
+
LOST,
|
|
329
|
+
"Missing character: There is no \xfe in font cmtt10!",
|
|
330
|
+
LOST,
|
|
331
|
+
];
|
|
332
|
+
const hits = failingWarnings(log);
|
|
333
|
+
check(
|
|
334
|
+
"a lost character quotes each distinct line once, in order, with how many times TeX printed it",
|
|
335
|
+
JSON.stringify(hits) ===
|
|
336
|
+
JSON.stringify([
|
|
337
|
+
{
|
|
338
|
+
id: "lost-character",
|
|
339
|
+
reason: FAILING_WARNINGS[0].reason,
|
|
340
|
+
quotes: [
|
|
341
|
+
{ text: LOST, times: 3 },
|
|
342
|
+
{
|
|
343
|
+
text: "Missing character: There is no \xfe in font cmtt10!",
|
|
344
|
+
times: 1,
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
]),
|
|
349
|
+
hits,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
{
|
|
353
|
+
// A long font name pushes the line past column 79: TeX breaks it, and unwrapLog rejoins it.
|
|
354
|
+
const long =
|
|
355
|
+
"Missing character: There is no \xff in font LinLibertineT-tlf-ts1-scaled-1000-extended!";
|
|
356
|
+
const split = [long.slice(0, MAX_PRINT_LINE), long.slice(MAX_PRINT_LINE)];
|
|
357
|
+
check(
|
|
358
|
+
"a lost-character line TeX split at column 79 is rejoined and found, quoted whole",
|
|
359
|
+
split[0]?.length === 79 &&
|
|
360
|
+
JSON.stringify(
|
|
361
|
+
failingWarnings(unwrapLog(split.join("\n")))[0]?.quotes,
|
|
362
|
+
) === JSON.stringify([{ text: long, times: 1 }]),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
{
|
|
366
|
+
// A line of exactly 79 bytes by itself is joined to the next one (unwrapLog's documented false
|
|
367
|
+
// join): the marker then starts at byte 79 of the joined line, where the physical line began.
|
|
368
|
+
const before = "x".repeat(MAX_PRINT_LINE);
|
|
369
|
+
const joined = unwrapLog([before, LOST, ""].join("\n"));
|
|
370
|
+
check(
|
|
371
|
+
"a lost-character line glued by a false join to a 79-byte line before it is still found",
|
|
372
|
+
joined[0] === before + LOST && failingWarnings(joined).length === 1,
|
|
373
|
+
);
|
|
374
|
+
check(
|
|
375
|
+
"the marker words NOT at a physical line start (byte 78) are not read as one",
|
|
376
|
+
failingWarnings(["y".repeat(MAX_PRINT_LINE - 1) + LOST]).length === 0,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
249
380
|
console.log(
|
|
250
|
-
`✓ ${String(check.count)} assertions passed — latex-log: markers across the 79-column wrap, both error spellings, aux`,
|
|
381
|
+
`✓ ${String(check.count)} assertions passed — latex-log: markers across the 79-column wrap, both error spellings, aux, warnings that fail the build`,
|
|
251
382
|
);
|
package/src/latex-log.ts
CHANGED
|
@@ -19,25 +19,19 @@
|
|
|
19
19
|
* must therefore read the log as `latin1`, so one character is one byte and `length` counts what
|
|
20
20
|
* TeX counted.
|
|
21
21
|
*/
|
|
22
|
+
import { filter, map, pipe, range, unique } from "remeda";
|
|
22
23
|
|
|
23
24
|
/** TeX Live's `max_print_line`. A physical log line of exactly this many bytes was broken by TeX. */
|
|
24
25
|
export const MAX_PRINT_LINE = 79;
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
* Log lines that ask for another pass
|
|
28
|
-
* not a guess about wording:
|
|
28
|
+
* Log lines that ask for another pass. Each is a documented message, not a guess about wording:
|
|
29
29
|
* - `rerun-requested` — any "Rerun to get …" (LaTeX core, natbib, hyperref's outlines, …)
|
|
30
30
|
* - `labels-changed` — "LaTeX Warning: Label(s) may have changed."
|
|
31
31
|
* - `rerunfilecheck` — "Package rerunfilecheck Warning: File `…' has changed."
|
|
32
|
-
*
|
|
33
|
-
* request a rerun by themselves: on a converged document they mean a genuinely undefined key.
|
|
32
|
+
* The end-of-run summaries about references are not markers: they fail the build (`FAILING_WARNINGS`).
|
|
34
33
|
*/
|
|
35
|
-
export type LogMarker =
|
|
36
|
-
| "rerun-requested"
|
|
37
|
-
| "labels-changed"
|
|
38
|
-
| "rerunfilecheck"
|
|
39
|
-
| "undefined-references"
|
|
40
|
-
| "undefined-citations";
|
|
34
|
+
export type LogMarker = "rerun-requested" | "labels-changed" | "rerunfilecheck";
|
|
41
35
|
|
|
42
36
|
/**
|
|
43
37
|
* Rejoin the lines TeX broke at column 79. A line of exactly 79 bytes continues on the next one.
|
|
@@ -71,21 +65,116 @@ export function logMarkers(lines: readonly string[]): LogMarker[] {
|
|
|
71
65
|
found.add("labels-changed");
|
|
72
66
|
if (line.startsWith("Package rerunfilecheck Warning:"))
|
|
73
67
|
found.add("rerunfilecheck");
|
|
74
|
-
if (line.includes("There were undefined references"))
|
|
75
|
-
found.add("undefined-references");
|
|
76
|
-
if (line.includes("There were undefined citations"))
|
|
77
|
-
found.add("undefined-citations");
|
|
78
68
|
}
|
|
79
69
|
const order: LogMarker[] = [
|
|
80
70
|
"rerun-requested",
|
|
81
71
|
"labels-changed",
|
|
82
72
|
"rerunfilecheck",
|
|
83
|
-
"undefined-references",
|
|
84
|
-
"undefined-citations",
|
|
85
73
|
];
|
|
86
74
|
return order.filter((m) => found.has(m));
|
|
87
75
|
}
|
|
88
76
|
|
|
77
|
+
/** A log warning that fails the build: what it is called, why it fails, and the lines that carry it. */
|
|
78
|
+
export interface FailingWarning {
|
|
79
|
+
readonly id: string;
|
|
80
|
+
/** One line a human reads before the quoted log lines. */
|
|
81
|
+
readonly reason: string;
|
|
82
|
+
/** Whether this logical line (after `unwrapLog`) carries it. */
|
|
83
|
+
readonly matches: (line: string) => boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Does `prefix` start one of the PHYSICAL lines this logical line was joined from? `unwrapLog` joins
|
|
88
|
+
* only lines of exactly 79 bytes, so every physical line starts at a multiple of 79. That keeps a
|
|
89
|
+
* marker found after unwrapLog's false join (a 79-byte line by itself, then the marker) and still
|
|
90
|
+
* ignores the same words in the middle of a line — an overfull box quoting the text it set.
|
|
91
|
+
*/
|
|
92
|
+
const startsPhysicalLine = (line: string, prefix: string): boolean =>
|
|
93
|
+
range(0, Math.floor(line.length / MAX_PRINT_LINE) + 1).some((k) =>
|
|
94
|
+
line.startsWith(prefix, k * MAX_PRINT_LINE),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 🔴 THE WARNINGS THAT FAIL THE BUILD — like an ESLint rule at "error". pdflatex exits 0 on each of
|
|
99
|
+
* them and writes a PDF that is wrong: a character missing, `??` or `[?]` in the text, a reference
|
|
100
|
+
* that may point at the wrong place. Only the log of the FINAL pass is judged (`latex-loop.ts`):
|
|
101
|
+
* before the rerun every reference is undefined by construction.
|
|
102
|
+
*
|
|
103
|
+
* WHY THESE FOUR. Each makes the PDF wrong for a reader, and turning all four on was measured on four
|
|
104
|
+
* real papers (their final logs of 2026-10-26): 0 hits each, so no correct paper turns red. Left out: overfull boxes (cosmetic,
|
|
105
|
+
* nearly every paper has some, and the pdf/* rules check the margins), font shape substitution
|
|
106
|
+
* (usually harmless), "Rerun to get" (the loop already reruns). Those never fail a build.
|
|
107
|
+
*
|
|
108
|
+
* Adding one is one entry here, plus its row of cases in `latex-log.harness.ts` (the harness does
|
|
109
|
+
* not type-check without it).
|
|
110
|
+
*
|
|
111
|
+
* `lost-character`: TeX prints "Missing character: There is no X in font F!" when
|
|
112
|
+
* `\tracinglostchars` > 0; LaTeX's format sets it to 2 (TeX Live 2026, `latex.ltx`), and pdflatex
|
|
113
|
+
* still exits 0. A paper that sets it to 0 silences the line, and this check with it. At 3 or more
|
|
114
|
+
* TeX makes it an error, and the build fails on the exit code instead.
|
|
115
|
+
*/
|
|
116
|
+
export const FAILING_WARNINGS = [
|
|
117
|
+
{
|
|
118
|
+
id: "lost-character",
|
|
119
|
+
reason:
|
|
120
|
+
"the font has no glyph for this character, so pdflatex left it out of the PDF",
|
|
121
|
+
matches: (l: string) =>
|
|
122
|
+
startsPhysicalLine(l, "Missing character: There is no "),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "undefined-references",
|
|
126
|
+
reason:
|
|
127
|
+
"a \\ref — or, without natbib, a \\cite — names what no pass defined, so the PDF prints ?? or [?] in its place",
|
|
128
|
+
matches: (l: string) => l.includes("There were undefined references"),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "undefined-citations",
|
|
132
|
+
reason:
|
|
133
|
+
"a \\cite names a key the bibliography does not hold, so the PDF prints [?] in its place (natbib's summary)",
|
|
134
|
+
matches: (l: string) => l.includes("There were undefined citations"),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "multiply-defined-labels",
|
|
138
|
+
reason: "two \\label share a name, so a \\ref may point at the wrong one",
|
|
139
|
+
matches: (l: string) => l.includes("There were multiply-defined labels"),
|
|
140
|
+
},
|
|
141
|
+
] as const satisfies readonly FailingWarning[];
|
|
142
|
+
|
|
143
|
+
export type FailingWarningId = (typeof FAILING_WARNINGS)[number]["id"];
|
|
144
|
+
|
|
145
|
+
/** One distinct log line, quoted as TeX wrote it, and how many times the log carries it. */
|
|
146
|
+
export interface Quote {
|
|
147
|
+
readonly text: string;
|
|
148
|
+
readonly times: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A failing warning the log carries, with the lines that carry it. */
|
|
152
|
+
export interface FailingHit {
|
|
153
|
+
readonly id: FailingWarningId;
|
|
154
|
+
readonly reason: string;
|
|
155
|
+
readonly quotes: readonly Quote[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const quotesOf = (matched: readonly string[]): readonly Quote[] =>
|
|
159
|
+
map(unique(matched), (text) => ({
|
|
160
|
+
text,
|
|
161
|
+
times: matched.filter((l) => l === text).length,
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
/** Which of `FAILING_WARNINGS` the log carries, in table order, each line quoted once with a count. */
|
|
165
|
+
export const failingWarnings = (
|
|
166
|
+
lines: readonly string[],
|
|
167
|
+
): readonly FailingHit[] =>
|
|
168
|
+
pipe(
|
|
169
|
+
FAILING_WARNINGS,
|
|
170
|
+
map((w) => ({
|
|
171
|
+
id: w.id,
|
|
172
|
+
reason: w.reason,
|
|
173
|
+
quotes: quotesOf(lines.filter((l) => w.matches(l))),
|
|
174
|
+
})),
|
|
175
|
+
filter((h) => h.quotes.length > 0),
|
|
176
|
+
);
|
|
177
|
+
|
|
89
178
|
/**
|
|
90
179
|
* Is this the line a TeX error starts on? Two spellings exist and `-file-line-error` produces
|
|
91
180
|
* both: most errors become `./paper.tex:4: Undefined control sequence.`, while some keep the
|