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
@@ -207,3 +207,100 @@ describe("the reference rules", () => {
207
207
  expect(referencesPath(PAPER)).toBe(`${PAPER}/_build/references.json`);
208
208
  });
209
209
  });
210
+
211
+ describe("reading what is on disk", () => {
212
+ it("no paper.tex: the bibliography is refs.bib, from its first byte", () => {
213
+ const files = memoryFiles({ [`${PAPER}/refs.bib`]: ENTRIES });
214
+ expect(bibliographyOf(files, PAPER)).toEqual({
215
+ source: "refs.bib",
216
+ text: ENTRIES,
217
+ offset: 0,
218
+ });
219
+ });
220
+
221
+ it("a references.json of another schema, without entries, or not JSON reads as none", () => {
222
+ for (const body of [
223
+ JSON.stringify({ schema: 999, entries: [] }),
224
+ JSON.stringify({ schema: 1 }),
225
+ "null",
226
+ "{",
227
+ ]) {
228
+ const files = memoryFiles({ [referencesPath(PAPER)]: body });
229
+ expect(readReferences(files, PAPER)).toBeNull();
230
+ }
231
+ });
232
+ });
233
+
234
+ /** A record for `bib`, as the build would write it, with `patch` applied to the document. */
235
+ const record = (
236
+ files: ReturnType<typeof memoryFiles>,
237
+ body: Record<string, unknown>,
238
+ ) => {
239
+ const bib = bibliographyOf(files, PAPER)!;
240
+ files.writeAtomic(
241
+ referencesPath(PAPER) as AbsolutePath,
242
+ new TextEncoder().encode(
243
+ JSON.stringify({
244
+ schema: 1,
245
+ bib: { source: bib.source, sha256: bibHash(bib) },
246
+ ...body,
247
+ }),
248
+ ),
249
+ );
250
+ };
251
+
252
+ describe("the reference rules over a record written by hand", () => {
253
+ it("not-checked with no reason recorded says so", async () => {
254
+ const tex = TEX(ENTRIES);
255
+ const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
256
+ record(files, { status: "not-checked", entries: [] });
257
+ const msgs = await lint(files, tex);
258
+ expect(msgs.map((m) => m.ruleId)).toEqual(["paper/refs-checked"]);
259
+ expect(msgs[0]!.message).toMatch(/no reason recorded/);
260
+ });
261
+
262
+ it("a verdict without a reason, and one for a key the bibliography lacks, still report", async () => {
263
+ const tex = TEX(ENTRIES);
264
+ const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
265
+ record(files, {
266
+ status: "checked",
267
+ entries: [
268
+ { key: "schick2023", exists: "true", authors: "mismatch" },
269
+ { key: "ghost", exists: "false", authors: "match" },
270
+ ],
271
+ });
272
+ const msgs = await lint(files, tex);
273
+ expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
274
+ ["paper/cite-exists", 1],
275
+ ["paper/author-list", 3],
276
+ ]);
277
+ });
278
+ });
279
+
280
+ describe("the reference rules over refs.bib, and on other files", () => {
281
+ it("an external refs.bib: findings sit at the start of paper.tex, naming the key", async () => {
282
+ const tex = "\\documentclass{acmart}\n\\begin{document}x\\end{document}\n";
283
+ const files = memoryFiles({
284
+ [`${PAPER}/paper.tex`]: tex,
285
+ [`${PAPER}/refs.bib`]: ENTRIES,
286
+ });
287
+ record(files, {
288
+ status: "checked",
289
+ entries: [verdict("schick2023", "mismatch")],
290
+ });
291
+ const msgs = await lint(files, tex);
292
+ expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
293
+ ["paper/author-list", 1],
294
+ ]);
295
+ expect(msgs[0]!.message).toMatch(/schick2023/);
296
+ });
297
+
298
+ it("only paper.tex is judged", () => {
299
+ const files = memoryFiles({});
300
+ const rules = referenceRules({ files });
301
+ for (const rule of Object.values(rules))
302
+ expect(rule.create({ filename: `${PAPER}/notes.tex` } as never)).toEqual(
303
+ {},
304
+ );
305
+ });
306
+ });
package/src/references.ts CHANGED
@@ -129,8 +129,8 @@ export async function recordReferences(
129
129
  at(referencesPath(paperDir)),
130
130
  new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`),
131
131
  );
132
- if (doc.status === "not-checked")
133
- return `references NOT checked — ${doc.why ?? "no reason given"}; lint will say so`;
132
+ if (result.kind === "not-checked")
133
+ return `references NOT checked — ${result.why}; lint will say so`;
134
134
  const bad = doc.entries.filter(
135
135
  (e) => e.exists === "false" || e.authors === "mismatch",
136
136
  ).length;
@@ -3,20 +3,16 @@
3
3
  * paperlint ships. The end-to-end path (a project's paperlint.json → `paperlint lint`) is in `cli.harness.mjs`;
4
4
  * this pins the parser's output shape and each refusal.
5
5
  */
6
- import assert from "node:assert/strict";
7
6
  import { dirname, join } from "node:path";
8
7
  import { fileURLToPath } from "node:url";
8
+ import { createChecker } from "../lib/check.mjs";
9
9
 
10
10
  const HERE = dirname(fileURLToPath(import.meta.url));
11
11
  const { parseRuleBlocks, shippedRuleIds, unknownKeys } = await import(
12
12
  join(HERE, "rules-config.ts")
13
13
  );
14
14
 
15
- let n = 0;
16
- const check = (label, cond, detail = "") => {
17
- assert.ok(cond, detail ? `${label} — ${detail}` : label);
18
- n++;
19
- };
15
+ const check = createChecker();
20
16
 
21
17
  const SHIPPED = new Set(["pdf/last-page-balance", "paper/typography"]);
22
18
  const parse = (raw) => parseRuleBlocks(raw, "S", SHIPPED, "/consumer");
@@ -112,7 +108,13 @@ check(
112
108
  markdown: foreign,
113
109
  },
114
110
  },
115
- { plugins: { paper: { rules: { typography: {} } } } },
111
+ // A plugin that carries only a language (the `tex` plugin does) has no rules to ship.
112
+ {
113
+ plugins: {
114
+ paper: { rules: { typography: {} } },
115
+ tex: { languages: {} },
116
+ },
117
+ },
116
118
  { files: ["x"] },
117
119
  ],
118
120
  [foreign],
@@ -126,5 +128,5 @@ check(
126
128
  }
127
129
 
128
130
  console.log(
129
- `✓ ${String(n)} assertions passed — rules-config: blocks parsed with basePath, every refusal names its key`,
131
+ `✓ ${String(check.count)} assertions passed — rules-config: blocks parsed with basePath, every refusal names its key`,
130
132
  );
@@ -6,7 +6,6 @@
6
6
  * So every "found" here has a paired "not found on the neighboring directory", and the defaults
7
7
  * are separately checked against the shape of the live corpus.
8
8
  */
9
- import assert from "node:assert/strict";
10
9
  import {
11
10
  mkdtempSync,
12
11
  mkdirSync,
@@ -17,16 +16,13 @@ import {
17
16
  import { tmpdir } from "node:os";
18
17
  import { join, dirname } from "node:path";
19
18
  import { fileURLToPath } from "node:url";
19
+ import { createChecker } from "../lib/check.mjs";
20
20
 
21
21
  const HERE = dirname(fileURLToPath(import.meta.url));
22
22
  const { checkStructure, formatStructure, asEslintResults, STRUCTURE_DEFAULTS } =
23
23
  await import(join(HERE, "structure.ts"));
24
24
 
25
- let n = 0;
26
- const check = (label, cond) => {
27
- assert.ok(cond, label);
28
- n++;
29
- };
25
+ const check = createChecker();
30
26
 
31
27
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-struct-")));
32
28
  const papers = join(root, "papers");
@@ -177,5 +173,5 @@ try {
177
173
  }
178
174
 
179
175
  console.log(
180
- `✓ ${String(n)} assertions passed — structure: a missing file cannot complain for itself`,
176
+ `✓ ${String(check.count)} assertions passed — structure: a missing file cannot complain for itself`,
181
177
  );
@@ -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
  }