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
@@ -9,9 +9,7 @@
9
9
  * this file can tell the two apart.
10
10
  *
11
11
  * Run: npx vigiles test src/doctor.harness.mjs
12
- * Killed by: src/doctor.mutations.mjs
13
12
  */
14
- import assert from "node:assert/strict";
15
13
  import { PAPERS_DIR_FIELD } from "../lib/paper-config.mjs";
16
14
  import {
17
15
  mkdtempSync,
@@ -23,6 +21,7 @@ import {
23
21
  import { tmpdir } from "node:os";
24
22
  import { dirname, join } from "node:path";
25
23
  import { fileURLToPath } from "node:url";
24
+ import { createChecker } from "../lib/check.mjs";
26
25
 
27
26
  const HERE = dirname(fileURLToPath(import.meta.url));
28
27
  const { doctor, detectPapers, PROGRAMS, found } = await import(
@@ -32,11 +31,7 @@ const { papersRoot } = await import(
32
31
  join(HERE, "..", "hooks", "paper-edit-guard.hook.mjs")
33
32
  );
34
33
 
35
- let n = 0;
36
- const check = (label, cond) => {
37
- n++;
38
- assert.ok(cond, label);
39
- };
34
+ const check = createChecker();
40
35
 
41
36
  /** A consumer on disk: a papers directory, and maybe a declaration in the root paperlint.json. */
42
37
  function consumer({ papersDir, declared, makeDir = true }) {
@@ -333,5 +328,5 @@ const runDoctor = (
333
328
  }
334
329
 
335
330
  console.log(
336
- `✓ ${n} assertions passed — paperlint doctor: an install can vouch for itself`,
331
+ `✓ ${check.count} assertions passed — paperlint doctor: an install can vouch for itself`,
337
332
  );
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `doctor.ts` — the edges the harness does not stage: an unreadable directory during paper
3
+ * discovery, a settings file that names no papers directory, an occupied skill entry with no
4
+ * reason, and a hooks section that cannot be read at all.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readdirSync,
11
+ realpathSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { test } from "vitest";
17
+ import { detectPapers, doctor } from "./doctor.ts";
18
+
19
+ const project = (): string =>
20
+ realpathSync(mkdtempSync(join(tmpdir(), "doctor-")));
21
+
22
+ test("detectPapers: a directory that does not exist has no papers", () => {
23
+ assert.deepEqual(detectPapers(join(project(), "gone")), []);
24
+ });
25
+
26
+ test("detectPapers: a child that cannot be listed is skipped, its siblings are still found", () => {
27
+ const root = project();
28
+ mkdirSync(join(root, "locked"));
29
+ mkdirSync(join(root, "papers", "p1"), { recursive: true });
30
+ writeFileSync(join(root, "papers", "p1", "paper.tex"), "");
31
+ const readdir = (dir: string) => {
32
+ if (dir === join(root, "locked")) throw new Error("EACCES");
33
+ return readdirSync(dir, { withFileTypes: true });
34
+ };
35
+ assert.deepEqual(detectPapers(root, 2, readdir), ["papers"]);
36
+ });
37
+
38
+ test("doctor: a settings file silent on papersDir, an occupied entry, an unreadable hooks file", () => {
39
+ const root = project();
40
+ writeFileSync(join(root, "paperlint.json"), "{}\n");
41
+ // `.claude/settings.json` as a DIRECTORY: it exists, and reading it throws EISDIR.
42
+ mkdirSync(join(root, ".claude", "settings.json"), { recursive: true });
43
+ const lines: string[] = [];
44
+ const code = doctor({
45
+ cwd: root,
46
+ log: (s: string) => lines.push(s),
47
+ run: (() => ({ status: 0 })) as never,
48
+ cliPapers: join(root, "papers"),
49
+ skillLinks: () => ({
50
+ ok: true,
51
+ home: join(root, ".claude", "skills"),
52
+ example: null,
53
+ links: [{ name: "paper-pipeline", status: "foreign" }],
54
+ }),
55
+ });
56
+ const out = lines.join("\n");
57
+ assert.match(
58
+ out,
59
+ /✓ paperlint\.json names no papersDir — the default "papers"/,
60
+ );
61
+ assert.match(out, /paper-pipeline — occupied, not the shipped skill/);
62
+ assert.match(out, /\nhooks\n {2}⚠ not checked — EISDIR/);
63
+ assert.equal(typeof code, "number");
64
+ });
package/src/doctor.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * Prior art for the shape: `brew doctor`, `flutter doctor`, `npm doctor`, `expo-doctor`.
22
22
  */
23
23
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
24
- import { existsSync, readFileSync, readdirSync } from "node:fs";
24
+ import { type Dirent, existsSync, readFileSync, readdirSync } from "node:fs";
25
25
  import { join, relative, resolve } from "node:path";
26
26
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
27
27
  import { spawnSync } from "node:child_process";
@@ -101,14 +101,22 @@ export const PROGRAMS: readonly Program[] = [
101
101
  export const found = (bin: string, run = spawnSync): boolean =>
102
102
  run("command", ["-v", bin], { shell: true, stdio: "ignore" }).status === 0;
103
103
 
104
+ /** Lists a directory's entries; the disk by default, a test stages an unreadable child. */
105
+ type ReadDir = (dir: string) => Dirent[];
106
+ const readDir: ReadDir = (dir) => readdirSync(dir, { withFileTypes: true });
107
+
104
108
  /** Directories that look like papers, used to REPLACE a guessed default with a measurement. */
105
- export function detectPapers(cwd: string, depth = 2): string[] {
109
+ export function detectPapers(
110
+ cwd: string,
111
+ depth = 2,
112
+ readdir: ReadDir = readDir,
113
+ ): string[] {
106
114
  const hits: string[] = [];
107
115
  const skip = new Set(["node_modules", ".git", "dist", "_build", ".claude"]);
108
116
  const walk = (dir: string, left: number): void => {
109
117
  let entries;
110
118
  try {
111
- entries = readdirSync(dir, { withFileTypes: true });
119
+ entries = readdir(dir);
112
120
  } catch {
113
121
  return;
114
122
  }
@@ -119,7 +127,7 @@ export function detectPapers(cwd: string, depth = 2): string[] {
119
127
  // A directory whose CHILDREN look like papers is the papers root — not the paper itself.
120
128
  let children;
121
129
  try {
122
- children = readdirSync(here, { withFileTypes: true });
130
+ children = readdir(here);
123
131
  } catch {
124
132
  continue;
125
133
  }
@@ -10,6 +10,10 @@ test("map and andThen run on ok and pass an error through untouched", () => {
10
10
  map(ok(2), (n) => n + 1),
11
11
  ok(3),
12
12
  );
13
+ assert.deepEqual(
14
+ map(half(3), () => assert.fail("mapped after an error")),
15
+ err("3 is odd"),
16
+ );
13
17
  assert.deepEqual(andThen(ok(8), half), ok(4));
14
18
  // Guards: an error short-circuits — the function after it is not called.
15
19
  assert.deepEqual(
@@ -22,8 +22,3 @@ export const match = <T, E, R>(
22
22
  r: Result<T, E>,
23
23
  on: { readonly ok: (t: T) => R; readonly err: (e: E) => R },
24
24
  ): R => (r.ok ? on.ok(r.value) : on.err(r.error));
25
-
26
- /** The `default` of an exhaustive `switch`: a new variant is a compile error, not a silent fall-through. */
27
- export function assertNever(x: never): never {
28
- throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
29
- }
@@ -7,7 +7,6 @@
7
7
  * `probeTree` through a fake runner.
8
8
  * All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/build.mjs`.
9
9
  */
10
- import assert from "node:assert/strict";
11
10
  import {
12
11
  chmodSync,
13
12
  mkdirSync,
@@ -19,6 +18,7 @@ import {
19
18
  import { tmpdir } from "node:os";
20
19
  import { dirname, join } from "node:path";
21
20
  import { fileURLToPath } from "node:url";
21
+ import { createChecker } from "../lib/check.mjs";
22
22
 
23
23
  const HERE = dirname(fileURLToPath(import.meta.url));
24
24
  const {
@@ -28,14 +28,11 @@ const {
28
28
  whichOnPath,
29
29
  isExecutable,
30
30
  probeTree,
31
+ missingDependencies,
31
32
  supportedPlatform,
32
33
  } = await import(join(HERE, "engine.ts"));
33
34
 
34
- let n = 0;
35
- const check = (label, cond, detail = "") => {
36
- assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
37
- n++;
38
- };
35
+ const check = createChecker();
39
36
 
40
37
  // ── 1. resolveEngine ────────────────────────────────────────────────────────────────────
41
38
  const tree = (label, missing = []) => ({ label, bin: `/${label}`, missing });
@@ -251,4 +248,77 @@ check(
251
248
  }
252
249
  }
253
250
 
254
- console.log(`engine: ${n} checks passed`);
251
+ // ── missingDependencies: what an install that "continued anyway" left out ──────────────────
252
+ // Real stdout of `tlmgr check depends` on a tree whose install-tl lost unicode-data to a mirror
253
+ // (2026-09-26): the pdflatex format could not be built, yet every venue .sty was present.
254
+ // Byte for byte: tlmgr opens every section header with a FORM FEED, then a space.
255
+ const BROKEN_DEPENDS =
256
+ "\f DEPENDS WITHOUT PACKAGES:\n" +
257
+ "unicode-data in: collection-basic latex-bin luahbtex luatex\n" +
258
+ "\f PACKAGES NOT IN ANY COLLECTION: acmart booktabs caption\n";
259
+ {
260
+ const calls = [];
261
+ const run = (cmd, args) => {
262
+ calls.push([cmd, args]);
263
+ return {
264
+ stdout: BROKEN_DEPENDS,
265
+ stderr: "tlmgr: An error has occurred.",
266
+ status: 2,
267
+ };
268
+ };
269
+ const missing = missingDependencies("/tl/bin", run);
270
+ check(
271
+ "missingDependencies: asks THAT tree's tlmgr `check depends`",
272
+ calls.length === 1 &&
273
+ calls[0][0] === "/tl/bin/tlmgr" &&
274
+ JSON.stringify(calls[0][1]) === '["check","depends"]',
275
+ JSON.stringify(calls),
276
+ );
277
+ // Guards: a package the tree's own database requires but never installed is a gap.
278
+ check(
279
+ "🔴 missingDependencies: names the dependency the install dropped",
280
+ JSON.stringify(missing) === '["unicode-data"]',
281
+ JSON.stringify(missing),
282
+ );
283
+ }
284
+ check(
285
+ "missingDependencies: packages outside any collection are not gaps",
286
+ JSON.stringify(
287
+ missingDependencies("/tl/bin", () => ({
288
+ stdout: "\f PACKAGES NOT IN ANY COLLECTION: acmart booktabs\n",
289
+ status: 2,
290
+ })),
291
+ ) === "[]",
292
+ );
293
+ check(
294
+ "missingDependencies: a clean tree has none",
295
+ JSON.stringify(
296
+ missingDependencies("/tl/bin", () => ({ stdout: "", status: 0 })),
297
+ ) === "[]",
298
+ );
299
+
300
+ check(
301
+ "missingDependencies: a tlmgr that cannot start reports nothing (the tree is judged by probeTree alone)",
302
+ JSON.stringify(
303
+ missingDependencies("/tl/bin", () => ({ error: new Error("ENOENT") })),
304
+ ) === "[]",
305
+ );
306
+ check(
307
+ "missingDependencies: a section that runs to the end of the output, with no header after it",
308
+ JSON.stringify(
309
+ missingDependencies("/tl/bin", () => ({
310
+ stdout:
311
+ "\f DEPENDS WITHOUT PACKAGES:\nunicode-data in: latex-bin\nhyphen-base in: x",
312
+ status: 2,
313
+ })),
314
+ ) === '["hyphen-base","unicode-data"]',
315
+ );
316
+ check(
317
+ "a runner that returns no stdout at all: tlmgr lists nothing, kpsewhich finds nothing",
318
+ JSON.stringify(missingDependencies("/tl/bin", () => ({ status: 0 }))) ===
319
+ "[]" &&
320
+ JSON.stringify(probeTree("/tl/bin", PKGS, () => ({ status: 0 }))) ===
321
+ JSON.stringify(Object.keys(PKGS).sort()),
322
+ );
323
+
324
+ console.log(`engine: ${check.count} checks passed`);
package/src/engine.ts CHANGED
@@ -164,6 +164,41 @@ export function probeTree(
164
164
  return missingPackages(packages, String(r.stdout ?? ""));
165
165
  }
166
166
 
167
+ /**
168
+ * Packages the tree's OWN database lists as dependencies but never installed, by its `tlmgr check
169
+ * depends`. 🔴 The venue's `.sty` files are not enough evidence of a working tree: install-tl that
170
+ * cannot download a package prints "continuing anyway", and a missing `unicode-data` leaves every
171
+ * `.sty` in place while the pdflatex format cannot be built — pdflatex then dies before writing a
172
+ * log. Only paperlint's cache trees are asked: their tlmgr writes to a directory we own. A tlmgr
173
+ * that cannot start returns nothing here; the tree is then judged by `probeTree` alone.
174
+ */
175
+ export function missingDependencies(
176
+ bin: string,
177
+ run: Runner = spawnSync,
178
+ ): string[] {
179
+ const r = run(join(bin, "tlmgr"), ["check", "depends"], {
180
+ encoding: "utf8",
181
+ stdio: ["ignore", "pipe", "pipe"],
182
+ });
183
+ if (r.error) return [];
184
+ const lines = String(r.stdout ?? "").split("\n");
185
+ const start = lines.findIndex(
186
+ (l) => l.trim() === "DEPENDS WITHOUT PACKAGES:",
187
+ );
188
+ if (start < 0) return [];
189
+ // The section runs until tlmgr's next header. Headers open with a form feed and a space, entries
190
+ // with the package name, so a header is any line with leading whitespace.
191
+ const section = lines.slice(start + 1);
192
+ const end = section.findIndex((l) => l !== l.trimStart() || !l.trim());
193
+ return [
194
+ ...new Set(
195
+ (end < 0 ? section : section.slice(0, end)).map((l) =>
196
+ l.split(" in: ")[0]!.trim(),
197
+ ),
198
+ ),
199
+ ].sort();
200
+ }
201
+
167
202
  /** The first directory on `path` holding a runnable `name` — what a shell would run. */
168
203
  export function whichOnPath(
169
204
  name: string,
@@ -18,6 +18,7 @@ import {
18
18
  factsDocument,
19
19
  factsPath,
20
20
  measurePaper,
21
+ parseFactsText,
21
22
  writeFactsFile,
22
23
  type MeasureOptions,
23
24
  } from "./facts-file.ts";
@@ -278,3 +279,112 @@ test("declaredVenue: a paperlint.json with an unknown key is refused, naming the
278
279
  });
279
280
  assert.throws(() => declaredVenue(files, PAPER), /unknown key "venu"/);
280
281
  });
282
+
283
+ test("a PDF with no text has an empty font list, not a failed document", () => {
284
+ const d = factsDocument({
285
+ pdf: "paper.pdf",
286
+ sha: "a".repeat(64),
287
+ venue: null,
288
+ kind: null,
289
+ read: {
290
+ pages: 1,
291
+ fonts: { kind: "no-text" },
292
+ last: lastPage(0, 0),
293
+ layout: [],
294
+ },
295
+ geometry: NONE,
296
+ });
297
+ assert.deepEqual(d.fonts, []);
298
+ });
299
+
300
+ test("measure: a PDF gone between pdf.js's read and the hash is an error, not a hash of nothing", async () => {
301
+ const { o, files } = setup();
302
+ files.map.delete(PDF);
303
+ const m = await measurePaper(PAPER, PDF, o);
304
+ assert.equal(!m.ok && m.error, `${PDF}: gone after pdf.js read it`);
305
+ });
306
+
307
+ test("measure: no paperlint.json and no override — venue and kind are null", async () => {
308
+ const { o, files } = setup();
309
+ files.map.delete(`${PAPER}/paperlint.json`);
310
+ const m = await measurePaper(PAPER, PDF, o);
311
+ assert.deepEqual(m.ok && [m.value.facts.venue, m.value.facts.kind], [
312
+ null,
313
+ null,
314
+ ]);
315
+ });
316
+
317
+ // ── reading the file back: every refusal names what is wrong ────────────────────────────
318
+ const written = (geometry: Geometry = MEASURED) => ({
319
+ ...doc(lastPage(60, 30), geometry),
320
+ });
321
+ const parse = (d: unknown) => parseFactsText(JSON.stringify(d));
322
+ const why = (d: unknown): string => {
323
+ const r = parse(d);
324
+ return !r.ok && r.error.kind === "broken" ? r.error.why : "";
325
+ };
326
+
327
+ test("parseFactsText: a written document reads back, with and without geometry", () => {
328
+ const measured = parse(written());
329
+ assert.ok(measured.ok);
330
+ assert.equal(measured.value.geometry?.columns, 2);
331
+ const none = parse(written(NONE));
332
+ assert.equal(none.ok && none.value.geometry, null);
333
+ });
334
+
335
+ test("parseFactsText: every malformed shape is refused with its reason", () => {
336
+ assert.equal(why([1]), "not a JSON object");
337
+ assert.equal(why({ ...written(), pdf: "" }), "no `pdf` path");
338
+ assert.equal(why({ ...written(), pdf: 7 }), "no `pdf` path");
339
+ assert.equal(
340
+ why({ ...written(), pdf_sha256: "xyz" }),
341
+ "no `pdf_sha256` of 64 hex digits",
342
+ );
343
+ assert.equal(
344
+ why({ ...written(), fonts: [{ name: "x" }] }),
345
+ "`fonts` is not a list of { name, type, embedded, program }",
346
+ );
347
+ assert.equal(
348
+ why({ ...written(), fonts: ["Times"] }),
349
+ "`fonts` is not a list of { name, type, embedded, program }",
350
+ );
351
+ assert.equal(
352
+ why({ ...written(), geometry_source: 3 }),
353
+ "`geometry_source` is neither a measurer's name nor null",
354
+ );
355
+ assert.equal(
356
+ why({ ...written(), columns: "two" }),
357
+ "`columns` is not a number",
358
+ );
359
+ assert.equal(
360
+ why({ ...written(), ref_pages: null }),
361
+ "`ref_pages` is not a number",
362
+ );
363
+ assert.equal(
364
+ why({ ...written(), pages_by_type: { body: "3" } }),
365
+ "`pages_by_type` is not an object of page counts",
366
+ );
367
+ assert.equal(
368
+ why({ ...written(), pages_by_type: [3] }),
369
+ "`pages_by_type` is not an object of page counts",
370
+ );
371
+ assert.equal(
372
+ why({ ...written(), fonts: "Times" }),
373
+ "`fonts` is not a list of { name, type, embedded, program }",
374
+ );
375
+ const notJson = parseFactsText("{");
376
+ assert.match(
377
+ !notJson.ok && notJson.error.kind === "broken" ? notJson.error.why : "",
378
+ /^not JSON \(/,
379
+ );
380
+ });
381
+
382
+ test("parseFactsText: a missing schema is reported as null, another schema by its value", () => {
383
+ const missing = parse({ ...written(), schema: undefined });
384
+ assert.deepEqual(!missing.ok && missing.error, {
385
+ kind: "schema",
386
+ got: "null",
387
+ });
388
+ const other = parse({ ...written(), schema: 1 });
389
+ assert.deepEqual(!other.ok && other.error, { kind: "schema", got: "1" });
390
+ });
@@ -15,8 +15,6 @@
15
15
  * ⚠️ Assertions at the TOP LEVEL: `vigiles test` imports the file and counts "did not throw"
16
16
  * as a pass.
17
17
  */
18
- import assert from "node:assert/strict";
19
- import { recordCheck } from "vigiles";
20
18
  import {
21
19
  existsSync,
22
20
  mkdirSync,
@@ -29,6 +27,7 @@ import {
29
27
  import { tmpdir } from "node:os";
30
28
  import { dirname, join } from "node:path";
31
29
  import { fileURLToPath } from "node:url";
30
+ import { createChecker } from "../lib/check.mjs";
32
31
 
33
32
  const HERE = dirname(fileURLToPath(import.meta.url));
34
33
  const {
@@ -44,12 +43,7 @@ const {
44
43
  const { claudeCodeHookProtocol } = await import("vigiles/claude-code");
45
44
  const merge = (e, c, m) => claudeCodeHookProtocol.mergeRegistrations(e, c, m);
46
45
 
47
- let n = 0;
48
- const check = (label, cond) => {
49
- n++;
50
- assert.ok(cond, label);
51
- recordCheck(label);
52
- };
46
+ const check = createChecker();
53
47
 
54
48
  // ── the contract with vigiles, checked before anything relies on it ──────────────────────
55
49
  check(
@@ -64,6 +58,40 @@ check(
64
58
  wiring.names.join() ===
65
59
  "paper-edit-guard,paper-skills-nudge,paper-status-gates",
66
60
  );
61
+ // ── settings written by hand: shapes Claude Code would ignore are skipped, not crashed on ──
62
+ check(
63
+ "an event that is not a list, an entry with no `hooks` list, a handler with no command → no commands",
64
+ JSON.stringify(
65
+ wiredCounts(
66
+ {
67
+ hooks: {
68
+ PreToolUse: "npx paperlint hook paper-edit-guard",
69
+ PostToolUse: [{ matcher: "Edit" }, null, { hooks: "x" }],
70
+ Stop: [{ hooks: [{ type: "command" }, null] }],
71
+ },
72
+ },
73
+ wiring.names,
74
+ ),
75
+ ) ===
76
+ JSON.stringify(wiring.names.map((name) => ({ name, ours: 0, other: 0 }))),
77
+ );
78
+ {
79
+ const file = join(tmpdir(), `paperlint-no-hooks-${String(process.pid)}.json`);
80
+ writeFileSync(file, "{}\n");
81
+ let thrown = "";
82
+ try {
83
+ shippedWiring(file);
84
+ } catch (e) {
85
+ thrown = e.message;
86
+ }
87
+ rmSync(file);
88
+ check(
89
+ "a wiring file without `hooks` names no hook — and THROWS instead of wiring nothing",
90
+ thrown === `${file} names no paperlint hook — there is nothing to wire`,
91
+ thrown,
92
+ );
93
+ }
94
+
67
95
  const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-hooks-")));
68
96
  try {
69
97
  const empty = join(work, "empty-hooks.json");
@@ -135,7 +163,7 @@ try {
135
163
  check(
136
164
  "no settings file → it is created with the three hooks",
137
165
  r.status === "written" &&
138
- [...wiredCounts(s, wiring.names).values()].every(
166
+ wiredCounts(s, wiring.names).every(
139
167
  (c) => c.ours === 1 && c.other === 0,
140
168
  ),
141
169
  );
@@ -172,7 +200,7 @@ try {
172
200
  );
173
201
  check(
174
202
  "and ours are there, once each",
175
- [...wiredCounts(s, wiring.names).values()].every((c) => c.ours === 1),
203
+ wiredCounts(s, wiring.names).every((c) => c.ours === 1),
176
204
  );
177
205
  }
178
206
 
@@ -224,7 +252,9 @@ try {
224
252
  // The control: without the check, vigiles' merge WOULD add a second copy. This is the
225
253
  // reason the check exists, measured rather than asserted.
226
254
  const merged = merge(handWired, wiring.compiled, MANAGED_BY);
227
- const c = wiredCounts(merged, wiring.names).get("paper-edit-guard");
255
+ const c = wiredCounts(merged, wiring.names).find(
256
+ (c) => c.name === "paper-edit-guard",
257
+ );
228
258
  check(
229
259
  "(control) vigiles' merge alone would wire paper-edit-guard TWICE here",
230
260
  c.ours === 1 && c.other === 1,
@@ -247,7 +277,7 @@ try {
247
277
 
248
278
  // ── doctor's section ───────────────────────────────────────────────────────────────────
249
279
  const doc = (settings) => {
250
- const dir = project(`doc-${String(n)}`, settings);
280
+ const dir = project(`doc-${String(check.count)}`, settings);
251
281
  return doctorHooks(dir, wiring).join("\n");
252
282
  };
253
283
  const wiredOnce = merge({}, wiring.compiled, MANAGED_BY);
@@ -306,5 +336,5 @@ try {
306
336
  }
307
337
 
308
338
  console.log(
309
- `✓ ${String(n)} assertions passed — the hooks land in .claude/settings.json once`,
339
+ `✓ ${String(check.count)} assertions passed — the hooks land in .claude/settings.json once`,
310
340
  );