paperlint 2.1.0 → 3.1.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 (219) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +243 -235
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build-engine.d.ts.map +1 -1
  13. package/dist/build-engine.js +6 -2
  14. package/dist/build-engine.js.map +1 -1
  15. package/dist/build.d.ts +15 -0
  16. package/dist/build.d.ts.map +1 -1
  17. package/dist/build.js +60 -7
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +94 -30
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +500 -288
  22. package/dist/cli.js.map +1 -1
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +40 -72
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/engine.d.ts +9 -0
  27. package/dist/engine.d.ts.map +1 -1
  28. package/dist/engine.js +27 -0
  29. package/dist/engine.js.map +1 -1
  30. package/dist/facts-file.d.ts +2 -2
  31. package/dist/facts-file.js +2 -2
  32. package/dist/hooks-settings.d.ts +0 -21
  33. package/dist/hooks-settings.d.ts.map +1 -1
  34. package/dist/hooks-settings.js +17 -135
  35. package/dist/hooks-settings.js.map +1 -1
  36. package/dist/init.d.ts +39 -29
  37. package/dist/init.d.ts.map +1 -1
  38. package/dist/init.js +144 -173
  39. package/dist/init.js.map +1 -1
  40. package/dist/link-skills.d.ts +1 -1
  41. package/dist/link-skills.d.ts.map +1 -1
  42. package/dist/link-skills.js +11 -29
  43. package/dist/link-skills.js.map +1 -1
  44. package/dist/new-paper.d.ts +8 -1
  45. package/dist/new-paper.d.ts.map +1 -1
  46. package/dist/new-paper.js +49 -25
  47. package/dist/new-paper.js.map +1 -1
  48. package/dist/paper-files.d.ts +85 -0
  49. package/dist/paper-files.d.ts.map +1 -0
  50. package/dist/paper-files.js +96 -0
  51. package/dist/paper-files.js.map +1 -0
  52. package/dist/paper-settings.d.ts +13 -41
  53. package/dist/paper-settings.d.ts.map +1 -1
  54. package/dist/paper-settings.js +71 -71
  55. package/dist/paper-settings.js.map +1 -1
  56. package/dist/ports/check-references.d.ts +31 -0
  57. package/dist/ports/check-references.d.ts.map +1 -0
  58. package/dist/ports/check-references.js +7 -0
  59. package/dist/ports/check-references.js.map +1 -0
  60. package/dist/presets.d.ts +1 -1
  61. package/dist/presets.d.ts.map +1 -1
  62. package/dist/presets.js +4 -6
  63. package/dist/presets.js.map +1 -1
  64. package/dist/reference-rules.d.ts +47 -0
  65. package/dist/reference-rules.d.ts.map +1 -0
  66. package/dist/reference-rules.js +156 -0
  67. package/dist/reference-rules.js.map +1 -0
  68. package/dist/references.d.ts +36 -0
  69. package/dist/references.d.ts.map +1 -0
  70. package/dist/references.js +91 -0
  71. package/dist/references.js.map +1 -0
  72. package/dist/rules-config.d.ts +5 -3
  73. package/dist/rules-config.d.ts.map +1 -1
  74. package/dist/rules-config.js +15 -6
  75. package/dist/rules-config.js.map +1 -1
  76. package/dist/structure.js +3 -3
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.js +2 -2
  79. package/dist/tex-requirements.js.map +1 -1
  80. package/dist/toolchain.d.ts +5 -1
  81. package/dist/toolchain.d.ts.map +1 -1
  82. package/dist/toolchain.js +22 -5
  83. package/dist/toolchain.js.map +1 -1
  84. package/dist/types.d.ts +26 -18
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/venue-rules.d.ts.map +1 -1
  87. package/dist/venue-rules.js +12 -10
  88. package/dist/venue-rules.js.map +1 -1
  89. package/docs/configuration.md +163 -104
  90. package/docs/e2e.md +1 -1
  91. package/docs/install.md +25 -40
  92. package/docs/optional-rules.md +5 -4
  93. package/docs/rules.md +65 -24
  94. package/docs/skills.md +80 -0
  95. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  96. package/eslint-rules/latex-language.mjs +29 -1
  97. package/eslint-rules/paper-stages.harness.mjs +1 -117
  98. package/eslint-rules/paper-stages.mjs +0 -120
  99. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  100. package/eslint-rules/paper-typography.mjs +306 -191
  101. package/eslint-rules/paper-typography.test.mjs +337 -0
  102. package/eslint-rules/papers.harness.mjs +8 -12
  103. package/eslint-rules/papers.mjs +17 -31
  104. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  105. package/eslint-rules/review-frontmatter.mjs +118 -0
  106. package/eslint-rules/review-frontmatter.schema.json +36 -0
  107. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  108. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  109. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  110. package/eslint.config.mjs +0 -15
  111. package/fixtures/real-markdown-paper/baseline.json +6 -7
  112. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  113. package/fixtures/toolchain-mirror/install-tl +4 -1
  114. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  115. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  116. package/hooks/hooks.harness.mjs +39 -98
  117. package/hooks/hooks.mutations.mjs +6 -6
  118. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  119. package/hooks/paper-edit-guard.hook.mjs +31 -42
  120. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  121. package/hooks/paper-status-gates.hook.mjs +21 -19
  122. package/hooks/paper-status-gates.sh +8 -13
  123. package/lib/paper-config.d.mts +15 -25
  124. package/lib/paper-config.harness.mjs +18 -35
  125. package/lib/paper-config.mjs +83 -105
  126. package/lib/paper-config.mutations.mjs +2 -2
  127. package/lib/skill-checks.mjs +1 -1
  128. package/lib/skill-trigger-cases.harness.mjs +9 -8
  129. package/lib/skill-trigger-cases.mjs +7 -15
  130. package/package.json +1 -1
  131. package/scripts/eslint-report-guard.mjs +1 -1
  132. package/scripts/harness-api.frozen.json +0 -3
  133. package/scripts/mutation-batteries.frozen.json +5 -14
  134. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  135. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  136. package/skills/cold-read-diff/SKILL.md +20 -1
  137. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  138. package/skills/grade-paper-writing/SKILL.md +20 -1
  139. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/render-paper.harness.mjs +2 -2
  151. package/skills/verify-citations/SKILL.md +10 -1
  152. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  153. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  154. package/src/adapters/references/index.ts +102 -0
  155. package/src/adapters/references/reach.io.ts +12 -0
  156. package/src/build-engine.ts +6 -1
  157. package/src/build.harness.mjs +54 -6
  158. package/src/build.ts +89 -11
  159. package/src/cli.harness.mjs +149 -294
  160. package/src/cli.mutations.mjs +35 -44
  161. package/src/cli.ts +637 -328
  162. package/src/doctor.harness.mjs +41 -84
  163. package/src/doctor.mutations.mjs +9 -9
  164. package/src/doctor.ts +44 -89
  165. package/src/engine.harness.mjs +50 -0
  166. package/src/engine.ts +35 -0
  167. package/src/facts-file.test.ts +0 -7
  168. package/src/facts-file.ts +2 -2
  169. package/src/hooks-settings.harness.mjs +7 -92
  170. package/src/hooks-settings.mutations.mjs +2 -17
  171. package/src/hooks-settings.ts +18 -162
  172. package/src/init.test.ts +206 -0
  173. package/src/init.ts +187 -231
  174. package/src/link-skills.harness.mjs +1 -38
  175. package/src/link-skills.mutations.mjs +9 -5
  176. package/src/link-skills.ts +10 -34
  177. package/src/lint-fix.test.ts +120 -0
  178. package/src/linted-set.test.ts +118 -0
  179. package/src/new-paper.harness.mjs +2 -0
  180. package/src/new-paper.mutations.mjs +0 -14
  181. package/src/new-paper.test.ts +173 -14
  182. package/src/new-paper.ts +82 -30
  183. package/src/paper-files.test.ts +395 -0
  184. package/src/paper-files.ts +200 -0
  185. package/src/paper-settings-commands.test.ts +121 -122
  186. package/src/paper-settings.test.ts +93 -64
  187. package/src/paper-settings.ts +99 -97
  188. package/src/parse-args.test.ts +48 -0
  189. package/src/ports/check-references.ts +28 -0
  190. package/src/presets.ts +5 -15
  191. package/src/reference-rules.ts +229 -0
  192. package/src/references.test.ts +209 -0
  193. package/src/references.ts +138 -0
  194. package/src/rules-config.harness.mjs +24 -8
  195. package/src/rules-config.ts +19 -7
  196. package/src/rules-off.test.ts +111 -0
  197. package/src/structure.harness.mjs +4 -2
  198. package/src/structure.ts +3 -3
  199. package/src/tex-requirements.ts +2 -2
  200. package/src/toolchain.harness.mjs +41 -6
  201. package/src/toolchain.ts +29 -5
  202. package/src/types.ts +36 -18
  203. package/src/venue-rules.test.ts +23 -11
  204. package/src/venue-rules.ts +12 -14
  205. package/templates/paper/paperlint.json +1 -1
  206. package/eslint-rules/doc-fields.harness.mjs +0 -336
  207. package/eslint-rules/doc-fields.mjs +0 -186
  208. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  209. package/eslint-rules/paper-typography.harness.mjs +0 -291
  210. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  211. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  212. package/eslint-rules/review-findings-cause.mjs +0 -135
  213. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  214. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  215. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  216. package/fixtures/review-findings-cause/clean.md +0 -17
  217. package/fixtures/review-findings-cause/defect.md +0 -14
  218. package/fixtures/review-findings-cause/old-debt.md +0 -14
  219. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The online reference checks, as the `CheckReferences` port: `verify-cites` (the cited work
3
+ * exists, the title matches — Crossref, OpenAlex, Semantic Scholar, arXiv) and `bib-authors` (the
4
+ * authors are the published version's — DBLP). Both ship in this package's verify-citations skill;
5
+ * this adapter only runs them and shapes their answers.
6
+ *
7
+ * "Not checked" is decided up front, by one request: when Crossref cannot be reached at all, the
8
+ * checkers would degrade every entry to `unresolvable` / `unchecked`, which reads like a result.
9
+ * Saying `not-checked` with the reason is the honest record.
10
+ */
11
+ import type {
12
+ CheckReferences,
13
+ EntryVerdict,
14
+ } from "../../ports/check-references.ts";
15
+ import { unreachable } from "./reach.io.ts";
16
+ // @ts-expect-error — a skill script in .mjs, it has no types
17
+ import * as cites from "../../../skills/verify-citations/scripts/verify-cites.mjs";
18
+ // @ts-expect-error — a skill script in .mjs, it has no types
19
+ import * as authors from "../../../skills/verify-citations/scripts/bib-authors.mjs";
20
+
21
+ interface CiteResult {
22
+ readonly id: string;
23
+ readonly verdict: "true" | "false" | "unresolvable";
24
+ readonly reason?: string;
25
+ }
26
+ interface AuthorFinding {
27
+ readonly key: string;
28
+ readonly venue: string;
29
+ readonly missing: readonly string[];
30
+ readonly extra: readonly string[];
31
+ readonly orderDiffers: boolean;
32
+ }
33
+ interface KeyWhy {
34
+ readonly key: string;
35
+ readonly why: string;
36
+ }
37
+
38
+ const describeAuthors = (f: AuthorFinding): string =>
39
+ [
40
+ f.missing.length ? `missing ${f.missing.join(", ")}` : "",
41
+ f.extra.length ? `extra ${f.extra.join(", ")}` : "",
42
+ f.orderDiffers ? "order differs" : "",
43
+ ]
44
+ .filter(Boolean)
45
+ .join("; ") + ` (DBLP: ${f.venue})`;
46
+
47
+ interface AuthorBuckets {
48
+ readonly findings: readonly AuthorFinding[];
49
+ readonly skipped: readonly KeyWhy[];
50
+ readonly unchecked: readonly KeyWhy[];
51
+ readonly matched: readonly string[];
52
+ }
53
+
54
+ const authorsOf = (key: string, a: AuthorBuckets): EntryVerdict["authors"] =>
55
+ a.findings.some((f) => f.key === key)
56
+ ? "mismatch"
57
+ : a.unchecked.some((u) => u.key === key)
58
+ ? "unchecked"
59
+ : a.matched.includes(key)
60
+ ? "match"
61
+ : "skipped";
62
+
63
+ /** One entry's verdict from the two checkers' answers. */
64
+ function entryVerdict(
65
+ key: string,
66
+ found: readonly CiteResult[],
67
+ a: AuthorBuckets,
68
+ ): EntryVerdict {
69
+ const c = found.find((x) => x.id === key);
70
+ const mismatch = a.findings.find((f) => f.key === key);
71
+ const why = [
72
+ c && c.verdict !== "true" ? c.reason : undefined,
73
+ mismatch ? describeAuthors(mismatch) : undefined,
74
+ a.unchecked.find((u) => u.key === key)?.why,
75
+ ]
76
+ .filter(Boolean)
77
+ .join("; ");
78
+ return {
79
+ key,
80
+ exists: c?.verdict ?? "unresolvable",
81
+ authors: authorsOf(key, a),
82
+ ...(why ? { why } : {}),
83
+ };
84
+ }
85
+
86
+ export const onlineReferences: CheckReferences = async (bib) => {
87
+ const why = await unreachable();
88
+ if (why !== null) return { kind: "not-checked", why };
89
+ const cache = {};
90
+ const found: CiteResult[] = [];
91
+ for (const c of cites.parseBib(bib) as { id?: string }[])
92
+ if (c.id) found.push(await cites.verifyCitationLive(c, { cache }));
93
+ const a = (await authors.checkAuthors(
94
+ authors.parseBib(bib),
95
+ )) as AuthorBuckets;
96
+ const keys = new Set([
97
+ ...found.map((c) => c.id),
98
+ ...a.findings.map((f) => f.key),
99
+ ]);
100
+ const entries = [...keys].map((key) => entryVerdict(key, found, a));
101
+ return { kind: "checked", entries };
102
+ };
@@ -0,0 +1,12 @@
1
+ /** Whether the citation services answer at all — the one request that decides "not checked". */
2
+ export async function unreachable(): Promise<string | null> {
3
+ try {
4
+ await fetch("https://api.crossref.org/", {
5
+ method: "HEAD",
6
+ signal: AbortSignal.timeout(10_000),
7
+ });
8
+ return null;
9
+ } catch (e) {
10
+ return `the citation services cannot be reached (${(e as Error).message})`;
11
+ }
12
+ }
@@ -13,6 +13,7 @@ import { homedir } from "node:os";
13
13
  import { delimiter, join } from "node:path";
14
14
  import {
15
15
  resolveEngine,
16
+ missingDependencies,
16
17
  probeTree,
17
18
  supportedPlatform,
18
19
  whichOnPath,
@@ -103,7 +104,11 @@ export function gatherFacts(o: Resolved): EngineFacts {
103
104
  (tree): TreeProbe => ({
104
105
  label: `TeX Live ${tree.year} — paperlint cache (${tree.dir})`,
105
106
  bin: tree.bin,
106
- missing: probeTree(tree.bin, o.tex.packages, o.run),
107
+ // Only a cache tree is asked for its dependencies: its tlmgr writes where we own.
108
+ missing: [
109
+ ...probeTree(tree.bin, o.tex.packages, o.run),
110
+ ...missingDependencies(tree.bin, o.run),
111
+ ],
107
112
  }),
108
113
  );
109
114
  const cache = usableTree(probes, (p) => p.missing.length === 0);
@@ -74,6 +74,10 @@ function fakeTex({
74
74
  log = "",
75
75
  missing = false,
76
76
  noPdf = false,
77
+ // `noLog`: pdflatex that dies before it opens paper.log (a format it cannot load); what it
78
+ // printed is `stdout`.
79
+ noLog = false,
80
+ stdout = "",
77
81
  } = {}) {
78
82
  const calls = [];
79
83
  const run = (bin, args, opts) => {
@@ -85,11 +89,11 @@ function fakeTex({
85
89
  };
86
90
  if (bin === "pdflatex") {
87
91
  writeFileSync(join(opts.cwd, "paper.aux"), aux);
88
- writeFileSync(join(opts.cwd, "paper.log"), log);
92
+ if (!noLog) writeFileSync(join(opts.cwd, "paper.log"), log);
89
93
  // `noPdf`: what real pdflatex does on a document with no pages — "No pages of output.", exit 0.
90
94
  if (exitCode === 0 && !noPdf)
91
95
  writeFileSync(join(opts.cwd, "paper.pdf"), "%PDF-fake");
92
- return { status: exitCode, stdout: "", stderr: "" };
96
+ return { status: exitCode, stdout, stderr: "" };
93
97
  }
94
98
  if (bin === "bibtex") {
95
99
  writeFileSync(join(opts.cwd, "paper.bbl"), "\\begin{thebibliography}{1}");
@@ -273,10 +277,14 @@ try {
273
277
  // ── the measure step: facts for the lint rules ────────────────────────────────────────
274
278
  const factsFile = join(clean, "_build", "paper.facts.json");
275
279
  check(
276
- "measure: the plan's last step writes the facts, and says it judges nothing",
277
- r.plan.at(-1)?.step === "measure" &&
278
- /pdf\.js → _build\/paper\.facts\.json/.test(r.plan.at(-1)?.why ?? "") &&
279
- /nothing is judged/.test(r.plan.at(-1)?.why ?? ""),
280
+ "measure: the plan's measure step writes the facts, and says it judges nothing",
281
+ r.plan.find((p) => p.step === "measure") !== undefined &&
282
+ /pdf\.js → _build\/paper\.facts\.json/.test(
283
+ r.plan.find((p) => p.step === "measure")?.why ?? "",
284
+ ) &&
285
+ /nothing is judged/.test(
286
+ r.plan.find((p) => p.step === "measure")?.why ?? "",
287
+ ),
280
288
  );
281
289
  check(
282
290
  "🔴 measure: a green build wrote _build/paper.facts.json through the readPdf port, on paper.pdf",
@@ -391,6 +399,46 @@ try {
391
399
  check("…and says the PDF was removed", /paper\.pdf removed/.test(shown));
392
400
  check("a failed pass is not retried", ftex.calls.length === 1);
393
401
 
402
+ // ── pdflatex dies before writing a log ────────────────────────────────────────────────
403
+ // Seen 2026-09-26: a TeX Live whose pdflatex format was never built. pdflatex exits 1 having
404
+ // printed the reason, and paper.log is either absent or left over from an earlier build.
405
+ const nolog = paper("nolog", {
406
+ "paper.tex": CLEAN_TEX,
407
+ "paper.log": "./paper.tex:9: OLD ERROR FROM YESTERDAY\nl.9 \\old\n",
408
+ });
409
+ const dieTex = fakeTex({
410
+ exitCode: 1,
411
+ noLog: true,
412
+ stdout:
413
+ "This is pdfTeX, Version 3.141592653-2.6-1.40.28 (TeX Live 2026) (INITEX)\n" +
414
+ "I can't find the format file `pdflatex.fmt'!\n",
415
+ });
416
+ const rn = await buildPaper(nolog, {
417
+ cwd: root,
418
+ readPdf: fakeRead,
419
+ run: dieTex.run,
420
+ env: ENV,
421
+ log: quiet,
422
+ });
423
+ const shownN = formatResult(rn);
424
+ // Guards: the reason pdflatex printed reaches the human when there is no log to quote.
425
+ check(
426
+ "🔴 no log: the failure shows what pdflatex printed",
427
+ rn.status === "failed" && shownN.includes("I can't find the format file"),
428
+ shownN,
429
+ );
430
+ // Guards: a log from an earlier build is never quoted as this build's error.
431
+ check(
432
+ "🔴 no log: a stale paper.log from an earlier build is not quoted",
433
+ !shownN.includes("OLD ERROR FROM YESTERDAY"),
434
+ shownN,
435
+ );
436
+ check(
437
+ "no log: it does not point at a log file that this run did not write",
438
+ !shownN.includes("full log:") && shownN.includes("wrote no paper.log"),
439
+ shownN,
440
+ );
441
+
394
442
  // ── pdflatex not installed ────────────────────────────────────────────────────────────
395
443
  const rm = await buildPaper(clean, {
396
444
  cwd: root,
package/src/build.ts CHANGED
@@ -45,7 +45,7 @@ import { delimiter, join, relative } from "node:path";
45
45
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
46
46
  import { getParser } from "@unified-latex/unified-latex-util-parse";
47
47
  import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
48
- import { PAPER_SETTINGS_FILE } from "../lib/paper-config.mjs";
48
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
49
49
  import {
50
50
  declaredVenue,
51
51
  factsPath,
@@ -64,6 +64,8 @@ import { whyNoGeometry } from "./domain/geometry.ts";
64
64
  import type { AbsolutePath } from "./domain/paths.ts";
65
65
  import type { Files } from "./ports/files.ts";
66
66
  import type { MeasureGeometry } from "./ports/measure-geometry.ts";
67
+ import type { CheckReferences } from "./ports/check-references.ts";
68
+ import { recordReferences, REFERENCES_FILE } from "./references.ts";
67
69
  import {
68
70
  auxBib,
69
71
  bibtexExcerpt,
@@ -87,7 +89,7 @@ export const PAPER_MARKERS = [
87
89
  "PIPELINE-STATUS.md",
88
90
  "paper.tex",
89
91
  "paper.md",
90
- PAPER_SETTINGS_FILE,
92
+ CONFIG_FILE,
91
93
  ];
92
94
 
93
95
  /** The source paperlint compiles, and the job name every output file carries. */
@@ -135,6 +137,8 @@ export interface BuildContext {
135
137
  readonly measure: MeasureGeometry;
136
138
  /** Where the facts file is written. */
137
139
  readonly files: Files;
140
+ /** The online reference checks; the CLI wires the real ones, a test passes a function. */
141
+ readonly checkReferences: CheckReferences;
138
142
  }
139
143
 
140
144
  export type StepOutcome =
@@ -350,9 +354,8 @@ function latexPass(
350
354
  const r = ctx.run("pdflatex", pdflatexArgs(final), opts);
351
355
  if (r.error) return null;
352
356
  const exitCode = r.status ?? 1;
353
- const log = unwrapLog(
354
- readOr(join(ctx.paperDir, `${JOB}.log`), "latin1") ?? "",
355
- );
357
+ const written = readOr(join(ctx.paperDir, `${JOB}.log`), "latin1");
358
+ const log = unwrapLog(written ?? "");
356
359
  return {
357
360
  step: "latex",
358
361
  final,
@@ -361,10 +364,30 @@ function latexPass(
361
364
  after: hashes(ctx.paperDir),
362
365
  markers: logMarkers(log),
363
366
  bib: bibInput(ctx.paperDir),
364
- errorLines: exitCode === 0 ? [] : errorExcerpt(log).map(fromLatin1),
367
+ errorLines:
368
+ exitCode === 0
369
+ ? []
370
+ : written === null
371
+ ? printed(r)
372
+ : errorExcerpt(log).map(fromLatin1),
365
373
  };
366
374
  }
367
375
 
376
+ /**
377
+ * The last lines pdflatex printed, for the run that wrote no log — it died before opening one (a
378
+ * format it cannot load, a missing TeX Live file). Marked by NO_LOG, which the report keys on.
379
+ */
380
+ function printed(r: { stdout?: unknown; stderr?: unknown }): string[] {
381
+ const lines = fromLatin1(
382
+ `${String(r.stdout ?? "")}\n${String(r.stderr ?? "")}`,
383
+ )
384
+ .split("\n")
385
+ .filter((l) => l.trim());
386
+ return [NO_LOG, ...lines.slice(-8).map((l) => ` ${l}`)];
387
+ }
388
+
389
+ const NO_LOG = `pdflatex wrote no ${JOB}.log — the last lines it printed:`;
390
+
368
391
  /** One bibtex run, on the input the `.aux` names BEFORE it runs, or null when it could not start. */
369
392
  function bibtexPass(ctx: BuildContext, opts: SpawnOptions): Observation | null {
370
393
  const before = hashes(ctx.paperDir);
@@ -391,6 +414,9 @@ export function compile(ctx: BuildContext): {
391
414
  } {
392
415
  const history: Observation[] = [];
393
416
  const opts = spawnOptions(ctx);
417
+ // Every log this loop reads is one its own pdflatex wrote. A pdflatex that dies before opening
418
+ // paper.log would otherwise have an earlier build's error quoted as this one's.
419
+ rmSync(join(ctx.paperDir, `${JOB}.log`), { force: true });
394
420
  const runs = { latex: 0, bibtex: 0 };
395
421
  for (;;) {
396
422
  const step = nextStep(summarize(history));
@@ -446,7 +472,7 @@ export const compileStep: BuildStep = {
446
472
  ...(end.lines.length
447
473
  ? end.lines
448
474
  : ["(no error line found in the log)"]),
449
- `full log: ${log}`,
475
+ ...(end.lines[0] === NO_LOG ? [] : [`full log: ${log}`]),
450
476
  ],
451
477
  };
452
478
  }
@@ -521,11 +547,36 @@ export const measureStep: BuildStep = {
521
547
  },
522
548
  };
523
549
 
550
+ // ── step: references ────────────────────────────────────────────────────────────────────
551
+
552
+ /**
553
+ * Check the bibliography online — the cited work exists and its title matches (verify-cites), its
554
+ * authors are the published version's (bib-authors) — and record the verdicts in
555
+ * `_build/references.json` for the offline lint rules (`reference-rules.ts`). OPTIONAL and NEVER
556
+ * FAILING: without network the PDF is still built, and the record says `not-checked`.
557
+ */
558
+ export const referencesStep: BuildStep = {
559
+ name: "references",
560
+ required: false,
561
+ applies: (facts) =>
562
+ facts.main
563
+ ? {
564
+ yes: true,
565
+ why: `online: citations exist, titles and authors match → ${FACTS_DIR}/${REFERENCES_FILE} (never fails the build)`,
566
+ }
567
+ : { yes: false, why: "nothing is compiled" },
568
+ run: async (ctx) => ({
569
+ ok: true,
570
+ note: await recordReferences(ctx.files, ctx.paperDir, ctx.checkReferences),
571
+ }),
572
+ };
573
+
524
574
  /** The build, in order. A new step is one entry here. */
525
575
  export const STEPS: readonly BuildStep[] = [
526
576
  inputsStep,
527
577
  compileStep,
528
578
  measureStep,
579
+ referencesStep,
529
580
  ];
530
581
 
531
582
  // ── the command ─────────────────────────────────────────────────────────────────────────
@@ -574,6 +625,11 @@ export interface BuildOptions {
574
625
  measure?: MeasureGeometry;
575
626
  /** Where the facts file is written. Default: the disk. */
576
627
  files?: Files;
628
+ /**
629
+ * The online reference checks. Default: none — the record then says `not-checked`, and lint
630
+ * warns. The CLI passes the real ones (`adapters/references`).
631
+ */
632
+ checkReferences?: CheckReferences;
577
633
  }
578
634
 
579
635
  /**
@@ -594,13 +650,21 @@ function baseDefaults({
594
650
  dryRun = false,
595
651
  readPdf = pdfjsReader,
596
652
  projectRoot = env["CLAUDE_PROJECT_DIR"] || cwd,
597
- }: BuildOptions): Required<Omit<BuildOptions, "measure" | "files">> {
653
+ }: BuildOptions): Required<
654
+ Omit<BuildOptions, "measure" | "files" | "checkReferences">
655
+ > {
598
656
  return { run, cwd, env, steps, log, dryRun, readPdf, projectRoot };
599
657
  }
600
658
 
659
+ /** Without a checker the record says so — never a pass. The CLI wires the real one. */
660
+ const notWired: CheckReferences = async () => ({
661
+ kind: "not-checked",
662
+ why: "no reference checker was wired into this build",
663
+ });
664
+
601
665
  /** banal as the measurer, wired from the build's environment: the one piece of root work left here (#76). */
602
666
  function defaultMeasurer(
603
- b: Required<Omit<BuildOptions, "measure" | "files">>,
667
+ b: Required<Omit<BuildOptions, "measure" | "files" | "checkReferences">>,
604
668
  ): MeasureGeometry {
605
669
  const dirs = hostDirs({ cwd: b.cwd });
606
670
  return banalMeasurer(
@@ -614,7 +678,12 @@ function defaultMeasurer(
614
678
  function withDefaults(o: BuildOptions): Required<BuildOptions> {
615
679
  const base = baseDefaults(o);
616
680
  const measure = o.measure ?? defaultMeasurer(base);
617
- return { ...base, measure, files: o.files ?? nodeFiles };
681
+ return {
682
+ ...base,
683
+ measure,
684
+ files: o.files ?? nodeFiles,
685
+ checkReferences: o.checkReferences ?? notWired,
686
+ };
618
687
  }
619
688
 
620
689
  /** Run the applicable steps in order, each on the environment the steps before it left. */
@@ -622,7 +691,15 @@ async function runSteps(
622
691
  paperDir: string,
623
692
  dir: string,
624
693
  plan: PlanLine[],
625
- { run, env, steps, readPdf, measure, files }: Required<BuildOptions>,
694
+ {
695
+ run,
696
+ env,
697
+ steps,
698
+ readPdf,
699
+ measure,
700
+ files,
701
+ checkReferences,
702
+ }: Required<BuildOptions>,
626
703
  ): Promise<BuildResult> {
627
704
  let stepEnv = env;
628
705
  const notes: string[] = [];
@@ -635,6 +712,7 @@ async function runSteps(
635
712
  readPdf,
636
713
  measure,
637
714
  files,
715
+ checkReferences,
638
716
  });
639
717
  if (!out.ok) {
640
718
  // The PDF THIS run wrote and the step then rejected (a partial pass).