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.
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +18 -8
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.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/dist/submission-confirm-hook.d.ts +27 -19
- package/dist/submission-confirm-hook.d.ts.map +1 -1
- package/dist/submission-confirm-hook.js +302 -95
- package/dist/submission-confirm-hook.js.map +1 -1
- package/docs/configuration.md +30 -1
- package/docs/e2e.md +1 -0
- package/docs/layout.md +22 -22
- package/docs/submission.md +32 -8
- 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/skills/submission-portal/SKILL.md +18 -12
- package/skills/submission-portal/SKILL.md.spec.ts +17 -11
- package/src/build.test.ts +46 -3
- package/src/build.ts +20 -8
- package/src/cli.test.ts +3 -3
- package/src/cli.ts +2 -2
- 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/src/submission-confirm-hook.test.ts +493 -60
- package/src/submission-confirm-hook.ts +448 -116
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
|
|
@@ -12,6 +12,7 @@ import { createChecker } from "../lib/check.ts";
|
|
|
12
12
|
import type {
|
|
13
13
|
BibInput,
|
|
14
14
|
Hashes,
|
|
15
|
+
LogFailure,
|
|
15
16
|
Observation,
|
|
16
17
|
State,
|
|
17
18
|
Step,
|
|
@@ -52,6 +53,7 @@ const latex = (o: Partial<LatexPass> = {}): LatexPass => ({
|
|
|
52
53
|
bib: NO_BIB,
|
|
53
54
|
errorLines: [],
|
|
54
55
|
fls: null,
|
|
56
|
+
failing: [],
|
|
55
57
|
...o,
|
|
56
58
|
});
|
|
57
59
|
const bibtex = (o: Partial<BibtexPass> = {}): BibtexPass => ({
|
|
@@ -63,6 +65,14 @@ const bibtex = (o: Partial<BibtexPass> = {}): BibtexPass => ({
|
|
|
63
65
|
errorLines: [],
|
|
64
66
|
...o,
|
|
65
67
|
});
|
|
68
|
+
/** What latex-log reports for the end-of-run "There were undefined references." */
|
|
69
|
+
const undefRefs: LogFailure = {
|
|
70
|
+
id: "undefined-references",
|
|
71
|
+
reason: "a \\ref names a label no pass defined",
|
|
72
|
+
quotes: [
|
|
73
|
+
{ text: "LaTeX Warning: There were undefined references.", times: 1 },
|
|
74
|
+
],
|
|
75
|
+
};
|
|
66
76
|
const kind = (s: Step) =>
|
|
67
77
|
s.kind === "latex" ? `latex${s.final ? "-final" : ""}` : s.kind;
|
|
68
78
|
|
|
@@ -73,7 +83,6 @@ const S = (o: Partial<State> = {}): State => ({
|
|
|
73
83
|
unsettled: [],
|
|
74
84
|
latexPasses: 1,
|
|
75
85
|
finalDone: false,
|
|
76
|
-
warnings: [],
|
|
77
86
|
...o,
|
|
78
87
|
});
|
|
79
88
|
const exitFail: NonNullable<State["failed"]> = {
|
|
@@ -144,15 +153,6 @@ check(
|
|
|
144
153
|
q3cap.cause.kind === "no-convergence" &&
|
|
145
154
|
(q3cap.lines[0] ?? "").includes("paper.aux, labels-changed"),
|
|
146
155
|
);
|
|
147
|
-
check(
|
|
148
|
-
"done carries the state's warnings",
|
|
149
|
-
(() => {
|
|
150
|
-
const done = nextStep(
|
|
151
|
-
S({ finalDone: true, warnings: ["undefined-citations"] }),
|
|
152
|
-
);
|
|
153
|
-
return done.kind === "done" && done.warnings[0] === "undefined-citations";
|
|
154
|
-
})(),
|
|
155
|
-
);
|
|
156
156
|
|
|
157
157
|
// ── 2. summarize over histories ─────────────────────────────────────────────────────────
|
|
158
158
|
const sum = summarize;
|
|
@@ -165,7 +165,6 @@ check(
|
|
|
165
165
|
unsettled: ["paper.aux"],
|
|
166
166
|
latexPasses: 0,
|
|
167
167
|
finalDone: false,
|
|
168
|
-
warnings: [],
|
|
169
168
|
}),
|
|
170
169
|
);
|
|
171
170
|
// Guards: failing on a failed pass — -halt-on-error stops pdflatex at the first error, and a loop
|
|
@@ -222,7 +221,7 @@ check(
|
|
|
222
221
|
// never resolves, so rerunning for it burns the cap and reports no-convergence for a typo.
|
|
223
222
|
check(
|
|
224
223
|
"summarize: undefined references ALONE leave nothing unsettled — on stable files they are a real bad key",
|
|
225
|
-
sum([latex({
|
|
224
|
+
sum([latex({ failing: [undefRefs] })]).unsettled.length === 0,
|
|
226
225
|
);
|
|
227
226
|
check(
|
|
228
227
|
"summarize: bibtex that rewrote the .bbl leaves paper.bbl unsettled",
|
|
@@ -241,20 +240,8 @@ check(
|
|
|
241
240
|
sum([latex(), bibtex(), latex(), latex({ final: true })]).latexPasses === 2,
|
|
242
241
|
);
|
|
243
242
|
check(
|
|
244
|
-
"summarize: finalDone
|
|
245
|
-
(()
|
|
246
|
-
const s = sum([
|
|
247
|
-
latex(),
|
|
248
|
-
latex({
|
|
249
|
-
final: true,
|
|
250
|
-
markers: ["undefined-citations", "rerun-requested"],
|
|
251
|
-
}),
|
|
252
|
-
]);
|
|
253
|
-
return (
|
|
254
|
-
s.finalDone &&
|
|
255
|
-
JSON.stringify(s.warnings) === JSON.stringify(["undefined-citations"])
|
|
256
|
-
);
|
|
257
|
-
})(),
|
|
243
|
+
"summarize: finalDone after a clean final pass",
|
|
244
|
+
sum([latex(), latex({ final: true })]).finalDone,
|
|
258
245
|
);
|
|
259
246
|
|
|
260
247
|
// ── 3. composed: nextStep(summarize(history)) ───────────────────────────────────────────
|
|
@@ -301,7 +288,7 @@ const TABLE: [string, Observation[], string][] = [
|
|
|
301
288
|
],
|
|
302
289
|
[
|
|
303
290
|
"undefined references ALONE do not request a rerun — on stable files they are a real bad key",
|
|
304
|
-
[latex({
|
|
291
|
+
[latex({ failing: [undefRefs] })],
|
|
305
292
|
"latex-final",
|
|
306
293
|
],
|
|
307
294
|
[
|
|
@@ -445,16 +432,65 @@ check(
|
|
|
445
432
|
kind(step(convergedAtCap)) === "latex-final",
|
|
446
433
|
);
|
|
447
434
|
|
|
448
|
-
// ──
|
|
449
|
-
const
|
|
435
|
+
// ── a warning on the list of failing ones (latex-log's FAILING_WARNINGS) fails the build ──
|
|
436
|
+
const lost: LogFailure = {
|
|
437
|
+
id: "lost-character",
|
|
438
|
+
reason: "pdflatex dropped a character its font has no glyph for",
|
|
439
|
+
quotes: [
|
|
440
|
+
{ text: "Missing character: There is no \u207b in font cmr10!", times: 1 },
|
|
441
|
+
{ text: "Missing character: There is no \ufffd in font cmtt10!", times: 3 },
|
|
442
|
+
],
|
|
443
|
+
};
|
|
444
|
+
const lostFinal = step([latex(), latex({ final: true, failing: [lost] })]);
|
|
445
|
+
check(
|
|
446
|
+
"🔴 a failing warning in the FINAL log is a fail: latex, the ids, the reason and every quoted line",
|
|
447
|
+
lostFinal.kind === "fail" &&
|
|
448
|
+
lostFinal.step === "latex" &&
|
|
449
|
+
JSON.stringify(lostFinal.cause) ===
|
|
450
|
+
JSON.stringify({ kind: "log-warnings", ids: ["lost-character"] }) &&
|
|
451
|
+
JSON.stringify(lostFinal.lines) ===
|
|
452
|
+
JSON.stringify([
|
|
453
|
+
"lost-character — pdflatex dropped a character its font has no glyph for:",
|
|
454
|
+
" Missing character: There is no \u207b in font cmr10!",
|
|
455
|
+
" Missing character: There is no \ufffd in font cmtt10! (3 times)",
|
|
456
|
+
]),
|
|
457
|
+
lostFinal,
|
|
458
|
+
);
|
|
459
|
+
check(
|
|
460
|
+
"a failing warning in a NON-final log decides nothing: the loop goes on to the final pass",
|
|
461
|
+
kind(step([latex({ failing: [lost] })])) === "latex-final",
|
|
462
|
+
);
|
|
463
|
+
const lostAndExit = step([
|
|
450
464
|
latex(),
|
|
451
|
-
latex({
|
|
465
|
+
latex({
|
|
466
|
+
final: true,
|
|
467
|
+
exitCode: 1,
|
|
468
|
+
errorLines: ["! Emergency stop."],
|
|
469
|
+
failing: [lost],
|
|
470
|
+
}),
|
|
452
471
|
]);
|
|
453
472
|
check(
|
|
454
|
-
"
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
473
|
+
"a final pass that also exited non-zero fails on its exit code, quoting the error",
|
|
474
|
+
lostAndExit.kind === "fail" &&
|
|
475
|
+
lostAndExit.cause.kind === "exit" &&
|
|
476
|
+
lostAndExit.lines[0] === "! Emergency stop.",
|
|
477
|
+
);
|
|
478
|
+
check(
|
|
479
|
+
"an undefined-references summary in the final log fails too — the same path, its own id",
|
|
480
|
+
(() => {
|
|
481
|
+
const s = step([latex(), latex({ final: true, failing: [undefRefs] })]);
|
|
482
|
+
return (
|
|
483
|
+
s.kind === "fail" &&
|
|
484
|
+
JSON.stringify(s.cause) ===
|
|
485
|
+
JSON.stringify({ kind: "log-warnings", ids: ["undefined-references"] })
|
|
486
|
+
);
|
|
487
|
+
})(),
|
|
488
|
+
);
|
|
489
|
+
check(
|
|
490
|
+
"a final log whose only marker is off the list (a rerun request) is done",
|
|
491
|
+
kind(
|
|
492
|
+
step([latex(), latex({ final: true, markers: ["rerun-requested"] })]),
|
|
493
|
+
) === "done",
|
|
458
494
|
);
|
|
459
495
|
|
|
460
496
|
// ── helpers ─────────────────────────────────────────────────────────────────────────────
|
package/src/latex-loop.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* Both halves are table tests (`latex-loop.harness.ts`), not scenarios that need TeX.
|
|
7
7
|
*
|
|
8
8
|
* ── THE LOOP: `summarize` reduces the history to a `State`, and `nextStep` asks four questions ──
|
|
9
|
-
* 1. did the last program fail
|
|
9
|
+
* 1. did the last program fail — a non-zero exit, or a
|
|
10
|
+
* final log carrying a warning on latex-log's list of
|
|
11
|
+
* failing ones (`FAILING_WARNINGS`)? → fail, with its lines
|
|
10
12
|
* 2. did the citations, a .bib or the style change since
|
|
11
13
|
* bibtex last ran? → bibtex
|
|
12
14
|
* 3. did the last pass change a tracked file, or did the
|
|
@@ -43,12 +45,17 @@ export const TRACKED: readonly Tracked[] = ["aux", "toc", "out", "bbl"];
|
|
|
43
45
|
export type Hashes = { readonly [K in Tracked]: string | null };
|
|
44
46
|
|
|
45
47
|
/** Log markers, as `latex-log.ts` reads them. Re-declared as a type so this module has no imports. */
|
|
46
|
-
export type LogMarker =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
export type LogMarker = "rerun-requested" | "labels-changed" | "rerunfilecheck";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A warning on `latex-log.ts`'s list of those that fail the build, as the log of one pass carries it:
|
|
52
|
+
* its id, why it fails, and each distinct line quoted once with how many times it appeared.
|
|
53
|
+
*/
|
|
54
|
+
export interface LogFailure {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly reason: string;
|
|
57
|
+
readonly quotes: readonly { readonly text: string; readonly times: number }[];
|
|
58
|
+
}
|
|
52
59
|
|
|
53
60
|
const RERUN: readonly LogMarker[] = [
|
|
54
61
|
"rerun-requested",
|
|
@@ -88,6 +95,8 @@ export type Observation =
|
|
|
88
95
|
readonly errorLines: readonly string[];
|
|
89
96
|
/** What `-recorder` wrote after this pass (`paper.fls`), or null when pdflatex wrote none. */
|
|
90
97
|
readonly fls: string | null;
|
|
98
|
+
/** The failing warnings this pass's log carries. Only the final pass's are judged. */
|
|
99
|
+
readonly failing: readonly LogFailure[];
|
|
91
100
|
}
|
|
92
101
|
| {
|
|
93
102
|
readonly step: "bibtex";
|
|
@@ -101,16 +110,13 @@ export type Observation =
|
|
|
101
110
|
|
|
102
111
|
export type FailCause =
|
|
103
112
|
| { readonly kind: "exit"; readonly code: number }
|
|
104
|
-
| { readonly kind: "no-convergence"; readonly unsettled: readonly string[] }
|
|
113
|
+
| { readonly kind: "no-convergence"; readonly unsettled: readonly string[] }
|
|
114
|
+
| { readonly kind: "log-warnings"; readonly ids: readonly string[] };
|
|
105
115
|
|
|
106
116
|
export type Step =
|
|
107
117
|
| { readonly kind: "latex"; readonly final: boolean }
|
|
108
118
|
| { readonly kind: "bibtex" }
|
|
109
|
-
| {
|
|
110
|
-
readonly kind: "done";
|
|
111
|
-
/** Undefined-reference summaries still in the final log — a converged doc with a bad key. */
|
|
112
|
-
readonly warnings: readonly LogMarker[];
|
|
113
|
-
}
|
|
119
|
+
| { readonly kind: "done" }
|
|
114
120
|
| {
|
|
115
121
|
readonly kind: "fail";
|
|
116
122
|
readonly step: "latex" | "bibtex";
|
|
@@ -136,13 +142,11 @@ export type State = {
|
|
|
136
142
|
/** Non-final pdflatex passes so far. */
|
|
137
143
|
readonly latexPasses: number;
|
|
138
144
|
readonly finalDone: boolean;
|
|
139
|
-
/** Undefined-reference summaries in the log of the final pass. */
|
|
140
|
-
readonly warnings: readonly LogMarker[];
|
|
141
145
|
};
|
|
142
146
|
|
|
143
147
|
export function nextStep(s: State): Step {
|
|
144
148
|
if (s.failed) return { kind: "fail", ...s.failed };
|
|
145
|
-
if (s.finalDone) return { kind: "done"
|
|
149
|
+
if (s.finalDone) return { kind: "done" };
|
|
146
150
|
if (s.bibOutdated) return { kind: "bibtex" };
|
|
147
151
|
if (s.unsettled.length === 0) return { kind: "latex", final: true };
|
|
148
152
|
if (s.latexPasses < MAX_PASSES) return { kind: "latex", final: false };
|
|
@@ -183,31 +187,48 @@ function unsettledBy(history: readonly Observation[]): string[] {
|
|
|
183
187
|
return [];
|
|
184
188
|
}
|
|
185
189
|
|
|
190
|
+
/** A failing warning as the human reads it: the id and reason, then each quoted line. */
|
|
191
|
+
const failureLines = (f: LogFailure): readonly string[] => [
|
|
192
|
+
`${f.id} — ${f.reason}:`,
|
|
193
|
+
...f.quotes.map(
|
|
194
|
+
(q) => ` ${q.text}${q.times > 1 ? ` (${String(q.times)} times)` : ""}`,
|
|
195
|
+
),
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Did the last run fail? A non-zero exit, whichever program; or a FINAL pass whose log carries a
|
|
200
|
+
* failing warning. An earlier pass's log is not judged: before the rerun every reference is
|
|
201
|
+
* undefined by construction.
|
|
202
|
+
*/
|
|
203
|
+
function failureOf(last: Observation | undefined): State["failed"] {
|
|
204
|
+
if (last === undefined) return null;
|
|
205
|
+
if (last.exitCode !== 0)
|
|
206
|
+
return {
|
|
207
|
+
step: last.step,
|
|
208
|
+
cause: { kind: "exit", code: last.exitCode },
|
|
209
|
+
lines: last.errorLines,
|
|
210
|
+
};
|
|
211
|
+
if (last.step !== "latex" || !last.final || last.failing.length === 0)
|
|
212
|
+
return null;
|
|
213
|
+
return {
|
|
214
|
+
step: "latex",
|
|
215
|
+
cause: { kind: "log-warnings", ids: last.failing.map((f) => f.id) },
|
|
216
|
+
lines: last.failing.flatMap(failureLines),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
186
220
|
export function summarize(history: readonly Observation[]): State {
|
|
187
221
|
const last = history.at(-1);
|
|
188
222
|
const latexRuns = history.filter((o) => o.step === "latex");
|
|
189
223
|
const lastLatex = latexRuns.at(-1);
|
|
190
224
|
const lastBibtex = history.filter((o) => o.step === "bibtex").at(-1);
|
|
191
225
|
return {
|
|
192
|
-
failed:
|
|
193
|
-
last && last.exitCode !== 0
|
|
194
|
-
? {
|
|
195
|
-
step: last.step,
|
|
196
|
-
cause: { kind: "exit", code: last.exitCode },
|
|
197
|
-
lines: last.errorLines,
|
|
198
|
-
}
|
|
199
|
-
: null,
|
|
226
|
+
failed: failureOf(last),
|
|
200
227
|
bibOutdated:
|
|
201
228
|
lastLatex?.bib.kind === "needed" &&
|
|
202
229
|
!(lastBibtex && sameBib(lastBibtex.bib, lastLatex.bib)),
|
|
203
230
|
unsettled: unsettledBy(history),
|
|
204
231
|
latexPasses: latexRuns.filter((o) => !o.final).length,
|
|
205
232
|
finalDone: last?.step === "latex" && last.final,
|
|
206
|
-
warnings:
|
|
207
|
-
last?.step === "latex"
|
|
208
|
-
? last.markers.filter(
|
|
209
|
-
(m) => m === "undefined-references" || m === "undefined-citations",
|
|
210
|
-
)
|
|
211
|
-
: [],
|
|
212
233
|
};
|
|
213
234
|
}
|