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
@@ -72,8 +72,14 @@ function validatorFor(dir: string): Validate {
72
72
  return v;
73
73
  }
74
74
 
75
- /** Every violation as `<file>: <path> <message>` — one line each, so all of them are fixed at once. */
76
- function violations(file: string, v: Validate): string[] {
75
+ /**
76
+ * Every violation as `<file>: <path> <message>` — one line each, so all of them are fixed at once.
77
+ * Ajv types `errors` as possibly null and a message as optional; both are tolerated, not assumed.
78
+ */
79
+ export function violations(
80
+ file: string,
81
+ v: Pick<Validate, "errors">,
82
+ ): string[] {
77
83
  return (v.errors ?? []).map(
78
84
  (e) => `${file}: ${e.dataPath || "(top level)"} ${e.message ?? ""}`,
79
85
  );
@@ -158,7 +164,9 @@ interface PresetJson {
158
164
  readonly extends?: string;
159
165
  readonly name?: string;
160
166
  readonly template?: string;
161
- readonly tex?: Partial<TexRequirements>;
167
+ /** The schema requires `packages` in a `tex` block; `tools` is optional. */
168
+ readonly tex?: Pick<TexRequirements, "packages"> &
169
+ Partial<Pick<TexRequirements, "tools">>;
162
170
  readonly format?: FormatJson;
163
171
  readonly rules?: Readonly<Record<string, unknown>>;
164
172
  }
@@ -221,9 +229,7 @@ export function parsePreset(
221
229
  extends: orNull(j.extends),
222
230
  name: orNull(j.name),
223
231
  template: orNull(j.template),
224
- tex: j.tex
225
- ? { packages: j.tex.packages ?? {}, tools: j.tex.tools ?? {} }
226
- : null,
232
+ tex: j.tex ? { packages: j.tex.packages, tools: j.tex.tools ?? {} } : null,
227
233
  format: formatOf(j.format),
228
234
  rules: j.rules ?? {},
229
235
  };
@@ -16,7 +16,6 @@
16
16
  * 4. banal, the command's second half: a stand-in banal served from `file://`, never HotCRP —
17
17
  * the installer's own tests are `banal.harness.mjs`; here only the wiring into the command.
18
18
  */
19
- import assert from "node:assert/strict";
20
19
  import { spawnSync } from "node:child_process";
21
20
  import { createHash } from "node:crypto";
22
21
  import {
@@ -33,6 +32,7 @@ import {
33
32
  import { tmpdir } from "node:os";
34
33
  import { dirname, join } from "node:path";
35
34
  import { fileURLToPath, pathToFileURL } from "node:url";
35
+ import { createChecker } from "../lib/check.mjs";
36
36
 
37
37
  const HERE = dirname(fileURLToPath(import.meta.url));
38
38
  const FIXTURE = join(HERE, "..", "fixtures", "toolchain-mirror");
@@ -43,11 +43,7 @@ const B = await import(join(HERE, "adapters", "banal", "index.ts"));
43
43
  const N = await import(join(HERE, "adapters", "node", "index.ts"));
44
44
  const C = await import(join(HERE, "adapters", "curl", "index.ts"));
45
45
 
46
- let n = 0;
47
- const check = (label, cond, detail = "") => {
48
- assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
49
- n++;
50
- };
46
+ const check = createChecker();
51
47
 
52
48
  // ── 1. pure pieces ──────────────────────────────────────────────────────────────────────
53
49
  check(
@@ -163,14 +159,19 @@ for (const f of [
163
159
  "stub-pdflatex",
164
160
  "stub-kpsewhich",
165
161
  "stub-tlmgr",
162
+ "stub-fmtutil-sys",
166
163
  "release-texlive.txt",
167
164
  ])
168
165
  copyFileSync(join(FIXTURE, f), join(staging, f));
169
166
  spawnSync("chmod", [
170
167
  "+x",
171
- ...["install-tl", "stub-pdflatex", "stub-kpsewhich", "stub-tlmgr"].map((f) =>
172
- join(staging, f),
173
- ),
168
+ ...[
169
+ "install-tl",
170
+ "stub-pdflatex",
171
+ "stub-kpsewhich",
172
+ "stub-tlmgr",
173
+ "stub-fmtutil-sys",
174
+ ].map((f) => join(staging, f)),
174
175
  ]);
175
176
  spawnSync(
176
177
  "tar",
@@ -192,6 +193,10 @@ writeFileSync(
192
193
  const url = (dir) => pathToFileURL(dir).href;
193
194
  const MISSING = url(join(work, "no-such-mirror"));
194
195
 
196
+ /** A call that downloads or installs. `tlmgr check depends` only reads, so it is not one. */
197
+ const installs = (c) =>
198
+ c.cmd === "curl" || (c.cmd.endsWith("tlmgr") && c.args.includes("install"));
199
+
195
200
  /** A `spawnSync` that records every program it was asked to run. */
196
201
  const recorder = () => {
197
202
  const calls = [];
@@ -364,8 +369,33 @@ const cmd = ({ banal = BANAL, ...over } = {}) => {
364
369
  );
365
370
  check(
366
371
  "second run: downloads nothing and installs nothing",
367
- !r.calls.some((c) => c.cmd === "curl" || c.cmd.endsWith("tlmgr")),
368
- JSON.stringify(r.calls.map((c) => c.cmd)),
372
+ !r.calls.some(installs),
373
+ JSON.stringify(r.calls.map((c) => [c.cmd, ...c.args])),
374
+ );
375
+ }
376
+ // Guards: a tree with every venue package can still be broken. install-tl that lost a package
377
+ // to a mirror prints "continuing anyway"; seen 2026-09-26 with unicode-data, after which the
378
+ // pdflatex format was never built and every build died before writing a log.
379
+ {
380
+ const tree = join(root, "2026");
381
+ writeFileSync(join(tree, "missing-deps.txt"), "unicode-data\n");
382
+ const check1 = cmd({ check: true });
383
+ check(
384
+ "🔴 a dropped base dependency: --check does not call the tree complete",
385
+ check1.code === 1,
386
+ `${check1.out}\n${check1.err}`,
387
+ );
388
+ const r = cmd();
389
+ check(
390
+ "🔴 a dropped base dependency: the run installs it, then builds the missing formats",
391
+ r.code === 0 &&
392
+ readFileSync(join(tree, "tlmgr.log"), "utf8").includes("unicode-data") &&
393
+ readFileSync(join(tree, "fmtutil.log"), "utf8").trim() === "--missing",
394
+ `${r.out}\n${r.err}`,
395
+ );
396
+ check(
397
+ "a dropped base dependency: the tree is whole afterwards",
398
+ readFileSync(join(tree, "missing-deps.txt"), "utf8").trim() === "",
369
399
  );
370
400
  }
371
401
  {
@@ -500,6 +530,7 @@ const mirrorFor = (name, year, installerYear = year) => {
500
530
  "stub-pdflatex",
501
531
  "stub-kpsewhich",
502
532
  "stub-tlmgr",
533
+ "stub-fmtutil-sys",
503
534
  ])
504
535
  copyFileSync(join(staging, f), join(stage, f));
505
536
  writeFileSync(
@@ -544,7 +575,7 @@ check(
544
575
  r.out.includes("TeX Live 2026") &&
545
576
  r.out.includes("nothing to do") &&
546
577
  !existsSync(join(years, "2027")) &&
547
- !r.calls.some((c) => c.cmd === "curl" || c.cmd.endsWith("tlmgr")),
578
+ !r.calls.some(installs),
548
579
  `${r.out}\n${JSON.stringify(r.calls.map((c) => c.cmd))}`,
549
580
  );
550
581
  }
@@ -657,4 +688,4 @@ check(
657
688
  }
658
689
 
659
690
  rmSync(work, { recursive: true, force: true });
660
- console.log(`toolchain: ${n} checks passed`);
691
+ console.log(`toolchain: ${check.count} checks passed`);
@@ -0,0 +1,280 @@
1
+ /**
2
+ * toolchain.ts edges the harness (a fixture mirror) does not reach, with a scripted runner: curl
3
+ * that fails with and without output, a mirror serving something that is not a tarball, an archive
4
+ * without install-tl or without its release file, install-tl that cannot start, tlmgr with nothing
5
+ * to install; and the pure readers on inputs their happy paths never see.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { join } from "node:path";
11
+ import { test } from "vitest";
12
+ import { useTempDir, writeTree } from "../test/support.mjs";
13
+ import {
14
+ cachedTrees,
15
+ CACHE_ENV,
16
+ describeGaps,
17
+ downloadInstaller,
18
+ ensureTexLive,
19
+ installBase,
20
+ installPackages,
21
+ releaseGap,
22
+ runToolchain,
23
+ tlYear,
24
+ UNSUPPORTED,
25
+ type ToolchainIO,
26
+ } from "./toolchain.ts";
27
+
28
+ const root = useTempDir("paperlint-toolchain-test-");
29
+ type Answer = {
30
+ status: number | null;
31
+ stdout?: string;
32
+ stderr?: string;
33
+ error?: Error;
34
+ };
35
+ /** A runner answering by program name (the last path segment); `onRun` may write files first. */
36
+ function io(
37
+ answers: Record<string, (args: string[]) => Answer>,
38
+ ): ToolchainIO & { lines: string[] } {
39
+ const lines: string[] = [];
40
+ return {
41
+ lines,
42
+ log: (l) => lines.push(l),
43
+ env: {},
44
+ run: ((cmd: string, args: string[]) => {
45
+ const name = cmd.split("/").pop() ?? cmd;
46
+ const answer = answers[`${name} ${args[0] ?? ""}`] ?? answers[name];
47
+ if (!answer) throw new Error(`unexpected ${name} ${args.join(" ")}`);
48
+ return answer(args);
49
+ }) as never,
50
+ };
51
+ }
52
+ const ok = (stdout = ""): Answer => ({ status: 0, stdout, stderr: "" });
53
+
54
+ test("pure readers: a release file with no year, a `local:` line with no year, a gap with no proof file", () => {
55
+ assert.equal(tlYear(""), null);
56
+ assert.equal(releaseGap("local: soon\nrepository: 2026"), null);
57
+ assert.equal(
58
+ describeGaps(
59
+ { packages: ["acmart"], tools: [], dependencies: [] },
60
+ { packages: {}, tools: {} },
61
+ ),
62
+ "acmart ()",
63
+ );
64
+ });
65
+
66
+ test("the cache: a year whose bin directory holds no runnable pdflatex is not a tree", () => {
67
+ const cache = join(root, "cache");
68
+ mkdirSync(join(cache, "2025", "bin", "x86_64-linux"), { recursive: true });
69
+ writeFileSync(join(cache, "2025", "bin", "x86_64-linux", "pdflatex"), "");
70
+ mkdirSync(join(cache, "2026", "bin", "x86_64-linux"), { recursive: true });
71
+ const exe = join(cache, "2026", "bin", "x86_64-linux", "pdflatex");
72
+ writeFileSync(exe, "#!/bin/sh\n");
73
+ chmodSync(exe, 0o755);
74
+ assert.deepEqual(
75
+ cachedTrees(cache).map((t) => t.year),
76
+ ["2026"],
77
+ );
78
+ });
79
+
80
+ test("download: curl failing with its own line, with only an error, with only a code; then a non-tarball", () => {
81
+ const t = io({
82
+ curl: (args) => {
83
+ const url = args[args.length - 1] ?? "";
84
+ if (url.startsWith("m1"))
85
+ return { status: 22, stdout: "", stderr: "curl: (22) 404\n" };
86
+ if (url.startsWith("m2"))
87
+ return { status: null, error: new Error("spawn curl ENOENT") };
88
+ if (url.startsWith("m3")) return { status: 7 };
89
+ return ok();
90
+ },
91
+ "tar tzf": () => ({ status: 2 }),
92
+ });
93
+ assert.equal(
94
+ downloadInstaller(t, ["m1", "m2", "m3", "m4"], join(root, "x.tgz")),
95
+ null,
96
+ );
97
+ assert.deepEqual(t.lines, [
98
+ " ⚠ m1/install-tl-unx.tar.gz: curl: (22) 404",
99
+ " ⚠ m2/install-tl-unx.tar.gz: spawn curl ENOENT",
100
+ " ⚠ m3/install-tl-unx.tar.gz: curl exited 7",
101
+ " ⚠ m4/install-tl-unx.tar.gz returned something that is not a .tar.gz — trying the next mirror",
102
+ ]);
103
+ });
104
+
105
+ /** A runner whose download succeeds and whose `tar xzf` lays out `files` in the work directory. */
106
+ const unpacking = (
107
+ files: Record<string, string> | null,
108
+ more: Record<string, (args: string[]) => Answer> = {},
109
+ ) =>
110
+ io({
111
+ curl: () => ok(),
112
+ "tar tzf": () => ok(),
113
+ "tar xzf": (args) => {
114
+ if (files === null) return { status: 2 };
115
+ writeTree(args[args.length - 1] ?? "", files);
116
+ return ok();
117
+ },
118
+ ...more,
119
+ });
120
+
121
+ test("install: an archive that does not unpack, holds no install-tl, or no release file", () => {
122
+ const bad = [
123
+ unpacking(null),
124
+ unpacking({ "other/x": "" }),
125
+ unpacking({ "install-tl-20260101/install-tl": "" }),
126
+ ].map((t) => installBase(t, join(root, "tl"), ["m"]));
127
+ for (const r of bad)
128
+ assert.deepEqual(r, {
129
+ ok: false,
130
+ lines: [
131
+ "the archive from m does not hold an install-tl with a release-texlive.txt",
132
+ ],
133
+ });
134
+ });
135
+
136
+ test("install: install-tl that cannot start names the error and its last lines", () => {
137
+ const t = unpacking(
138
+ {
139
+ "install-tl-20260101/install-tl": "",
140
+ "install-tl-20260101/release-texlive.txt":
141
+ "TeX Live (https://tug.org/texlive) version 2026\n",
142
+ },
143
+ {
144
+ "install-tl": () => ({ status: null, error: new Error("spawn EACCES") }),
145
+ },
146
+ );
147
+ assert.deepEqual(installBase(t, join(root, "tl2"), ["m"]), {
148
+ ok: false,
149
+ lines: ["install-tl failed (spawn EACCES) — its last lines:"],
150
+ });
151
+ });
152
+
153
+ test("tlmgr: nothing missing, nothing installed", () => {
154
+ const t = io({ kpsewhich: () => ok(), tlmgr: () => ok() });
155
+ const tree = { dir: "/t/2026", year: "2026", bin: "/t/2026/bin/x" };
156
+ assert.deepEqual(
157
+ installPackages(t, tree, { packages: {}, tools: {} }, ["m"]),
158
+ {
159
+ ok: true,
160
+ value: { gaps: { packages: [], tools: [], dependencies: [] }, tail: [] },
161
+ },
162
+ );
163
+ });
164
+
165
+ test("tlmgr: a run that prints nothing still leaves the gap to verify", () => {
166
+ let kpse = 0;
167
+ const t = io({
168
+ kpsewhich: () => ok(kpse++ ? "/t/acmart.cls\n" : ""),
169
+ "tlmgr check": () => ok(),
170
+ "tlmgr --repository": () => ({ status: 0 }),
171
+ });
172
+ const tree = { dir: "/t/2026", year: "2026", bin: "/t/2026/bin/x" };
173
+ const r = installPackages(
174
+ t,
175
+ tree,
176
+ { packages: { acmart: ["acmart.cls"] }, tools: {} },
177
+ ["m"],
178
+ );
179
+ assert.deepEqual(r, {
180
+ ok: true,
181
+ value: { gaps: { packages: [], tools: [], dependencies: [] }, tail: [] },
182
+ });
183
+ });
184
+
185
+ /** A cache holding one tree for `year` with a runnable pdflatex; returns the cache root. */
186
+ function cacheWith(name: string, year: string): string {
187
+ const cache = join(root, name);
188
+ const exe = join(cache, year, "bin", "x86_64-linux", "pdflatex");
189
+ mkdirSync(dirname(exe), { recursive: true });
190
+ writeFileSync(exe, "#!/bin/sh\n");
191
+ chmodSync(exe, 0o755);
192
+ return cache;
193
+ }
194
+ const ACMART = { packages: { acmart: ["acmart.cls"] }, tools: {} };
195
+ function ensure(
196
+ cache: string,
197
+ answers: Record<string, (args: string[]) => Answer>,
198
+ ) {
199
+ const t = io(answers);
200
+ const errs: string[] = [];
201
+ const r = ensureTexLive(ACMART, {
202
+ run: t.run,
203
+ log: t.log,
204
+ err: (l) => errs.push(l),
205
+ env: { [CACHE_ENV]: cache },
206
+ home: root,
207
+ now: () => 0,
208
+ });
209
+ return { r, errs };
210
+ }
211
+
212
+ test("ensure: tlmgr leaves the package missing — the verdict quotes tlmgr's last lines", () => {
213
+ const cache = cacheWith("lacks", "2026");
214
+ const { r, errs } = ensure(cache, {
215
+ kpsewhich: () => ok(),
216
+ "tlmgr check": () => ok(),
217
+ "tlmgr --repository": () =>
218
+ ok("tlmgr: package acmart installed, but nothing changed\n"),
219
+ });
220
+ assert.deepEqual(
221
+ [r, errs.slice(-2)],
222
+ [
223
+ { ok: false },
224
+ [
225
+ " tlmgr's last lines:",
226
+ " tlmgr: package acmart installed, but nothing changed",
227
+ ],
228
+ ],
229
+ );
230
+ });
231
+
232
+ test("ensure: tlmgr refuses across releases, the new tree is installed, and tlmgr still refuses", () => {
233
+ const cache = cacheWith("newer", "2025");
234
+ const refusal = ok(
235
+ "tlmgr: Local TeX Live (2025) is older than remote repository (2026).\n",
236
+ );
237
+ const { r, errs } = ensure(cache, {
238
+ kpsewhich: () => ok(),
239
+ "tlmgr check": () => ok(),
240
+ "tlmgr --repository": () => refusal,
241
+ curl: () => ok(),
242
+ "tar tzf": () => ok(),
243
+ "tar xzf": (args) => {
244
+ writeTree(args[args.length - 1] ?? "", {
245
+ "install-tl-20260101/install-tl": "",
246
+ "install-tl-20260101/release-texlive.txt":
247
+ "TeX Live (https://tug.org/texlive) version 2026\n",
248
+ });
249
+ return ok();
250
+ },
251
+ "install-tl": () => {
252
+ const exe = join(cache, "2026", "bin", "x86_64-linux", "pdflatex");
253
+ mkdirSync(dirname(exe), { recursive: true });
254
+ writeFileSync(exe, "#!/bin/sh\n");
255
+ chmodSync(exe, 0o755);
256
+ return ok();
257
+ },
258
+ });
259
+ assert.deepEqual(
260
+ [r, errs],
261
+ [
262
+ { ok: false },
263
+ [
264
+ `✗ paperlint toolchain: tlmgr still refuses: the repository is TeX Live 2026 and the new tree in ${join(cache, "2026")} is 2025`,
265
+ ],
266
+ ],
267
+ );
268
+ });
269
+
270
+ test("runToolchain with only its installer: the console is the default output", () => {
271
+ const seen: unknown[] = [];
272
+ const saved = console.error;
273
+ console.error = (...a: unknown[]) => void seen.push(a.join(" "));
274
+ try {
275
+ assert.equal(runToolchain({ platform: "win32", banal: {} as never }), 1);
276
+ } finally {
277
+ console.error = saved;
278
+ }
279
+ assert.deepEqual(seen, [`✗ paperlint toolchain: ${UNSUPPORTED}`]);
280
+ });
package/src/toolchain.ts CHANGED
@@ -45,6 +45,7 @@ import { join } from "node:path";
45
45
  import {
46
46
  isExecutable,
47
47
  missingTools,
48
+ missingDependencies,
48
49
  probeTree,
49
50
  supportedPlatform,
50
51
  type Runner,
@@ -161,8 +162,8 @@ export function mirrorsFrom(env: NodeJS.ProcessEnv): readonly string[] {
161
162
 
162
163
  /** `release-texlive.txt`'s first line: "TeX Live (https://tug.org/texlive) version 2026". */
163
164
  export function tlYear(release: string): string | null {
164
- const first = release.split("\n", 1)[0] ?? "";
165
- return /\bversion (\d{4})\b/.exec(first)?.[1] ?? null;
165
+ // `^[^\n]*`: the first line only — a year on a later line is not the release.
166
+ return /^[^\n]*\bversion (\d{4})\b/.exec(release)?.[1] ?? null;
166
167
  }
167
168
 
168
169
  /** The install-tl profile: scheme-basic, no docs or sources, every tree variable inside `dir`. */
@@ -240,14 +241,20 @@ function yearAfter(lines: readonly string[], label: string): string | null {
240
241
  : null;
241
242
  }
242
243
 
243
- /** What a tree lacks: packages by `kpsewhich`, tools by executable. */
244
+ /**
245
+ * What a tree lacks: the venue's packages by `kpsewhich`, tools by executable, and packages the
246
+ * tree's own database depends on but never installed (`tlmgr check depends`).
247
+ */
244
248
  export interface Gaps {
245
249
  readonly packages: readonly string[];
246
250
  readonly tools: readonly string[];
251
+ readonly dependencies: readonly string[];
247
252
  }
248
253
 
249
254
  export const noGaps = (g: Gaps): boolean =>
250
- g.packages.length === 0 && g.tools.length === 0;
255
+ g.packages.length === 0 &&
256
+ g.tools.length === 0 &&
257
+ g.dependencies.length === 0;
251
258
 
252
259
  /** `acmart (acmart.cls), texcount (texcount)` — a gap named with the file that proves it. */
253
260
  export function describeGaps(g: Gaps, tex: TexRequirements): string {
@@ -258,6 +265,9 @@ export function describeGaps(g: Gaps, tex: TexRequirements): string {
258
265
  return [
259
266
  ...named(g.packages, tex.packages),
260
267
  ...named(g.tools, tex.tools),
268
+ ...g.dependencies.map(
269
+ (d) => `${d} (a TeX Live dependency the install left out)`,
270
+ ),
261
271
  ].join(", ");
262
272
  }
263
273
 
@@ -296,6 +306,7 @@ export function gapsOf(
296
306
  return {
297
307
  packages: probeTree(tree.bin, tex.packages, run),
298
308
  tools: missingTools(tex.tools, tree.bin),
309
+ dependencies: missingDependencies(tree.bin, run),
299
310
  };
300
311
  }
301
312
 
@@ -348,7 +359,9 @@ function unpack(io: ToolchainIO, archive: string, work: string): string | null {
348
359
  return dir ? join(work, dir) : null;
349
360
  }
350
361
 
351
- export type Step<T> = { ok: true; value: T } | { ok: false; lines: string[] };
362
+ /** A failed step always says why: its first line is the headline, the rest detail. */
363
+ export type Step<T> =
364
+ { ok: true; value: T } | { ok: false; lines: [string, ...string[]] };
352
365
 
353
366
  /** Download, unpack and run install-tl: a fresh scheme-basic tree in `<root>/<year>`. */
354
367
  export function installBase(
@@ -447,7 +460,7 @@ function runInstallTl(
447
460
  */
448
461
  export type AddStep =
449
462
  | { readonly ok: true; readonly value: { gaps: Gaps; tail: string[] } }
450
- | { readonly ok: false; readonly lines: string[] }
463
+ | { readonly ok: false; readonly lines: [string, ...string[]] }
451
464
  | { readonly ok: false; readonly newer: ReleaseGap };
452
465
 
453
466
  export function installPackages(
@@ -460,7 +473,8 @@ export function installPackages(
460
473
  let last: string[] = [];
461
474
  for (const mirror of mirrors) {
462
475
  if (noGaps(gaps)) break;
463
- const want = [...gaps.packages, ...gaps.tools];
476
+ const want = [...gaps.packages, ...gaps.tools, ...gaps.dependencies];
477
+ const repairsBase = gaps.dependencies.length > 0;
464
478
  io.log(` tlmgr: installing ${want.length} package(s) from ${mirror}…`);
465
479
  const r = io.run(
466
480
  join(tree.bin, "tlmgr"),
@@ -481,6 +495,10 @@ export function installPackages(
481
495
  ],
482
496
  };
483
497
  last = tail(r);
498
+ // A dependency of the base was missing, so the formats it feeds were never built (pdflatex dies
499
+ // before writing a log without them). Build the missing ones now that the package is in.
500
+ if (repairsBase)
501
+ io.run(join(tree.bin, "fmtutil-sys"), ["--missing"], quiet(io, TLMGR_MS));
484
502
  gaps = gapsOf(tree, tex, io.run);
485
503
  }
486
504
  return { ok: true, value: { gaps, tail: last } };
@@ -638,10 +656,9 @@ export const binLine = (tree: CachedTree): string => ` bin: ${tree.bin}`;
638
656
 
639
657
  function fail(
640
658
  err: (line: string) => void,
641
- lines: readonly string[],
659
+ [head, ...rest]: readonly [string, ...string[]],
642
660
  ): InstallResult {
643
- const [head, ...rest] = lines;
644
- err(`✗ paperlint toolchain: ${head ?? "failed"}`);
661
+ err(`✗ paperlint toolchain: ${head}`);
645
662
  for (const l of rest) err(` ${l}`);
646
663
  return { ok: false };
647
664
  }
@@ -688,9 +705,17 @@ function report(o: Resolved, tree: CachedTree | null): number {
688
705
  o.log(binLine(tree));
689
706
  return 0;
690
707
  }
691
- const count = gaps.packages.length + gaps.tools.length;
708
+ const declared = gaps.packages.length + gaps.tools.length;
709
+ // A dependency the install left out is not one of the venue's declared packages: counted apart,
710
+ // so the line never reads "lacks 0 of 51" followed by a name.
711
+ const what = [
712
+ ...(declared ? [`${declared} of ${n} declared packages`] : []),
713
+ ...(gaps.dependencies.length
714
+ ? [`${gaps.dependencies.length} TeX Live base package(s)`]
715
+ : []),
716
+ ].join(" and ");
692
717
  o.log(
693
- `✗ TeX Live ${tree.year} in ${tree.dir} lacks ${count} of ${n} declared packages: ${describeGaps(gaps, o.tex)}`,
718
+ `✗ TeX Live ${tree.year} in ${tree.dir} lacks ${what}: ${describeGaps(gaps, o.tex)}`,
694
719
  );
695
720
  o.log(` run \`npx paperlint toolchain\` to install them`);
696
721
  return 1;
package/src/types.ts CHANGED
@@ -60,11 +60,17 @@ export interface Args {
60
60
  paper: string | null;
61
61
  /** `--format tex|md` for `new` and `init --paper`. Validated by the command, not here. */
62
62
  format: string | null;
63
+ /** `--venue <preset>` for `new`: a shipped preset name, or a path to the project's own. */
64
+ venue: string | null;
65
+ /** `--kind <kind>` for `new`: one of the venue preset's kinds. */
66
+ kind: string | null;
63
67
  /** `--hooks=<mode>` — parsed only so it can be REFUSED by name rather than read as a path. */
64
68
  hooksMode: string | null;
65
69
  help?: boolean;
66
70
  /** A flag that turned out to have no value. A non-empty field is a REFUSAL, not a default. */
67
71
  missingValue?: string;
72
+ /** The first flag the parser does not know. Refused, never read as a path. */
73
+ unknownFlag?: string;
68
74
  }
69
75
 
70
76
  /** A finding about the PRESENCE of a file — what an ESLint rule cannot express. */
@@ -113,6 +113,7 @@ function lint(
113
113
  p: Paper,
114
114
  filename = `${PAPER}/paper.tex`,
115
115
  options: Json = {},
116
+ cwd?: string,
116
117
  ): Finding[] {
117
118
  const files = memoryFiles(paperFiles(p));
118
119
  const rules = venueRules({ files, venuesDir: VENUES });
@@ -123,6 +124,7 @@ function lint(
123
124
  ][]) {
124
125
  const visitor = rule.create({
125
126
  filename,
127
+ ...(cwd === undefined ? {} : { cwd }),
126
128
  sourceCode: { text: TEX },
127
129
  options:
128
130
  name === "geometry" && Object.keys(options).length ? [options] : [],
@@ -316,6 +318,9 @@ describe("pdf/measured — a paper that names a venue and was not measured says
316
318
  const fs = lint({ venue: DECL, facts: null, pdf: null });
317
319
  expect(ids(fs)).toEqual(["pdf/measured:unbuilt"]);
318
320
  expect(fs[0]?.message).toMatch(/paperlint build/);
321
+ // Guards: the message stays one short line — it is the first thing a new user sees.
322
+ expect(fs[0]?.message).toMatch(/^not built yet, so /);
323
+ expect(fs[0]?.message.length).toBeLessThan(110);
319
324
  });
320
325
 
321
326
  it("facts without geometry (no banal): pdf/measured names it, the three geometry rules are silent, fonts still judge", () => {
@@ -552,3 +557,61 @@ describe("paperlint's own config turns the venue rules on for every paper.tex",
552
557
  expect(OPTIONAL_RULES.has("pdf/last-page-balance")).toBe(true);
553
558
  });
554
559
  });
560
+
561
+ describe("a project's own preset, and the facts' other spellings", () => {
562
+ // A standalone preset (no `extends`, so it carries a `tex` block) that sets a body size with no
563
+ // tolerance, a reference range, two columns — and no page size and no font families.
564
+ const HOUSE = {
565
+ [`${PAPER}/house.jsonc`]: JSON.stringify({
566
+ tex: { packages: { acmart: ["acmart.cls"] } },
567
+ format: { columns: 2, body_pt: 9, ref_pt_min: 7, ref_pt_max: 8 },
568
+ }),
569
+ };
570
+ const house = (patch: (f: Json) => void = () => {}) =>
571
+ lint({
572
+ venue: { extends: "./house.jsonc" },
573
+ facts: withFacts(patch),
574
+ extra: HOUSE,
575
+ });
576
+
577
+ it("no page size in the preset: nothing to compare, whatever the PDF measures", () => {
578
+ expect(ids(house((f) => ((f.page_w_in = 5), (f.page_h_in = 5))))).toEqual(
579
+ [],
580
+ );
581
+ });
582
+
583
+ it("a body size with no tolerance is not judged; a reference range with none is judged exactly", () => {
584
+ expect(ids(house((f) => (f.body_pt = 12)))).toEqual([]);
585
+ // 6.9 pt passes agenticdev (its body_pt_tol widens the range) and fails here, where nothing does.
586
+ expect(ids(house((f) => (f.ref_pt = 6.9)))).toEqual(["pdf/limits:refPt"]);
587
+ });
588
+
589
+ it("facts naming the PDF by an absolute path are checked against that file", () => {
590
+ expect(
591
+ lint({
592
+ venue: DECL,
593
+ facts: withFacts((f) => (f.pdf = `${PAPER}/paper.pdf`)),
594
+ }),
595
+ ).toEqual([]);
596
+ });
597
+
598
+ it("a PDF with no fonts at all: each missing family says so", () => {
599
+ const fs = lint({ venue: DECL, facts: withFacts((f) => (f.fonts = [])) });
600
+ expect(ids(fs)).toEqual(["pdf/fonts:noFamily", "pdf/fonts:noFamily"]);
601
+ expect(fs[0]?.message).toMatch(/\(no fonts\)/);
602
+ });
603
+
604
+ it("file names in messages are relative to ESLint's cwd — and never empty", () => {
605
+ const at = (cwd: string) =>
606
+ lint(
607
+ { venue: { kind: "short" }, facts: null, pdf: null },
608
+ undefined,
609
+ {},
610
+ cwd,
611
+ )[0]?.message;
612
+ expect(at("/work")).toMatch(/set "extends" in papers\/p\/paperlint\.json/);
613
+ expect(at(`${PAPER}/paperlint.json`)).toMatch(
614
+ /set "extends" in \/work\/papers\/p\/paperlint\.json/,
615
+ );
616
+ });
617
+ });