paperlint 3.1.0 → 3.1.2

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 (311) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +21 -105
  4. package/CONTRIBUTING.md +23 -25
  5. package/README.md +11 -7
  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/clock.io.d.ts +5 -0
  16. package/dist/adapters/references/clock.io.d.ts.map +1 -0
  17. package/dist/adapters/references/clock.io.js +7 -0
  18. package/dist/adapters/references/clock.io.js.map +1 -0
  19. package/dist/adapters/references/index.d.ts +24 -2
  20. package/dist/adapters/references/index.d.ts.map +1 -1
  21. package/dist/adapters/references/index.js +131 -12
  22. package/dist/adapters/references/index.js.map +1 -1
  23. package/dist/adapters/references/pool.d.ts +8 -0
  24. package/dist/adapters/references/pool.d.ts.map +1 -0
  25. package/dist/adapters/references/pool.js +19 -0
  26. package/dist/adapters/references/pool.js.map +1 -0
  27. package/dist/build.d.ts +14 -2
  28. package/dist/build.d.ts.map +1 -1
  29. package/dist/build.js +17 -11
  30. package/dist/build.js.map +1 -1
  31. package/dist/cli.d.ts +27 -1
  32. package/dist/cli.d.ts.map +1 -1
  33. package/dist/cli.js +71 -55
  34. package/dist/cli.js.map +1 -1
  35. package/dist/doctor.d.ts +27 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +4 -3
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/domain/lookup-cache.d.ts +75 -0
  40. package/dist/domain/lookup-cache.d.ts.map +1 -0
  41. package/dist/domain/lookup-cache.js +137 -0
  42. package/dist/domain/lookup-cache.js.map +1 -0
  43. package/dist/domain/result.d.ts +0 -2
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +0 -4
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/hooks-settings.d.ts +8 -5
  48. package/dist/hooks-settings.d.ts.map +1 -1
  49. package/dist/hooks-settings.js +32 -32
  50. package/dist/hooks-settings.js.map +1 -1
  51. package/dist/init.d.ts +8 -0
  52. package/dist/init.d.ts.map +1 -1
  53. package/dist/init.js +3 -3
  54. package/dist/init.js.map +1 -1
  55. package/dist/latex-log.d.ts +0 -2
  56. package/dist/latex-log.d.ts.map +1 -1
  57. package/dist/latex-log.js +7 -13
  58. package/dist/latex-log.js.map +1 -1
  59. package/dist/link-skills.d.ts +15 -2
  60. package/dist/link-skills.d.ts.map +1 -1
  61. package/dist/link-skills.js +36 -20
  62. package/dist/link-skills.js.map +1 -1
  63. package/dist/paper-files.d.ts.map +1 -1
  64. package/dist/paper-files.js +6 -4
  65. package/dist/paper-files.js.map +1 -1
  66. package/dist/pdf-facts.d.ts +11 -0
  67. package/dist/pdf-facts.d.ts.map +1 -1
  68. package/dist/pdf-facts.js +16 -18
  69. package/dist/pdf-facts.js.map +1 -1
  70. package/dist/pdf-geometry.js +1 -1
  71. package/dist/pdf-geometry.js.map +1 -1
  72. package/dist/ports/check-references.d.ts +12 -2
  73. package/dist/ports/check-references.d.ts.map +1 -1
  74. package/dist/ports/check-references.js +0 -5
  75. package/dist/ports/check-references.js.map +1 -1
  76. package/dist/presets.d.ts +2 -0
  77. package/dist/presets.d.ts.map +1 -1
  78. package/dist/presets.js +3 -1
  79. package/dist/presets.js.map +1 -1
  80. package/dist/references.d.ts +11 -3
  81. package/dist/references.d.ts.map +1 -1
  82. package/dist/references.js +63 -15
  83. package/dist/references.js.map +1 -1
  84. package/dist/tex-requirements.d.ts +8 -0
  85. package/dist/tex-requirements.d.ts.map +1 -1
  86. package/dist/tex-requirements.js +6 -5
  87. package/dist/tex-requirements.js.map +1 -1
  88. package/dist/toolchain.d.ts +3 -2
  89. package/dist/toolchain.d.ts.map +1 -1
  90. package/dist/toolchain.js +4 -5
  91. package/dist/toolchain.js.map +1 -1
  92. package/dist/venue-rules.d.ts.map +1 -1
  93. package/dist/venue-rules.js +11 -9
  94. package/dist/venue-rules.js.map +1 -1
  95. package/docs/prior-art/README.md +11 -10
  96. package/docs/prior-art/citation-checkers.md +174 -0
  97. package/docs/prior-art/test-tooling.md +7 -30
  98. package/docs/references.md +126 -0
  99. package/docs/testing.md +118 -0
  100. package/docs/toolchain.md +6 -4
  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 +9 -2
  109. package/eslint-rules/paper-typography.test.mjs +146 -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.test.mjs +37 -1
  113. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  114. package/eslint-rules/tex-build.mjs +13 -13
  115. package/eslint.config.mjs +6 -2
  116. package/hooks/hooks.harness.mjs +6 -11
  117. package/hooks/paper-edit-guard.hook.mjs +2 -2
  118. package/hooks/paper-edit-guard.test.mjs +61 -0
  119. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  120. package/hooks/paper-skills-nudge.test.mjs +38 -0
  121. package/hooks/paper-status-gates.test.mjs +42 -0
  122. package/lib/agent-cli-version.harness.mjs +7 -0
  123. package/lib/check.d.mts +17 -0
  124. package/lib/check.mjs +50 -0
  125. package/lib/check.test.ts +67 -0
  126. package/lib/markdown.mjs +16 -69
  127. package/lib/markdown.test.mjs +246 -0
  128. package/lib/paper-config.harness.mjs +28 -9
  129. package/lib/paper-config.mjs +7 -2
  130. package/lib/skill-checks.mjs +18 -46
  131. package/lib/skill-checks.test.mjs +297 -0
  132. package/lib/skill-corpus.mjs +11 -13
  133. package/lib/skill-corpus.test.mjs +54 -0
  134. package/lib/skill-trigger-cases.harness.mjs +57 -4
  135. package/package.json +6 -5
  136. package/scripts/check.harness.mjs +4 -10
  137. package/scripts/check.mjs +45 -42
  138. package/scripts/check.test.mjs +104 -0
  139. package/scripts/coverage-config.test.ts +406 -0
  140. package/scripts/eslint-report-guard.test.mjs +48 -0
  141. package/scripts/harness-api.frozen.json +0 -2
  142. package/scripts/layer-legacy-frozen.d.mts +9 -1
  143. package/scripts/layer-legacy-frozen.mjs +28 -11
  144. package/scripts/layer-legacy-frozen.test.ts +143 -1
  145. package/scripts/rules-are-content-only.harness.mjs +3 -11
  146. package/scripts/rules-are-content-only.mjs +29 -19
  147. package/scripts/rules-are-content-only.test.mjs +61 -0
  148. package/scripts/rules-see-files.mjs +11 -9
  149. package/scripts/rules-see-files.test.mjs +71 -0
  150. package/scripts/test.mjs +44 -0
  151. package/scripts/test.test.mjs +52 -0
  152. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  153. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  154. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  155. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  156. package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
  157. package/skills/paper-pipeline/scripts/README.md +13 -132
  158. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  159. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  160. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  161. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  162. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  163. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  164. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  165. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  166. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  167. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  168. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  169. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  170. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  171. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  172. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  173. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  174. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  175. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  176. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  177. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  178. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  179. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  180. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  181. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  182. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  183. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  184. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  185. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  186. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  187. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  188. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  189. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  190. package/skills/render-paper/checkers-requirements.txt +30 -19
  191. package/skills/render-paper/ensure-checkers.sh +20 -4
  192. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  193. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  194. package/skills/tighten-paper/structure.test.mjs +116 -0
  195. package/skills/verify-citations/SKILL.md +30 -14
  196. package/skills/verify-citations/SKILL.md.spec.ts +29 -13
  197. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
  198. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  199. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  200. package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
  201. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  202. package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
  203. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
  204. package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
  205. package/src/adapters/banal/failure.test.ts +70 -1
  206. package/src/adapters/banal/index.test.ts +16 -0
  207. package/src/adapters/banal/output.test.ts +5 -0
  208. package/src/adapters/curl/download.test.ts +43 -1
  209. package/src/adapters/memory/index.test.ts +25 -0
  210. package/src/adapters/node/files.io.ts +3 -2
  211. package/src/adapters/node/files.test.ts +13 -1
  212. package/src/adapters/node/process.test.ts +38 -0
  213. package/src/adapters/pdfjs/fill.test.ts +34 -0
  214. package/src/adapters/pdfjs/fill.ts +44 -25
  215. package/src/adapters/references/clock.io.ts +8 -0
  216. package/src/adapters/references/index.test.ts +560 -0
  217. package/src/adapters/references/index.ts +203 -19
  218. package/src/adapters/references/pool.test.ts +31 -0
  219. package/src/adapters/references/pool.ts +24 -0
  220. package/src/build-engine.harness.mjs +3 -7
  221. package/src/build-engine.test.ts +137 -0
  222. package/src/build.harness.mjs +5 -9
  223. package/src/build.test.ts +362 -0
  224. package/src/build.ts +23 -12
  225. package/src/cli.harness.mjs +10 -17
  226. package/src/cli.test.ts +584 -0
  227. package/src/cli.ts +112 -66
  228. package/src/doctor.harness.mjs +3 -8
  229. package/src/doctor.test.ts +64 -0
  230. package/src/doctor.ts +12 -4
  231. package/src/domain/lookup-cache.test.ts +178 -0
  232. package/src/domain/lookup-cache.ts +202 -0
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +27 -7
  236. package/src/facts-file.test.ts +110 -0
  237. package/src/hooks-settings.harness.mjs +43 -13
  238. package/src/hooks-settings.ts +41 -35
  239. package/src/init.test.ts +308 -5
  240. package/src/init.ts +3 -3
  241. package/src/latex-log.harness.mjs +32 -7
  242. package/src/latex-log.ts +6 -13
  243. package/src/latex-loop.harness.mjs +8 -7
  244. package/src/link-skills.harness.mjs +3 -8
  245. package/src/link-skills.test.ts +105 -0
  246. package/src/link-skills.ts +75 -21
  247. package/src/new-paper.harness.mjs +3 -7
  248. package/src/new-paper.test.ts +31 -1
  249. package/src/paper-files.test.ts +12 -0
  250. package/src/paper-files.ts +9 -5
  251. package/src/paper-settings.test.ts +20 -0
  252. package/src/pdf-facts.harness.mjs +3 -6
  253. package/src/pdf-facts.test.ts +144 -0
  254. package/src/pdf-facts.ts +17 -19
  255. package/src/pdf-geometry.harness.mjs +3 -7
  256. package/src/pdf-geometry.ts +1 -1
  257. package/src/ports/check-references.ts +16 -2
  258. package/src/presets.test.ts +2 -0
  259. package/src/presets.ts +7 -1
  260. package/src/references.test.ts +257 -10
  261. package/src/references.ts +93 -17
  262. package/src/rules-config.harness.mjs +10 -8
  263. package/src/structure.harness.mjs +3 -7
  264. package/src/tex-requirements.harness.mjs +34 -7
  265. package/src/tex-requirements.ts +12 -6
  266. package/src/toolchain.harness.mjs +3 -7
  267. package/src/toolchain.test.ts +280 -0
  268. package/src/toolchain.ts +8 -7
  269. package/src/venue-rules.test.ts +60 -0
  270. package/src/venue-rules.ts +13 -11
  271. package/action.mutations.mjs +0 -162
  272. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  273. package/eslint-rules/latex-language.mutations.mjs +0 -196
  274. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  275. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  276. package/eslint-rules/papers.mutations.mjs +0 -186
  277. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  278. package/eslint-rules/tex-build.mutations.mjs +0 -258
  279. package/hooks/hooks.mutations.mjs +0 -337
  280. package/lib/agent-cli-version.mutations.mjs +0 -109
  281. package/lib/mutation-driver.harness.mjs +0 -227
  282. package/lib/mutation-driver.mjs +0 -397
  283. package/lib/mutation-driver.mutations.mjs +0 -68
  284. package/lib/paper-config.mutations.mjs +0 -143
  285. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  286. package/scripts/check.mutations.mjs +0 -110
  287. package/scripts/exclusive.mjs +0 -138
  288. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  289. package/scripts/mutation-batteries-frozen.mjs +0 -238
  290. package/scripts/mutation-batteries.frozen.json +0 -108
  291. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  292. package/scripts/rules-see-files.mutations.mjs +0 -131
  293. package/scripts/run-mutations.mjs +0 -100
  294. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  295. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  296. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  297. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  298. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  299. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  300. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  301. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  302. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  303. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  304. package/skills/skill-contract.mutations.mjs +0 -179
  305. package/src/build.mutations.mjs +0 -196
  306. package/src/cli.mutations.mjs +0 -414
  307. package/src/doctor.mutations.mjs +0 -175
  308. package/src/hooks-settings.mutations.mjs +0 -99
  309. package/src/link-skills.mutations.mjs +0 -140
  310. package/src/new-paper.mutations.mjs +0 -65
  311. package/src/structure.mutations.mjs +0 -83
@@ -7,7 +7,6 @@
7
7
  * 2. the schema REJECTS what it must — each bad profile names the offending path;
8
8
  * 3. what a paper gets: its venue plus the base set, or the base set with the reason said.
9
9
  */
10
- import assert from "node:assert/strict";
11
10
  import {
12
11
  copyFileSync,
13
12
  mkdtempSync,
@@ -19,6 +18,7 @@ import { tmpdir } from "node:os";
19
18
  import { dirname, join } from "node:path";
20
19
  import { fileURLToPath } from "node:url";
21
20
  import ts from "typescript";
21
+ import { createChecker } from "../lib/check.mjs";
22
22
 
23
23
  const HERE = dirname(fileURLToPath(import.meta.url));
24
24
  const VENUES = join(
@@ -31,11 +31,7 @@ const VENUES = join(
31
31
  );
32
32
  const R = await import(join(HERE, "tex-requirements.ts"));
33
33
 
34
- let n = 0;
35
- const check = (label, cond, detail = "") => {
36
- assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
37
- n++;
38
- };
34
+ const check = createChecker();
39
35
  const throws = (fn) => {
40
36
  try {
41
37
  fn();
@@ -229,4 +225,35 @@ rmSync(tmp, { recursive: true, force: true });
229
225
  );
230
226
  }
231
227
 
232
- console.log(`tex-requirements: ${n} checks passed`);
228
+ // ── 5. the parser's tolerances: an optional block left out, and Ajv's optional fields ───
229
+ {
230
+ const bare = R.parsePreset(
231
+ '{ "extends": "paperlint:aisec" }',
232
+ "bare.jsonc",
233
+ VENUES,
234
+ );
235
+ check(
236
+ "a preset that only extends another: no tex block, `rules` an empty object",
237
+ JSON.stringify([bare.extends, bare.tex, bare.rules]) ===
238
+ '["paperlint:aisec",null,{}]',
239
+ bare,
240
+ );
241
+ // Guards: `packages` is read without a fallback because the schema refuses a tex block
242
+ // without it — this is the refusal the type relies on.
243
+ check(
244
+ "a tex block without `packages` is refused by the schema, before anything reads it",
245
+ /tex\.jsonc: \.tex should have required property 'packages'/.test(
246
+ throws(() => R.parsePreset('{ "tex": {} }', "tex.jsonc", VENUES)),
247
+ ),
248
+ throws(() => R.parsePreset('{ "tex": {} }', "tex.jsonc", VENUES)),
249
+ );
250
+ check(
251
+ "violations: Ajv's null `errors` is no violation; a message Ajv left out is an empty one",
252
+ R.violations("f.jsonc", { errors: null }).length === 0 &&
253
+ R.violations("f.jsonc", {
254
+ errors: [{ dataPath: "", message: undefined }],
255
+ })[0] === "f.jsonc: (top level) ",
256
+ );
257
+ }
258
+
259
+ console.log(`tex-requirements: ${check.count} checks passed`);
@@ -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(
@@ -692,4 +688,4 @@ check(
692
688
  }
693
689
 
694
690
  rmSync(work, { recursive: true, force: true });
695
- 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
@@ -162,8 +162,8 @@ export function mirrorsFrom(env: NodeJS.ProcessEnv): readonly string[] {
162
162
 
163
163
  /** `release-texlive.txt`'s first line: "TeX Live (https://tug.org/texlive) version 2026". */
164
164
  export function tlYear(release: string): string | null {
165
- const first = release.split("\n", 1)[0] ?? "";
166
- 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;
167
167
  }
168
168
 
169
169
  /** The install-tl profile: scheme-basic, no docs or sources, every tree variable inside `dir`. */
@@ -359,7 +359,9 @@ function unpack(io: ToolchainIO, archive: string, work: string): string | null {
359
359
  return dir ? join(work, dir) : null;
360
360
  }
361
361
 
362
- 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[]] };
363
365
 
364
366
  /** Download, unpack and run install-tl: a fresh scheme-basic tree in `<root>/<year>`. */
365
367
  export function installBase(
@@ -458,7 +460,7 @@ function runInstallTl(
458
460
  */
459
461
  export type AddStep =
460
462
  | { readonly ok: true; readonly value: { gaps: Gaps; tail: string[] } }
461
- | { readonly ok: false; readonly lines: string[] }
463
+ | { readonly ok: false; readonly lines: [string, ...string[]] }
462
464
  | { readonly ok: false; readonly newer: ReleaseGap };
463
465
 
464
466
  export function installPackages(
@@ -654,10 +656,9 @@ export const binLine = (tree: CachedTree): string => ` bin: ${tree.bin}`;
654
656
 
655
657
  function fail(
656
658
  err: (line: string) => void,
657
- lines: readonly string[],
659
+ [head, ...rest]: readonly [string, ...string[]],
658
660
  ): InstallResult {
659
- const [head, ...rest] = lines;
660
- err(`✗ paperlint toolchain: ${head ?? "failed"}`);
661
+ err(`✗ paperlint toolchain: ${head}`);
661
662
  for (const l of rest) err(` ${l}`);
662
663
  return { ok: false };
663
664
  }
@@ -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] : [],
@@ -555,3 +557,61 @@ describe("paperlint's own config turns the venue rules on for every paper.tex",
555
557
  expect(OPTIONAL_RULES.has("pdf/last-page-balance")).toBe(true);
556
558
  });
557
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
+ });
@@ -253,17 +253,19 @@ function judgePages(g: FlatGeometry, resolved: Resolved): Finding[] {
253
253
  [kind.limits.bodyPagesMax, g.body_pages, "body pages"],
254
254
  [kind.limits.refPagesMax, g.ref_pages, "reference pages"],
255
255
  ];
256
- return pages
257
- .filter(([max, got]) => max !== null && got > max)
258
- .map(([max, got, what]) =>
259
- finding("pages", {
260
- what,
261
- got,
262
- max: max ?? 0,
263
- venue: resolved.venue,
264
- kind: kind.name,
265
- }),
266
- );
256
+ return pages.flatMap(([max, got, what]) =>
257
+ max !== null && got > max
258
+ ? [
259
+ finding("pages", {
260
+ what,
261
+ got,
262
+ max,
263
+ venue: resolved.venue,
264
+ kind: kind.name,
265
+ }),
266
+ ]
267
+ : [],
268
+ );
267
269
  }
268
270
 
269
271
  function judgeRefPt(g: FlatGeometry, resolved: Resolved): Finding[] {