paperlint 3.0.0 → 3.1.1

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 (319) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/README.md +179 -180
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. package/src/structure.mutations.mjs +0 -83
@@ -0,0 +1,137 @@
1
+ /**
2
+ * build-engine.ts arms the harness does not reach: a system pdflatex that lacks packages (said
3
+ * before asking), an install that fails, an install that leaves packages missing, a cache used from
4
+ * an environment with no PATH, and the refusal line for a decision that is neither refuse nor ask.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { test } from "vitest";
10
+ import { useTempDir } from "../test/support.mjs";
11
+ import { prepareEngine, refusal } from "./build-engine.ts";
12
+
13
+ const work = useTempDir("paperlint-build-engine-test-");
14
+ const TEX = { packages: { acmart: ["acmart.cls"] }, tools: {} };
15
+ /** A runnable pdflatex in `dir`. */
16
+ const texAt = (dir: string): string => {
17
+ mkdirSync(dir, { recursive: true });
18
+ writeFileSync(join(dir, "pdflatex"), "");
19
+ chmodSync(join(dir, "pdflatex"), 0o755);
20
+ return dir;
21
+ };
22
+ /** kpsewhich answers per bin directory. */
23
+ const fakeRun = (found: Record<string, string>) =>
24
+ ((cmd: string) => ({
25
+ stdout: found[dirname(cmd)] ?? "",
26
+ status: 0,
27
+ })) as never;
28
+
29
+ async function go(o: Parameters<typeof prepareEngine>[0]) {
30
+ const out: string[] = [];
31
+ const err: string[] = [];
32
+ const r = await prepareEngine({
33
+ log: (l) => out.push(l),
34
+ err: (l) => err.push(l),
35
+ platform: "linux",
36
+ interactive: true,
37
+ ask: async () => "y",
38
+ ...o,
39
+ });
40
+ return { r, out, err };
41
+ }
42
+
43
+ test("a system pdflatex lacking packages is named before asking; a failed install stops the run", async () => {
44
+ const sys = texAt(join(work, "sys", "bin"));
45
+ const x = await go({
46
+ tex: TEX,
47
+ env: { PAPERLINT_TEXLIVE_DIR: join(work, "empty-cache"), PATH: sys },
48
+ run: fakeRun({}),
49
+ install: () => ({ ok: false }),
50
+ });
51
+ assert.deepEqual(
52
+ [x.r, x.out],
53
+ [
54
+ { ok: false, code: 1 },
55
+ [`${join(sys, "pdflatex")} is not used: it lacks acmart`],
56
+ ],
57
+ );
58
+ });
59
+
60
+ test("an install that verifies but still lacks a package is refused, naming it", async () => {
61
+ const cache = join(work, "cache-lacking");
62
+ const x = await go({
63
+ tex: TEX,
64
+ env: { PAPERLINT_TEXLIVE_DIR: cache, PATH: join(work, "nothing") },
65
+ run: fakeRun({}),
66
+ install: () => {
67
+ texAt(join(cache, "2026", "bin", "x86_64-linux"));
68
+ return { ok: true, tree: { dir: "", year: "2026", bin: "" } };
69
+ },
70
+ });
71
+ assert.deepEqual(x.r, { ok: false, code: 1 });
72
+ assert.match(x.err.join("\n"), /missing: 1 package\(s\): acmart/);
73
+ });
74
+
75
+ test("an install that leaves no tree at all is refused with nothing named missing", async () => {
76
+ const x = await go({
77
+ tex: TEX,
78
+ env: {
79
+ PAPERLINT_TEXLIVE_DIR: join(work, "cache-none"),
80
+ PATH: join(work, "nothing"),
81
+ },
82
+ run: fakeRun({}),
83
+ install: () => ({ ok: true, tree: { dir: "", year: "2026", bin: "" } }),
84
+ });
85
+ assert.deepEqual(x.r, { ok: false, code: 1 });
86
+ assert.match(x.err.join("\n"), /missing: 0 package\(s\): \)/);
87
+ });
88
+
89
+ test("a complete cache is used from an environment with no PATH at all", async () => {
90
+ const bin = texAt(join(work, "cache-ok", "2026", "bin", "x86_64-linux"));
91
+ const x = await go({
92
+ tex: TEX,
93
+ env: { PAPERLINT_TEXLIVE_DIR: join(work, "cache-ok") },
94
+ run: fakeRun({ [bin]: "/t/acmart.cls\n" }),
95
+ });
96
+ assert.deepEqual(x.r, {
97
+ ok: true,
98
+ env: { PAPERLINT_TEXLIVE_DIR: join(work, "cache-ok"), PATH: `${bin}:` },
99
+ });
100
+ });
101
+
102
+ test("the refusal line for a decision to use a tree names nothing missing", () => {
103
+ assert.equal(
104
+ refusal({ kind: "use-cache" } as never, { system: null } as never),
105
+ "✗ paperlint build: no TeX Live with every package these papers need — run `npx paperlint toolchain` (missing: 0 package(s): )",
106
+ );
107
+ });
108
+
109
+ test("without an ask or an install given: Enter is yes, and the default install runs (here: every mirror fails)", async () => {
110
+ const cache = join(work, "cache-default");
111
+ const calls: string[] = [];
112
+ const out: string[] = [];
113
+ const err: string[] = [];
114
+ const r = await prepareEngine({
115
+ tex: TEX,
116
+ log: (l) => out.push(l),
117
+ err: (l) => err.push(l),
118
+ platform: "linux",
119
+ interactive: true,
120
+ env: {
121
+ PAPERLINT_TEXLIVE_DIR: cache,
122
+ PAPERLINT_CTAN_MIRROR: "https://mirror.invalid/tlnet",
123
+ PATH: join(work, "nothing"),
124
+ },
125
+ run: ((cmd: string) => {
126
+ calls.push(cmd.split("/").pop() ?? cmd);
127
+ return {
128
+ status: 22,
129
+ stdout: "",
130
+ stderr: "curl: (6) could not resolve\n",
131
+ };
132
+ }) as never,
133
+ });
134
+ assert.deepEqual(r, { ok: false, code: 1 });
135
+ assert.ok(calls.includes("curl"), calls.join(","));
136
+ assert.match(err.join("\n"), /no CTAN mirror returned install-tl/);
137
+ });
@@ -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);
@@ -10,7 +10,6 @@
10
10
  * `build.sh` is never executed (checked by the trace it would leave on disk and by the list of
11
11
  * processes started), and a failed build removes the stale `paper.pdf` (checked on disk).
12
12
  */
13
- import assert from "node:assert/strict";
14
13
  import {
15
14
  mkdtempSync,
16
15
  mkdirSync,
@@ -23,6 +22,7 @@ import {
23
22
  import { tmpdir } from "node:os";
24
23
  import { join, dirname, delimiter } from "node:path";
25
24
  import { fileURLToPath } from "node:url";
25
+ import { createChecker } from "../lib/check.mjs";
26
26
 
27
27
  const HERE = dirname(fileURLToPath(import.meta.url));
28
28
  const {
@@ -44,11 +44,7 @@ const { packageVenuesDir } = await import(
44
44
  join(HERE, "..", "skills", "paper-pipeline", "scripts", "consumer.mjs")
45
45
  );
46
46
 
47
- let n = 0;
48
- const check = (label, cond) => {
49
- assert.ok(cond, label);
50
- n++;
51
- };
47
+ const check = createChecker();
52
48
 
53
49
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-build-")));
54
50
  // 🔴 banal is looked for under the temp root, never in the developer's cache: with the real
@@ -74,6 +70,10 @@ function fakeTex({
74
70
  log = "",
75
71
  missing = false,
76
72
  noPdf = false,
73
+ // `noLog`: pdflatex that dies before it opens paper.log (a format it cannot load); what it
74
+ // printed is `stdout`.
75
+ noLog = false,
76
+ stdout = "",
77
77
  } = {}) {
78
78
  const calls = [];
79
79
  const run = (bin, args, opts) => {
@@ -85,11 +85,11 @@ function fakeTex({
85
85
  };
86
86
  if (bin === "pdflatex") {
87
87
  writeFileSync(join(opts.cwd, "paper.aux"), aux);
88
- writeFileSync(join(opts.cwd, "paper.log"), log);
88
+ if (!noLog) writeFileSync(join(opts.cwd, "paper.log"), log);
89
89
  // `noPdf`: what real pdflatex does on a document with no pages — "No pages of output.", exit 0.
90
90
  if (exitCode === 0 && !noPdf)
91
91
  writeFileSync(join(opts.cwd, "paper.pdf"), "%PDF-fake");
92
- return { status: exitCode, stdout: "", stderr: "" };
92
+ return { status: exitCode, stdout, stderr: "" };
93
93
  }
94
94
  if (bin === "bibtex") {
95
95
  writeFileSync(join(opts.cwd, "paper.bbl"), "\\begin{thebibliography}{1}");
@@ -395,6 +395,46 @@ try {
395
395
  check("…and says the PDF was removed", /paper\.pdf removed/.test(shown));
396
396
  check("a failed pass is not retried", ftex.calls.length === 1);
397
397
 
398
+ // ── pdflatex dies before writing a log ────────────────────────────────────────────────
399
+ // Seen 2026-09-26: a TeX Live whose pdflatex format was never built. pdflatex exits 1 having
400
+ // printed the reason, and paper.log is either absent or left over from an earlier build.
401
+ const nolog = paper("nolog", {
402
+ "paper.tex": CLEAN_TEX,
403
+ "paper.log": "./paper.tex:9: OLD ERROR FROM YESTERDAY\nl.9 \\old\n",
404
+ });
405
+ const dieTex = fakeTex({
406
+ exitCode: 1,
407
+ noLog: true,
408
+ stdout:
409
+ "This is pdfTeX, Version 3.141592653-2.6-1.40.28 (TeX Live 2026) (INITEX)\n" +
410
+ "I can't find the format file `pdflatex.fmt'!\n",
411
+ });
412
+ const rn = await buildPaper(nolog, {
413
+ cwd: root,
414
+ readPdf: fakeRead,
415
+ run: dieTex.run,
416
+ env: ENV,
417
+ log: quiet,
418
+ });
419
+ const shownN = formatResult(rn);
420
+ // Guards: the reason pdflatex printed reaches the human when there is no log to quote.
421
+ check(
422
+ "🔴 no log: the failure shows what pdflatex printed",
423
+ rn.status === "failed" && shownN.includes("I can't find the format file"),
424
+ shownN,
425
+ );
426
+ // Guards: a log from an earlier build is never quoted as this build's error.
427
+ check(
428
+ "🔴 no log: a stale paper.log from an earlier build is not quoted",
429
+ !shownN.includes("OLD ERROR FROM YESTERDAY"),
430
+ shownN,
431
+ );
432
+ check(
433
+ "no log: it does not point at a log file that this run did not write",
434
+ !shownN.includes("full log:") && shownN.includes("wrote no paper.log"),
435
+ shownN,
436
+ );
437
+
398
438
  // ── pdflatex not installed ────────────────────────────────────────────────────────────
399
439
  const rm = await buildPaper(clean, {
400
440
  cwd: root,
@@ -467,8 +507,8 @@ try {
467
507
  check("on full success there is no remedy", remedyFor([r]) === "");
468
508
 
469
509
  // ── 🔴 A STALE PDF NEVER SURVIVES A RUN THAT DID NOT REPLACE IT — every path of buildPapers ──
470
- // The order matters for the battery: the up-front removal is killed by the no-engine case, the
471
- // existence check by the empty document, and neither case depends on the other defence.
510
+ // The order matters here: the up-front removal is exercised by the no-engine case below, the
511
+ // existence check by the empty-document case, and neither depends on the other defence.
472
512
  const REMOVED =
473
513
  "paper.pdf removed — a stale PDF must not pass for this build";
474
514
 
@@ -636,5 +676,5 @@ try {
636
676
  }
637
677
 
638
678
  console.log(
639
- `✓ ${String(n)} assertions passed — build: paperlint compiles, build.sh never runs, a red build leaves no PDF`,
679
+ `✓ ${String(check.count)} assertions passed — build: paperlint compiles, build.sh never runs, a red build leaves no PDF`,
640
680
  );
@@ -0,0 +1,362 @@
1
+ /**
2
+ * build.ts edges the harness does not reach: the compile step's wording for every way pdflatex or
3
+ * bibtex stops, bibtex's input when the `.aux` names a database, the measure note for a review
4
+ * build, a stub page and a measured geometry, the loop over non-applicable steps, and the verdict
5
+ * lines. pdflatex and bibtex are a fake runner that writes the files the real ones would.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { existsSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { test } from "vitest";
11
+ import { useTempDir, writeTree } from "../test/support.mjs";
12
+ import {
13
+ bibInput,
14
+ buildPaper,
15
+ buildPapers,
16
+ compileStep,
17
+ formatResult,
18
+ parseDocumentclass,
19
+ readFacts,
20
+ type BuildOptions,
21
+ type PaperFacts,
22
+ } from "./build.ts";
23
+ import type { Geometry } from "./domain/geometry.ts";
24
+ import type { PdfReader } from "./pdf-facts.ts";
25
+
26
+ const root = useTempDir("paperlint-build-test-");
27
+ const TEX = "\\documentclass{article}\\begin{document}x\\end{document}";
28
+ let n = 0;
29
+ const paper = (files: Record<string, string> = { "paper.tex": TEX }) =>
30
+ writeTree(join(root, `p${String(n++)}`), files);
31
+
32
+ interface Fake {
33
+ /** pdflatex's exit status; `null` is a signal. */
34
+ latex?: number | null;
35
+ /** bibtex's exit status, and what it prints. */
36
+ bibtex?: number | null;
37
+ bibtexOut?: string;
38
+ /** Which program cannot be started. */
39
+ missing?: "pdflatex" | "bibtex";
40
+ aux?: string;
41
+ log?: string;
42
+ /** pdflatex dies before opening paper.log, printing nothing. */
43
+ noLog?: boolean;
44
+ /** Each pass writes a different .aux, so the loop never settles. */
45
+ churn?: boolean;
46
+ }
47
+ /** A pdflatex pass: writes the .aux, the log unless it dies first, and a PDF when it exits 0. */
48
+ function latexPass(f: Fake, cwd: string, pass: number) {
49
+ const status = "latex" in f ? f.latex : 0;
50
+ const aux = f.churn ? `\\relax % ${String(pass)}\n` : (f.aux ?? "\\relax\n");
51
+ writeFileSync(join(cwd, "paper.aux"), aux);
52
+ if (!f.noLog) writeFileSync(join(cwd, "paper.log"), f.log ?? "");
53
+ if (status === 0) writeFileSync(join(cwd, "paper.pdf"), "%PDF-fake");
54
+ return f.noLog ? { status } : { status, stdout: "", stderr: "" };
55
+ }
56
+ /** A bibtex run: writes the .bbl; status and output as the case says. */
57
+ function bibtexRun(f: Fake, cwd: string) {
58
+ writeFileSync(join(cwd, "paper.bbl"), "\\begin{thebibliography}{1}");
59
+ if (f.bibtex === undefined)
60
+ return { status: 0, stdout: "This is BibTeX\n", stderr: "" };
61
+ return f.bibtexOut === undefined
62
+ ? { status: f.bibtex }
63
+ : { status: f.bibtex, stdout: f.bibtexOut };
64
+ }
65
+ function fakeRun(f: Fake): BuildOptions["run"] {
66
+ let pass = 0;
67
+ return ((bin: string, _args: string[], opts: { cwd: string }) => {
68
+ if (bin === f.missing)
69
+ return {
70
+ error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }),
71
+ status: null,
72
+ };
73
+ return bin === "pdflatex"
74
+ ? latexPass(f, opts.cwd, ++pass)
75
+ : bibtexRun(f, opts.cwd);
76
+ }) as never;
77
+ }
78
+
79
+ const NO_GEOMETRY: Geometry = {
80
+ kind: "unmeasured",
81
+ why: ["no banal"],
82
+ tried: null,
83
+ };
84
+ const page = (words: number, numbered = 0) => ({
85
+ widthPt: 612,
86
+ heightPt: 792,
87
+ words: [
88
+ ...Array.from({ length: numbered }, (_, i) => ({
89
+ x0: 20,
90
+ y0: 60 + i * 10,
91
+ x1: 30,
92
+ y1: 70 + i * 10,
93
+ text: String(i + 1),
94
+ })),
95
+ ...Array.from({ length: words }, (_, i) => ({
96
+ x0: i % 2 ? 320 : 54,
97
+ y0: 60 + (i >> 1) * 10,
98
+ x1: i % 2 ? 340 : 74,
99
+ y1: 70 + (i >> 1) * 10,
100
+ text: "w",
101
+ })),
102
+ ],
103
+ });
104
+ const reader =
105
+ (last = page(80)): PdfReader =>
106
+ async () => ({
107
+ ok: true,
108
+ facts: {
109
+ pages: 1,
110
+ fonts: {
111
+ kind: "drawn",
112
+ list: [{ kind: "embedded", name: "CMR10", program: "Type1" }],
113
+ },
114
+ last,
115
+ layout: [],
116
+ },
117
+ });
118
+ const quiet = () => {};
119
+ /** buildPaper with fakes; the verdict line is what the command would print for it. */
120
+ async function build(dir: string, f: Fake = {}, over: BuildOptions = {}) {
121
+ const r = await buildPaper(dir, {
122
+ cwd: root,
123
+ log: quiet,
124
+ env: { PAPERLINT_BANAL_DIR: join(root, "no-banal") },
125
+ run: fakeRun(f),
126
+ readPdf: reader(),
127
+ measure: { measure: () => NO_GEOMETRY },
128
+ ...over,
129
+ });
130
+ return { r, line: formatResult(r).replaceAll(root, "<root>") };
131
+ }
132
+
133
+ test("documentclass: an empty name is no class; a macro inside the name contributes nothing", () => {
134
+ assert.equal(parseDocumentclass("\\documentclass{}"), null);
135
+ // Nested deep enough to exhaust the parser's stack: no class, not a crash.
136
+ assert.equal(
137
+ parseDocumentclass(`\\documentclass{x}${"{".repeat(20_000)}`),
138
+ null,
139
+ );
140
+ assert.deepEqual(parseDocumentclass("\\documentclass{a\\foo{}b}"), {
141
+ name: "ab",
142
+ options: [],
143
+ });
144
+ // unified-latex attaches arguments to the macros it has a signature for; a tree where
145
+ // `\documentclass` came back bare has no name to read, and is no class.
146
+ assert.equal(
147
+ parseDocumentclass("\\documentclass", {
148
+ parse: () =>
149
+ ({
150
+ type: "root",
151
+ content: [{ type: "macro", content: "documentclass" }],
152
+ }) as never,
153
+ }),
154
+ null,
155
+ );
156
+ });
157
+
158
+ test("facts of a paper with no paper.tex: no class", () => {
159
+ assert.deepEqual(readFacts(paper({ "paper.md": "# P\n" })), {
160
+ main: null,
161
+ documentclass: null,
162
+ venue: null,
163
+ ignoredScripts: [],
164
+ });
165
+ });
166
+
167
+ test("compile's plan line names the options, a missing class and the venue", () => {
168
+ const facts = (over: Partial<PaperFacts>): PaperFacts => ({
169
+ main: "paper.tex",
170
+ documentclass: null,
171
+ venue: null,
172
+ ignoredScripts: [],
173
+ ...over,
174
+ });
175
+ assert.deepEqual(
176
+ [
177
+ compileStep.applies(
178
+ facts({
179
+ documentclass: { name: "acmart", options: ["sigconf", "review"] },
180
+ }),
181
+ ),
182
+ compileStep.applies(facts({ venue: "aisec" })),
183
+ ],
184
+ [
185
+ { yes: true, why: "paper.tex (\\documentclass[sigconf,review]{acmart})" },
186
+ { yes: true, why: "paper.tex (no \\documentclass found, venue aisec)" },
187
+ ],
188
+ );
189
+ });
190
+
191
+ test("bibtex's input: no .aux, nothing named, a database named with and without .bib, one that is missing", () => {
192
+ assert.deepEqual(bibInput(paper({})), { kind: "none" });
193
+ const none = paper({ "paper.aux": "\\relax\n" });
194
+ assert.deepEqual(bibInput(none), { kind: "none" });
195
+ const named = paper({
196
+ "paper.aux": "\\citation{k}\n\\bibstyle{plain}\n\\bibdata{a.bib,missing}\n",
197
+ "a.bib": "@misc{k}\n",
198
+ });
199
+ const got = bibInput(named);
200
+ assert.deepEqual(
201
+ { ...got, bibHash: got.kind === "needed" && typeof got.bibHash },
202
+ {
203
+ kind: "needed",
204
+ citations: ["k"],
205
+ databases: ["a.bib", "missing"],
206
+ style: "plain",
207
+ bibHash: "string",
208
+ },
209
+ );
210
+ const gone = bibInput(paper({ "paper.aux": "\\bibdata{gone}\n" }));
211
+ assert.equal(gone.kind === "needed" && gone.bibHash, null);
212
+ });
213
+
214
+ test("compile: pdflatex killed by a signal, with no log and nothing printed", async () => {
215
+ const { line } = await build(paper(), { latex: null, noLog: true });
216
+ assert.equal(
217
+ line,
218
+ [
219
+ " ✗ compile: pdflatex exited with 1",
220
+ " pdflatex wrote no paper.log — the last lines it printed:",
221
+ " paper.pdf removed — a stale PDF must not pass for this build",
222
+ ].join("\n"),
223
+ );
224
+ });
225
+
226
+ test("compile: bibtex that cannot start, and bibtex that fails, quote the .blg", async () => {
227
+ const aux = "\\citation{k}\n\\bibstyle{plain}\n\\bibdata{refs}\n";
228
+ const files = { "paper.tex": TEX, "refs.bib": "@misc{k}\n" };
229
+ const cannot = await build(paper(files), { aux, missing: "bibtex" });
230
+ assert.match(cannot.line, /^ {2}✗ compile: bibtex exited with 127\n/);
231
+ const dir = paper(files);
232
+ const failed = await build(dir, {
233
+ aux,
234
+ bibtex: 2,
235
+ bibtexOut:
236
+ "I couldn't open database file refs.bib\n---line 3 of file paper.aux\n",
237
+ });
238
+ assert.match(failed.line, /^ {2}✗ compile: bibtex exited with 2\n/);
239
+ assert.match(failed.line, /full log: <root>\/p\d+\/paper\.blg/);
240
+ const signal = await build(paper(files), { aux, bibtex: null });
241
+ assert.match(
242
+ signal.line,
243
+ /bibtex exited with 1\n {6}\(no error line found in the log\)/,
244
+ );
245
+ });
246
+
247
+ test("compile: a document whose .aux never settles did not converge", async () => {
248
+ const { line } = await build(paper(), { churn: true });
249
+ assert.match(line, /^ {2}✗ compile: pdflatex did not converge\n/);
250
+ });
251
+
252
+ test("compile: warnings the final log still reports are named in the note", async () => {
253
+ const { r } = await build(paper(), {
254
+ log: "LaTeX Warning: There were undefined references.\n",
255
+ });
256
+ assert.equal(r.status, "built");
257
+ assert.match(r.notes?.[0] ?? "", /— ⚠️ the final log still reports /);
258
+ });
259
+
260
+ test("measure: a review build and a stub last page are named; a measured geometry adds nothing", async () => {
261
+ const review = await build(paper(), {}, { readPdf: reader(page(20, 60)) });
262
+ assert.match(
263
+ review.line,
264
+ /last page has numbered lines \(a review build\), not measured/,
265
+ );
266
+ const stub = await build(paper(), {}, { readPdf: reader(page(3)) });
267
+ assert.match(stub.line, /last page has 3 words, too few to measure/);
268
+ const measured = await build(
269
+ paper(),
270
+ {},
271
+ {
272
+ measure: {
273
+ measure: () => ({
274
+ kind: "measured",
275
+ by: { tool: "banal", path: "/b", how: "$BANAL" },
276
+ geometry: {
277
+ pageWidthIn: 8.5,
278
+ pageHeightIn: 11,
279
+ columns: 2,
280
+ bodyPt: 9,
281
+ refPt: 7,
282
+ bodyPages: 1,
283
+ refPages: 0,
284
+ appendixPages: 0,
285
+ pagesByType: { body: 1 },
286
+ },
287
+ }),
288
+ },
289
+ },
290
+ );
291
+ assert.doesNotMatch(measured.line, /page geometry not measured/);
292
+ });
293
+
294
+ test("a step that does not apply is skipped; a run whose steps leave no note prints the bare PDF", async () => {
295
+ const { line } = await build(
296
+ paper(),
297
+ {},
298
+ {
299
+ steps: [
300
+ {
301
+ name: "never",
302
+ required: false,
303
+ applies: () => ({ yes: false, why: "no" }),
304
+ run: () => {
305
+ throw new Error("ran");
306
+ },
307
+ },
308
+ {
309
+ name: "quiet",
310
+ required: true,
311
+ applies: () => ({ yes: true, why: "yes" }),
312
+ run: () => ({ ok: true }),
313
+ },
314
+ ],
315
+ },
316
+ );
317
+ assert.equal(line, " ✓ paper.pdf");
318
+ });
319
+
320
+ test("paths: a paper that IS the working directory is named by its full path", async () => {
321
+ const dir = paper();
322
+ const logged: string[] = [];
323
+ await buildPaper(dir, {
324
+ cwd: dir,
325
+ log: (l) => logged.push(l),
326
+ dryRun: true,
327
+ measure: { measure: () => NO_GEOMETRY },
328
+ });
329
+ assert.equal(logged[0], dir);
330
+ const lines: string[] = [];
331
+ writeFileSync(join(dir, "paper.pdf"), "%PDF-old");
332
+ const run = await buildPapers([dir], {
333
+ cwd: dir,
334
+ log: (l) => lines.push(l),
335
+ engine: async () => null,
336
+ });
337
+ assert.deepEqual(
338
+ [run, lines],
339
+ [
340
+ { kind: "no-engine" },
341
+ [`${dir}: paper.pdf removed — a stale PDF must not pass for this build`],
342
+ ],
343
+ );
344
+ assert.equal(existsSync(join(dir, "paper.pdf")), false);
345
+ });
346
+
347
+ test("facts that cannot be read fail the build at `facts`", async () => {
348
+ const { line } = await build(
349
+ paper({ "paper.tex": TEX, "paperlint.json": "{ nope" }),
350
+ );
351
+ assert.match(line, /^ {2}✗ facts: /);
352
+ });
353
+
354
+ test("a failed result without its lines still says what failed", () => {
355
+ assert.equal(
356
+ formatResult({ dir: "d", status: "failed", plan: [] }),
357
+ [
358
+ " ✗ build: failed",
359
+ " paper.pdf removed — a stale PDF must not pass for this build",
360
+ ].join("\n"),
361
+ );
362
+ });