paperlint 3.1.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 (284) 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/bin/paperlint.test.mjs +45 -0
  6. package/dist/adapters/node/files.io.d.ts +2 -0
  7. package/dist/adapters/node/files.io.d.ts.map +1 -1
  8. package/dist/adapters/node/files.io.js +3 -2
  9. package/dist/adapters/node/files.io.js.map +1 -1
  10. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  11. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  12. package/dist/adapters/pdfjs/fill.js +31 -21
  13. package/dist/adapters/pdfjs/fill.js.map +1 -1
  14. package/dist/adapters/references/index.d.ts.map +1 -1
  15. package/dist/adapters/references/index.js +14 -5
  16. package/dist/adapters/references/index.js.map +1 -1
  17. package/dist/adapters/references/pool.d.ts +8 -0
  18. package/dist/adapters/references/pool.d.ts.map +1 -0
  19. package/dist/adapters/references/pool.js +19 -0
  20. package/dist/adapters/references/pool.js.map +1 -0
  21. package/dist/build.d.ts +10 -2
  22. package/dist/build.d.ts.map +1 -1
  23. package/dist/build.js +7 -8
  24. package/dist/build.js.map +1 -1
  25. package/dist/cli.d.ts +27 -1
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +71 -55
  28. package/dist/cli.js.map +1 -1
  29. package/dist/doctor.d.ts +27 -1
  30. package/dist/doctor.d.ts.map +1 -1
  31. package/dist/doctor.js +4 -3
  32. package/dist/doctor.js.map +1 -1
  33. package/dist/domain/result.d.ts +0 -2
  34. package/dist/domain/result.d.ts.map +1 -1
  35. package/dist/domain/result.js +0 -4
  36. package/dist/domain/result.js.map +1 -1
  37. package/dist/hooks-settings.d.ts +8 -5
  38. package/dist/hooks-settings.d.ts.map +1 -1
  39. package/dist/hooks-settings.js +32 -32
  40. package/dist/hooks-settings.js.map +1 -1
  41. package/dist/init.d.ts +8 -0
  42. package/dist/init.d.ts.map +1 -1
  43. package/dist/init.js +3 -3
  44. package/dist/init.js.map +1 -1
  45. package/dist/latex-log.d.ts +0 -2
  46. package/dist/latex-log.d.ts.map +1 -1
  47. package/dist/latex-log.js +7 -13
  48. package/dist/latex-log.js.map +1 -1
  49. package/dist/link-skills.d.ts +15 -2
  50. package/dist/link-skills.d.ts.map +1 -1
  51. package/dist/link-skills.js +36 -20
  52. package/dist/link-skills.js.map +1 -1
  53. package/dist/paper-files.d.ts.map +1 -1
  54. package/dist/paper-files.js +6 -4
  55. package/dist/paper-files.js.map +1 -1
  56. package/dist/pdf-facts.d.ts +11 -0
  57. package/dist/pdf-facts.d.ts.map +1 -1
  58. package/dist/pdf-facts.js +16 -18
  59. package/dist/pdf-facts.js.map +1 -1
  60. package/dist/pdf-geometry.js +1 -1
  61. package/dist/pdf-geometry.js.map +1 -1
  62. package/dist/presets.d.ts +2 -0
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +3 -1
  65. package/dist/presets.js.map +1 -1
  66. package/dist/references.js +2 -2
  67. package/dist/references.js.map +1 -1
  68. package/dist/tex-requirements.d.ts +8 -0
  69. package/dist/tex-requirements.d.ts.map +1 -1
  70. package/dist/tex-requirements.js +6 -5
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/toolchain.d.ts +3 -2
  73. package/dist/toolchain.d.ts.map +1 -1
  74. package/dist/toolchain.js +4 -5
  75. package/dist/toolchain.js.map +1 -1
  76. package/dist/venue-rules.d.ts.map +1 -1
  77. package/dist/venue-rules.js +11 -9
  78. package/dist/venue-rules.js.map +1 -1
  79. package/docs/prior-art/README.md +1 -1
  80. package/docs/prior-art/test-tooling.md +7 -30
  81. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  82. package/eslint-rules/latex-language.harness.mjs +3 -3
  83. package/eslint-rules/latex-language.mjs +12 -9
  84. package/eslint-rules/latex-language.test.mjs +200 -0
  85. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  86. package/eslint-rules/paper-stages.harness.mjs +9 -14
  87. package/eslint-rules/paper-stages.test.mjs +135 -0
  88. package/eslint-rules/paper-typography.mjs +9 -2
  89. package/eslint-rules/paper-typography.test.mjs +146 -1
  90. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  91. package/eslint-rules/review-frontmatter.mjs +2 -1
  92. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  93. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  94. package/eslint-rules/tex-build.mjs +13 -13
  95. package/eslint.config.mjs +6 -2
  96. package/hooks/hooks.harness.mjs +6 -11
  97. package/hooks/paper-edit-guard.hook.mjs +2 -2
  98. package/hooks/paper-edit-guard.test.mjs +61 -0
  99. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  100. package/hooks/paper-skills-nudge.test.mjs +38 -0
  101. package/hooks/paper-status-gates.test.mjs +42 -0
  102. package/lib/agent-cli-version.harness.mjs +7 -0
  103. package/lib/check.d.mts +17 -0
  104. package/lib/check.mjs +50 -0
  105. package/lib/check.test.ts +67 -0
  106. package/lib/markdown.mjs +16 -69
  107. package/lib/markdown.test.mjs +246 -0
  108. package/lib/paper-config.harness.mjs +28 -9
  109. package/lib/paper-config.mjs +7 -2
  110. package/lib/skill-checks.mjs +18 -46
  111. package/lib/skill-checks.test.mjs +297 -0
  112. package/lib/skill-corpus.mjs +11 -13
  113. package/lib/skill-corpus.test.mjs +54 -0
  114. package/lib/skill-trigger-cases.harness.mjs +57 -4
  115. package/package.json +6 -5
  116. package/scripts/check.harness.mjs +4 -10
  117. package/scripts/check.mjs +45 -42
  118. package/scripts/check.test.mjs +104 -0
  119. package/scripts/coverage-config.test.ts +406 -0
  120. package/scripts/eslint-report-guard.test.mjs +48 -0
  121. package/scripts/harness-api.frozen.json +0 -2
  122. package/scripts/layer-legacy-frozen.d.mts +9 -1
  123. package/scripts/layer-legacy-frozen.mjs +28 -11
  124. package/scripts/layer-legacy-frozen.test.ts +143 -1
  125. package/scripts/rules-are-content-only.harness.mjs +3 -11
  126. package/scripts/rules-are-content-only.mjs +29 -19
  127. package/scripts/rules-are-content-only.test.mjs +61 -0
  128. package/scripts/rules-see-files.mjs +11 -9
  129. package/scripts/rules-see-files.test.mjs +71 -0
  130. package/scripts/test.mjs +44 -0
  131. package/scripts/test.test.mjs +52 -0
  132. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  133. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  134. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  135. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  136. package/skills/paper-pipeline/scripts/README.md +13 -132
  137. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  138. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  139. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  140. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  141. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  142. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  145. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  148. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  149. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  150. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  151. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  152. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  153. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  154. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  155. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  156. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  157. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  158. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  159. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  160. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  161. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  162. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  163. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  164. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  165. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  166. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  167. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  168. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  169. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  170. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  171. package/skills/tighten-paper/structure.test.mjs +116 -0
  172. package/skills/verify-citations/SKILL.md +5 -5
  173. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  174. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  175. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  176. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  177. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  178. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  179. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  180. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  181. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  182. package/src/adapters/banal/failure.test.ts +70 -1
  183. package/src/adapters/banal/index.test.ts +16 -0
  184. package/src/adapters/banal/output.test.ts +5 -0
  185. package/src/adapters/curl/download.test.ts +43 -1
  186. package/src/adapters/memory/index.test.ts +25 -0
  187. package/src/adapters/node/files.io.ts +3 -2
  188. package/src/adapters/node/files.test.ts +13 -1
  189. package/src/adapters/node/process.test.ts +38 -0
  190. package/src/adapters/pdfjs/fill.test.ts +34 -0
  191. package/src/adapters/pdfjs/fill.ts +44 -25
  192. package/src/adapters/references/index.test.ts +228 -0
  193. package/src/adapters/references/index.ts +21 -6
  194. package/src/adapters/references/pool.test.ts +31 -0
  195. package/src/adapters/references/pool.ts +24 -0
  196. package/src/build-engine.harness.mjs +3 -7
  197. package/src/build-engine.test.ts +137 -0
  198. package/src/build.harness.mjs +5 -9
  199. package/src/build.test.ts +362 -0
  200. package/src/build.ts +13 -9
  201. package/src/cli.harness.mjs +10 -17
  202. package/src/cli.test.ts +584 -0
  203. package/src/cli.ts +112 -66
  204. package/src/doctor.harness.mjs +3 -8
  205. package/src/doctor.test.ts +64 -0
  206. package/src/doctor.ts +12 -4
  207. package/src/domain/result.test.ts +4 -0
  208. package/src/domain/result.ts +0 -5
  209. package/src/engine.harness.mjs +27 -7
  210. package/src/facts-file.test.ts +110 -0
  211. package/src/hooks-settings.harness.mjs +43 -13
  212. package/src/hooks-settings.ts +41 -35
  213. package/src/init.test.ts +308 -5
  214. package/src/init.ts +3 -3
  215. package/src/latex-log.harness.mjs +32 -7
  216. package/src/latex-log.ts +6 -13
  217. package/src/latex-loop.harness.mjs +8 -7
  218. package/src/link-skills.harness.mjs +3 -8
  219. package/src/link-skills.test.ts +105 -0
  220. package/src/link-skills.ts +75 -21
  221. package/src/new-paper.harness.mjs +3 -7
  222. package/src/new-paper.test.ts +31 -1
  223. package/src/paper-files.test.ts +12 -0
  224. package/src/paper-files.ts +9 -5
  225. package/src/paper-settings.test.ts +20 -0
  226. package/src/pdf-facts.harness.mjs +3 -6
  227. package/src/pdf-facts.test.ts +144 -0
  228. package/src/pdf-facts.ts +17 -19
  229. package/src/pdf-geometry.harness.mjs +3 -7
  230. package/src/pdf-geometry.ts +1 -1
  231. package/src/presets.test.ts +2 -0
  232. package/src/presets.ts +7 -1
  233. package/src/references.test.ts +97 -0
  234. package/src/references.ts +2 -2
  235. package/src/rules-config.harness.mjs +10 -8
  236. package/src/structure.harness.mjs +3 -7
  237. package/src/tex-requirements.harness.mjs +34 -7
  238. package/src/tex-requirements.ts +12 -6
  239. package/src/toolchain.harness.mjs +3 -7
  240. package/src/toolchain.test.ts +280 -0
  241. package/src/toolchain.ts +8 -7
  242. package/src/venue-rules.test.ts +60 -0
  243. package/src/venue-rules.ts +13 -11
  244. package/action.mutations.mjs +0 -162
  245. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  246. package/eslint-rules/latex-language.mutations.mjs +0 -196
  247. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  248. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  249. package/eslint-rules/papers.mutations.mjs +0 -186
  250. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  251. package/eslint-rules/tex-build.mutations.mjs +0 -258
  252. package/hooks/hooks.mutations.mjs +0 -337
  253. package/lib/agent-cli-version.mutations.mjs +0 -109
  254. package/lib/mutation-driver.harness.mjs +0 -227
  255. package/lib/mutation-driver.mjs +0 -397
  256. package/lib/mutation-driver.mutations.mjs +0 -68
  257. package/lib/paper-config.mutations.mjs +0 -143
  258. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  259. package/scripts/check.mutations.mjs +0 -110
  260. package/scripts/exclusive.mjs +0 -138
  261. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  262. package/scripts/mutation-batteries-frozen.mjs +0 -238
  263. package/scripts/mutation-batteries.frozen.json +0 -108
  264. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  265. package/scripts/rules-see-files.mutations.mjs +0 -131
  266. package/scripts/run-mutations.mjs +0 -100
  267. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  268. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  269. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  270. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  271. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  272. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  273. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  274. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  275. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  276. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  277. package/skills/skill-contract.mutations.mjs +0 -179
  278. package/src/build.mutations.mjs +0 -196
  279. package/src/cli.mutations.mjs +0 -414
  280. package/src/doctor.mutations.mjs +0 -175
  281. package/src/hooks-settings.mutations.mjs +0 -99
  282. package/src/link-skills.mutations.mjs +0 -140
  283. package/src/new-paper.mutations.mjs +0 -65
  284. 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
+ });
@@ -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
@@ -511,8 +507,8 @@ try {
511
507
  check("on full success there is no remedy", remedyFor([r]) === "");
512
508
 
513
509
  // ── 🔴 A STALE PDF NEVER SURVIVES A RUN THAT DID NOT REPLACE IT — every path of buildPapers ──
514
- // The order matters for the battery: the up-front removal is killed by the no-engine case, the
515
- // 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.
516
512
  const REMOVED =
517
513
  "paper.pdf removed — a stale PDF must not pass for this build";
518
514
 
@@ -680,5 +676,5 @@ try {
680
676
  }
681
677
 
682
678
  console.log(
683
- `✓ ${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`,
684
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
+ });
package/src/build.ts CHANGED
@@ -187,11 +187,16 @@ function argText(arg: LatexArgument | undefined): string {
187
187
  .join("");
188
188
  }
189
189
 
190
- /** A unified-latex AST, or null when the text does not parse. */
191
- function parseTex(tex: string): LatexRoot | null {
190
+ /** A unified-latex AST, or null when the text does not parse. `parse` is unified-latex's parser. */
191
+ function parseTex(
192
+ tex: string,
193
+ parse: (tex: string) => LatexRoot = (t) => getParser().parse(t),
194
+ ): LatexRoot | null {
192
195
  try {
193
- return getParser().parse(tex);
196
+ return parse(tex);
194
197
  } catch {
198
+ // The parser recurses per group: ~20 000 nested `{` exhaust the stack (RangeError). Such a
199
+ // file has no class to report, and must not fail the build at `facts` for it.
195
200
  return null;
196
201
  }
197
202
  }
@@ -200,8 +205,11 @@ function parseTex(tex: string): LatexRoot | null {
200
205
  * `\documentclass[opts]{name}`, read by the unified-latex parser the lint rules already use —
201
206
  * so a `\documentclass` inside a comment is a comment, not a class.
202
207
  */
203
- export function parseDocumentclass(tex: string): PaperFacts["documentclass"] {
204
- return documentclassOf(parseTex(tex));
208
+ export function parseDocumentclass(
209
+ tex: string,
210
+ { parse }: { parse?: (tex: string) => LatexRoot } = {},
211
+ ): PaperFacts["documentclass"] {
212
+ return documentclassOf(parseTex(tex, parse));
205
213
  }
206
214
 
207
215
  function documentclassOf(ast: LatexRoot | null): PaperFacts["documentclass"] {
@@ -850,10 +858,6 @@ function formatRefusal(r: BuildResult): string {
850
858
  return r.staleRemoved ? `${refused}\n ${PDF_REMOVED}` : refused;
851
859
  }
852
860
 
853
- export function formatResults(results: readonly BuildResult[]): string {
854
- return results.map(formatResult).join("\n");
855
- }
856
-
857
861
  /**
858
862
  * 🔴 "Nothing to compile" COUNTS AS A REFUSAL on a par with a failed build. Failing to tell these
859
863
  * two apart is what once produced a green run over a paper no job had built.
@@ -11,8 +11,6 @@
11
11
  * leaked out of the depths of eslint-helpers.
12
12
  * Both are pinned down by the assertions below so there is no going back.
13
13
  */
14
- import assert from "node:assert/strict";
15
- import { recordCheck } from "vigiles";
16
14
  import { load as yamlLoad } from "js-yaml";
17
15
  import {
18
16
  mkdtempSync,
@@ -31,6 +29,7 @@ import { tmpdir } from "node:os";
31
29
  import { join, dirname } from "node:path";
32
30
  import { fileURLToPath } from "node:url";
33
31
  import { PAPERS_DIR_FIELD, findProjectRoot } from "../lib/paper-config.mjs";
32
+ import { createChecker } from "../lib/check.mjs";
34
33
 
35
34
  const HERE = dirname(fileURLToPath(import.meta.url));
36
35
  const { run, parseArgs, buildConfig, nextSteps, toPaths, runHook } =
@@ -39,14 +38,9 @@ const { init, choosePapers, offerWorkflow, missingPrograms, WORKFLOW_PATH } =
39
38
  await import(join(HERE, "init.ts"));
40
39
  const { PROGRAMS } = await import(join(HERE, "doctor.ts"));
41
40
 
42
- let n = 0;
43
41
  // Counted by vigiles too: `init` now loads `vigiles/claude-code` for its merge, and a harness that
44
42
  // loads vigiles without recording a check is reported as having verified nothing.
45
- const check = (label, cond) => {
46
- assert.ok(cond, label);
47
- recordCheck(label);
48
- n++;
49
- };
43
+ const check = createChecker();
50
44
 
51
45
  /** Runs the utility with output captured — quieter and faster than spawning a process. */
52
46
  async function cli(args, cwd) {
@@ -73,10 +67,9 @@ async function cli(args, cwd) {
73
67
  };
74
68
  } catch (e) {
75
69
  // 🔴 A LEAKED EXCEPTION IS A PROPERTY THAT MUST BE ASSERTED, NOT CAUGHT BY LETTING THE
76
- // HARNESS CRASH. The mutation that removes the catch around ESLint used to bring the
77
- // harness down with a stack trace, and the driver — under its own strict rule "killed only
78
- // by ITS OWN assertion" — refused to count that as a kill and printed "survived". I.e. the
79
- // real defect looked like a weak test.
70
+ // HARNESS CRASH. Removing the catch around ESLint brings the whole harness down with a
71
+ // stack trace instead of a clean, named assertion failure — i.e. the real defect would
72
+ // look like a broken test, not a caught one.
80
73
  return {
81
74
  code: 99,
82
75
  out: `THREW: ${e?.message ?? e}`,
@@ -537,8 +530,8 @@ check(
537
530
  // declaration had already been written — i.e. the install both succeeded and looked like
538
531
  // a crash.
539
532
  // 🔴 THE `.catch` HERE IS LOAD-BEARING, NOT CAUTION. A leaked exception is a PROPERTY that
540
- // gets asserted; caught by letting the harness crash it reads to the battery's driver as
541
- // "the mutation survived", i.e. the real defect would look like a hole in the test.
533
+ // gets asserted; without it, the harness crashes instead of failing a named assertion,
534
+ // i.e. the real defect would look like a hole in the test rather than a caught one.
542
535
  const aborted = await offerWorkflow(dir, "writing", {
543
536
  interactive: true,
544
537
  ask: async () => {
@@ -599,7 +592,7 @@ check(
599
592
  });
600
593
  // 🔴 JUDGE ONLY init's OWN REPORT, BEFORE doctor's banner. The first version of these
601
594
  // three assertions looked at the WHOLE output — and doctor also prints `✗ pdflatex` and
602
- // the same install command. The mutation that stripped the remedy OUT of init stayed
595
+ // the same install command. A version of init with the remedy stripped OUT still stayed
603
596
  // green: the assertion found a line printed by a different command and reported coverage
604
597
  // that did not exist.
605
598
  const own = out.text().split("── paperlint doctor")[0];
@@ -1260,7 +1253,7 @@ check(
1260
1253
  }
1261
1254
 
1262
1255
  console.log(
1263
- `✓ ${String(n)} assertions passed — paperlint lint, one command instead of hand-rolled config`,
1256
+ `✓ ${String(check.count)} assertions passed — paperlint lint, one command instead of hand-rolled config`,
1264
1257
  );
1265
1258
 
1266
1259
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1799,5 +1792,5 @@ console.log(
1799
1792
  }
1800
1793
 
1801
1794
  console.log(
1802
- `✓ ${String(n)} assertions in total, including init's hooks and paperlint new`,
1795
+ `✓ ${String(check.count)} assertions in total, including init's hooks and paperlint new`,
1803
1796
  );