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
@@ -0,0 +1,105 @@
1
+ /**
2
+ * link-skills.ts edges the harness does not reach: a resolver error without a code, a package that
3
+ * resolved outside every `node_modules` up the tree, a skills directory that is missing or holds a
4
+ * dangling link, a skills home that cannot be made, an entry that is a plain file, and — through
5
+ * the injected fs — an entry that cannot be resolved and a link that cannot be created.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import {
9
+ lstatSync,
10
+ mkdirSync,
11
+ readlinkSync,
12
+ realpathSync,
13
+ symlinkSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { test } from "vitest";
17
+ import { useTempDir, writeTree } from "../test/support.mjs";
18
+ import { linkSkills, locatePackage, type LinkFs } from "./link-skills.ts";
19
+
20
+ const root = useTempDir("paperlint-link-skills-");
21
+ const realFs: LinkFs = { lstatSync, realpathSync, readlinkSync, symlinkSync };
22
+
23
+ /** A package directory shipping skills `names` under `skills/`. */
24
+ function pkg(name: string, names: string[]): string {
25
+ const dir = join(root, name, "pkg");
26
+ mkdirSync(join(dir, "skills"), { recursive: true });
27
+ writeTree(
28
+ dir,
29
+ Object.fromEntries(names.map((n) => [`skills/${n}/SKILL.md`, "# s\n"])),
30
+ );
31
+ return dir;
32
+ }
33
+ const at = (dir: string) => () => ({ dir, spelled: dir });
34
+
35
+ test("locatePackage: a resolver error without a code; a package found outside every node_modules", () => {
36
+ assert.deepEqual(
37
+ locatePackage(root, {
38
+ resolveManifest: () => {
39
+ throw new Error("odd\nsecond line");
40
+ },
41
+ }),
42
+ { error: "error: odd" },
43
+ );
44
+ const elsewhere = pkg("elsewhere", []);
45
+ assert.deepEqual(
46
+ locatePackage(root, {
47
+ resolveManifest: () => join(elsewhere, "package.json"),
48
+ }),
49
+ { dir: elsewhere, spelled: elsewhere },
50
+ );
51
+ });
52
+
53
+ test("a package without a skills directory, or one holding a dangling link, is not linked", () => {
54
+ const bare = join(root, "bare");
55
+ mkdirSync(bare, { recursive: true });
56
+ const missing = linkSkills(join(root, "p1"), { locate: at(bare) });
57
+ assert.deepEqual(missing, {
58
+ ok: false,
59
+ error: `the package's skills directory is missing: ${join(bare, "skills")}`,
60
+ });
61
+ const dangling = pkg("dangling", ["a"]);
62
+ symlinkSync(join(root, "gone"), join(dangling, "skills", "b"));
63
+ const r = linkSkills(join(root, "p2"), { locate: at(dangling) });
64
+ assert.equal(r.ok, false);
65
+ assert.match(!r.ok ? r.error : "", /^1 skill link\(s\) in .* lead nowhere:/);
66
+ });
67
+
68
+ test("a skills home that cannot be made is named", () => {
69
+ const project = writeTree(join(root, "p3"), {
70
+ ".claude": "a file, not a directory",
71
+ });
72
+ const r = linkSkills(project, { locate: at(pkg("home", ["a"])) });
73
+ assert.equal(r.ok, false);
74
+ assert.match(!r.ok ? r.error : "", /^cannot create .*\.claude\/skills: /);
75
+ });
76
+
77
+ test("entries that are someone else's: a plain file, one that cannot be resolved, a link that cannot be made", () => {
78
+ const shipped = pkg("entries", ["file", "unresolved", "refused"]);
79
+ const project = writeTree(join(root, "p4"), { ".claude/skills/file": "x" });
80
+ mkdirSync(join(project, ".claude", "skills", "unresolved"));
81
+ const fs: LinkFs = {
82
+ ...realFs,
83
+ realpathSync: (p) => {
84
+ if (p.endsWith("unresolved")) throw new Error("ELOOP");
85
+ return realpathSync(p);
86
+ },
87
+ symlinkSync: () => {
88
+ throw new Error("EEXIST: raced");
89
+ },
90
+ };
91
+ const r = linkSkills(project, { locate: at(shipped), fs });
92
+ assert.deepEqual(r.ok ? r.links : [], [
93
+ { name: "file", status: "foreign", reason: "a file" },
94
+ {
95
+ name: "refused",
96
+ status: "foreign",
97
+ reason: "could not create the link: EEXIST: raced",
98
+ },
99
+ {
100
+ name: "unresolved",
101
+ status: "foreign",
102
+ reason: "an entry that cannot be resolved",
103
+ },
104
+ ]);
105
+ });
@@ -60,13 +60,18 @@ export type Located =
60
60
  *
61
61
  * @returns `dir` — the real directory; `spelled` — the same directory as the project names it.
62
62
  */
63
- export function locatePackage(project: string): Located {
63
+ export function locatePackage(
64
+ project: string,
65
+ {
66
+ resolveManifest = (from: string): string =>
67
+ createRequire(
68
+ pathToFileURL(join(from, "__paperlint_locate__.js")).href,
69
+ ).resolve(`${PACKAGE_NAME}/package.json`),
70
+ }: { resolveManifest?: (project: string) => string } = {},
71
+ ): Located {
64
72
  let manifest: string;
65
73
  try {
66
- const req = createRequire(
67
- pathToFileURL(join(project, "__paperlint_locate__.js")).href,
68
- );
69
- manifest = req.resolve(`${PACKAGE_NAME}/package.json`);
74
+ manifest = resolveManifest(project);
70
75
  } catch (e) {
71
76
  return {
72
77
  error: `${(e as NodeJS.ErrnoException).code ?? "error"}: ${(e as Error).message.split("\n")[0]}`,
@@ -126,10 +131,28 @@ export type LinkReport =
126
131
  }
127
132
  | { readonly ok: false; readonly error: string };
128
133
 
134
+ /** The filesystem calls linking makes — the disk by default; a test stages a race or a refusal. */
135
+ export interface LinkFs {
136
+ readonly lstatSync: (p: string) => {
137
+ isSymbolicLink(): boolean;
138
+ isDirectory(): boolean;
139
+ };
140
+ readonly realpathSync: (p: string) => string;
141
+ readonly readlinkSync: (p: string) => string;
142
+ readonly symlinkSync: (target: string, path: string, type: "dir") => void;
143
+ }
144
+ const nodeLinkFs: LinkFs = {
145
+ lstatSync,
146
+ realpathSync,
147
+ readlinkSync,
148
+ symlinkSync,
149
+ };
150
+
129
151
  /** What occupies `entry`, judged against the directory it should lead to. */
130
152
  function inspect(
131
153
  entry: string,
132
154
  want: string,
155
+ { lstatSync, realpathSync, readlinkSync }: LinkFs,
133
156
  ): { status: "present" | "missing" | "foreign"; reason?: string } {
134
157
  let st;
135
158
  try {
@@ -163,15 +186,22 @@ function linkOne(
163
186
  target,
164
187
  want,
165
188
  write,
166
- }: { entry: string; target: string; want: string; write: boolean },
189
+ fs,
190
+ }: {
191
+ entry: string;
192
+ target: string;
193
+ want: string;
194
+ write: boolean;
195
+ fs: LinkFs;
196
+ },
167
197
  ): SkillLink {
168
- const seen = inspect(entry, want);
198
+ const seen = inspect(entry, want, fs);
169
199
  if (!write || seen.status !== "missing")
170
200
  return seen.reason
171
201
  ? { name, status: seen.status, reason: seen.reason }
172
202
  : { name, status: seen.status };
173
203
  try {
174
- symlinkSync(target, entry, "dir");
204
+ fs.symlinkSync(target, entry, "dir");
175
205
  return { name, status: "created" };
176
206
  } catch (e) {
177
207
  return {
@@ -182,6 +212,16 @@ function linkOne(
182
212
  }
183
213
  }
184
214
 
215
+ /** Create the skills home; the reason it could not be, or null. */
216
+ function makeHome(home: string): string | null {
217
+ try {
218
+ mkdirSync(home, { recursive: true });
219
+ return null;
220
+ } catch (e) {
221
+ return `cannot create ${home}: ${(e as Error).message}`;
222
+ }
223
+ }
224
+
185
225
  /**
186
226
  * Link every shipped skill into `<project>/.claude/skills/`, or — with `write: false` — only
187
227
  * report which ones are missing (that is what `paperlint doctor` asks).
@@ -191,7 +231,12 @@ export function linkSkills(
191
231
  {
192
232
  write = true,
193
233
  locate = locatePackage,
194
- }: { write?: boolean; locate?: (p: string) => Located } = {},
234
+ fs = nodeLinkFs,
235
+ }: {
236
+ write?: boolean;
237
+ locate?: (p: string) => Located;
238
+ fs?: LinkFs;
239
+ } = {},
195
240
  ): LinkReport {
196
241
  const pkg = locate(project);
197
242
  if ("error" in pkg)
@@ -205,20 +250,28 @@ export function linkSkills(
205
250
  const spelledSkills = join(pkg.spelled, relative(pkg.dir, shipped.skillsDir));
206
251
 
207
252
  const home = join(project, SKILLS_HOME);
208
- if (write && shipped.names.length > 0) {
209
- try {
210
- mkdirSync(home, { recursive: true });
211
- } catch (e) {
212
- return {
213
- ok: false,
214
- error: `cannot create ${home}: ${(e as Error).message}`,
215
- };
216
- }
217
- }
253
+ const made = write && shipped.names.length > 0 ? makeHome(home) : null;
254
+ if (made !== null) return { ok: false, error: made };
255
+ return {
256
+ ok: true,
257
+ home,
258
+ ...linkAll(shipped, { spelledSkills, home, write, fs }),
259
+ };
260
+ }
261
+
262
+ /** Every shipped skill's link, and the first one's target as the report's example. */
263
+ function linkAll(
264
+ shipped: { readonly skillsDir: string; readonly names: readonly string[] },
265
+ {
266
+ spelledSkills,
267
+ home,
268
+ write,
269
+ fs,
270
+ }: { spelledSkills: string; home: string; write: boolean; fs: LinkFs },
271
+ ): { example: string | null; links: SkillLink[] } {
218
272
  // A relative target resolves against the directory that PHYSICALLY holds the link, so it is
219
273
  // computed from the real one — `.claude` may itself be a symlink.
220
274
  const physicalHome = existsSync(home) ? realpathSync(home) : resolve(home);
221
-
222
275
  let example: string | null = null;
223
276
  const links = shipped.names.map((name): SkillLink => {
224
277
  const target = relative(physicalHome, join(spelledSkills, name));
@@ -228,7 +281,8 @@ export function linkSkills(
228
281
  target,
229
282
  want: realpathSync(join(shipped.skillsDir, name)),
230
283
  write,
284
+ fs,
231
285
  });
232
286
  });
233
- return { ok: true, home, example, links };
287
+ return { example, links };
234
288
  }
@@ -16,7 +16,6 @@
16
16
  * ⚠️ Assertions at the TOP LEVEL: `vigiles test` imports the file and counts "did not throw"
17
17
  * as a pass.
18
18
  */
19
- import assert from "node:assert/strict";
20
19
  import {
21
20
  existsSync,
22
21
  mkdirSync,
@@ -32,6 +31,7 @@ import { dirname, join } from "node:path";
32
31
  import { fileURLToPath } from "node:url";
33
32
  import { load } from "js-yaml";
34
33
  import { frontmatterBlock } from "../lib/markdown.mjs";
34
+ import { createChecker } from "../lib/check.mjs";
35
35
 
36
36
  const HERE = dirname(fileURLToPath(import.meta.url));
37
37
  const ROOT = dirname(HERE);
@@ -46,11 +46,7 @@ const {
46
46
  } = await import(join(HERE, "new-paper.ts"));
47
47
  const { run } = await import(join(HERE, "cli.ts"));
48
48
 
49
- let n = 0;
50
- const check = (label, cond) => {
51
- n++;
52
- assert.ok(cond, label);
53
- };
49
+ const check = createChecker();
54
50
 
55
51
  const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-new-")));
56
52
  const read = (...p) => readFileSync(join(...p), "utf8");
@@ -217,5 +213,5 @@ try {
217
213
  }
218
214
 
219
215
  console.log(
220
- `✓ ${String(n)} assertions passed — paperlint new scaffolds what paperlint lint accepts`,
216
+ `✓ ${String(check.count)} assertions passed — paperlint new scaffolds what paperlint lint accepts`,
221
217
  );
@@ -15,7 +15,7 @@ import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { afterEach, describe, expect, it } from "vitest";
17
17
  import { existsSync } from "node:fs";
18
- import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
18
+ import { newPaper, OVERRIDE_DIR, reportNewPaper } from "./new-paper.ts";
19
19
  import { parsePaperSettings } from "./paper-settings.ts";
20
20
  import { chooseVenue, run } from "./cli.ts";
21
21
  import { shippedPresets } from "./presets.ts";
@@ -302,3 +302,33 @@ describe("chooseVenue — on a terminal", () => {
302
302
  expect(t.asked).toEqual([]);
303
303
  });
304
304
  });
305
+
306
+ describe("a project's own template", () => {
307
+ it("🔴 a template paperlint.json with comments refuses --venue by name, and nothing is made", () => {
308
+ const papers = join(tmp(), "papers");
309
+ mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
310
+ const src = join(papers, OVERRIDE_DIR, "paperlint.json");
311
+ writeFileSync(src, '{\n // our house venue\n "extends": null\n}\n');
312
+ const r = newPaper(papers, "p", "tex", {
313
+ venue: { extends: "paperlint:aisec", kind: null },
314
+ });
315
+ expect(r).toEqual({
316
+ ok: false,
317
+ reason: `--venue cannot be written into ${src}: it is not plain JSON — set "extends" by hand`,
318
+ });
319
+ expect(existsSync(join(papers, "p"))).toBe(false);
320
+ });
321
+
322
+ it("the report names where each created file came from", () => {
323
+ const papers = join(tmp(), "papers");
324
+ mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
325
+ writeFileSync(join(papers, OVERRIDE_DIR, "paperlint.json"), "{}\n");
326
+ const lines = reportNewPaper(newPaper(papers, "p", "tex"), (p) => p);
327
+ expect(lines).toContain(
328
+ " + paperlint.json (from the project's .template/ template)",
329
+ );
330
+ expect(
331
+ lines.filter((l) => l.endsWith("(from the package template)")).length,
332
+ ).toBeGreaterThan(0);
333
+ });
334
+ });
@@ -319,6 +319,18 @@ describe("paper-files — the pieces", () => {
319
319
  });
320
320
  });
321
321
 
322
+ describe("paper-files — blocks that carry no rules", () => {
323
+ it("a plugin with only a language registers no rule; a block with no files owns no pattern of its own", () => {
324
+ const own = [
325
+ { plugins: { tex: { languages: {} } } },
326
+ { ignores: ["**/x.md"], plugins: { tex: { languages: {} } } },
327
+ { files: ["**/a.md"], plugins: { p: { rules: { one: {} } } } },
328
+ ];
329
+ expect([...ruleOwners(own).keys()]).toEqual(["p/one"]);
330
+ expect(ownedPatterns(own)).toEqual(["**/a.md"]);
331
+ });
332
+ });
333
+
322
334
  describe("paper-files — narrowing a block", () => {
323
335
  const own = [
324
336
  { plugins: { g: { rules: { any: {} } } } },
@@ -71,8 +71,9 @@ export interface ScopedBlock {
71
71
  readonly rules: Readonly<Record<string, RuleEntry>>;
72
72
  }
73
73
 
74
- const globsOf = (b: Block): string[] =>
75
- (b.files ?? []).filter((f): f is string => typeof f === "string");
74
+ /** A block's `files`, the string globs only. */
75
+ const globsOf = (files: readonly unknown[]): string[] =>
76
+ files.filter((f): f is string => typeof f === "string");
76
77
 
77
78
  const stringsOf = (xs: readonly unknown[] | undefined): string[] =>
78
79
  (xs ?? []).filter((x): x is string => typeof x === "string");
@@ -102,8 +103,11 @@ export function ownedScopes(own: readonly unknown[]): OwnedScope[] {
102
103
  return merged(
103
104
  own
104
105
  .map((raw) => raw as Block)
105
- .filter((b) => b.files !== undefined)
106
- .map((b) => ({ files: globsOf(b), ignores: stringsOf(b.ignores) })),
106
+ .flatMap((b) =>
107
+ b.files === undefined
108
+ ? []
109
+ : [{ files: globsOf(b.files), ignores: stringsOf(b.ignores) }],
110
+ ),
107
111
  );
108
112
  }
109
113
 
@@ -150,7 +154,7 @@ export function ruleOwners(
150
154
  const scopes =
151
155
  b.files === undefined
152
156
  ? all
153
- : [{ files: globsOf(b), ignores: stringsOf(b.ignores) }];
157
+ : [{ files: globsOf(b.files), ignores: stringsOf(b.ignores) }];
154
158
  for (const id of ruleIdsOf(b))
155
159
  found.set(id, [...(found.get(id) ?? []), ...scopes]);
156
160
  }
@@ -118,6 +118,26 @@ describe("readPaperSettings", () => {
118
118
  });
119
119
  });
120
120
 
121
+ describe("readPaperSettings — a root paperlint.json that cannot be used", () => {
122
+ const ROOT = "/work";
123
+ const withFiles = (f: Record<string, string>) =>
124
+ memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
125
+
126
+ it("a root file that does not parse, or names a field of the wrong type, is the paper's problem too", () => {
127
+ const broken = (root: string) =>
128
+ readPaperSettings(withFiles({ [`${ROOT}/paperlint.json`]: root }), PAPER);
129
+ const notJson = broken("{ nope");
130
+ expect(!notJson.ok && notJson.error.kind).toBe("broken");
131
+ expect(broken('{"kind":3}')).toEqual({
132
+ ok: false,
133
+ error: {
134
+ kind: "broken",
135
+ why: expect.stringMatching(/^the root paperlint\.json: .*kind/),
136
+ },
137
+ });
138
+ });
139
+ });
140
+
121
141
  describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
122
142
  const ROOT = "/work";
123
143
  const withFiles = (f: Record<string, string>) =>
@@ -11,17 +11,14 @@ import { writeFileSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
+ import { createChecker } from "../lib/check.mjs";
14
15
 
15
16
  const HERE = dirname(fileURLToPath(import.meta.url));
16
17
  const FIX = resolve(HERE, "..", "fixtures", "pdf-facts");
17
18
  const { readPdf } = await import(join(HERE, "pdf-facts.ts"));
18
19
  const { classifyLastPage } = await import(join(HERE, "pdf-geometry.ts"));
19
20
 
20
- let n = 0;
21
- const check = (label, cond, detail = "") => {
22
- assert.ok(cond, detail ? `${label} — ${detail}` : label);
23
- n++;
24
- };
21
+ const check = createChecker();
25
22
 
26
23
  const read = async (name) => {
27
24
  const r = await readPdf(join(FIX, name));
@@ -184,5 +181,5 @@ try {
184
181
  }
185
182
 
186
183
  console.log(
187
- `✓ ${String(n)} assertions passed — pdf-facts: pdf.js on real PDFs agrees with poppler, and says what it cannot read`,
184
+ `✓ ${String(check.count)} assertions passed — pdf-facts: pdf.js on real PDFs agrees with poppler, and says what it cannot read`,
188
185
  );
@@ -0,0 +1,144 @@
1
+ /**
2
+ * pdf-facts.ts on document shapes real test PDFs do not carry, through a fake pdf.js document
3
+ * (the real pdf.js library for its transforms and operator codes): a font object that is not one,
4
+ * a font without a name or metrics, an item whose font never resolved, zero-scale transforms, a
5
+ * document with no pages, text with no font, and a failure that is not an Error.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { getResolvedPDFJS } from "unpdf";
9
+ import { test } from "vitest";
10
+ import { factsOf, failureOf, rawFontOf } from "./pdf-facts.ts";
11
+
12
+ const lib = await getResolvedPDFJS();
13
+ const H = 792;
14
+
15
+ interface FakeItem {
16
+ str: string;
17
+ fontName: string;
18
+ transform: number[];
19
+ width: number;
20
+ height: number;
21
+ }
22
+ /** A page whose text items and loaded fonts are given; its viewport flips y like pdf.js's. */
23
+ function page(items: FakeItem[], fonts: Record<string, unknown>) {
24
+ const objs = new Map(Object.entries(fonts));
25
+ return {
26
+ getOperatorList: async () => ({ fnArray: [], argsArray: [] }),
27
+ getTextContent: async () => ({ items }),
28
+ // Iterable like pdf.js's `commonObjs`, with its `has`/`get`.
29
+ commonObjs: {
30
+ has: (id: string) => objs.has(id),
31
+ get: (id: string) => objs.get(id),
32
+ [Symbol.iterator]: () => objs[Symbol.iterator](),
33
+ },
34
+ getViewport: () => ({
35
+ width: 612,
36
+ height: H,
37
+ transform: [1, 0, 0, -1, 0, H],
38
+ convertToViewportPoint: (x: number, y: number) => [x, H - y],
39
+ }),
40
+ };
41
+ }
42
+ const doc = (pages: ReturnType<typeof page>[]) =>
43
+ ({
44
+ numPages: pages.length,
45
+ getPage: async (i: number) => pages[i - 1],
46
+ }) as never;
47
+ const item = (
48
+ fontName: string,
49
+ transform: number[],
50
+ height = 10,
51
+ ): FakeItem => ({
52
+ str: "word",
53
+ fontName,
54
+ transform,
55
+ width: 20,
56
+ height,
57
+ });
58
+
59
+ test("rawFontOf: not an object, not a font, and a font without a name", () => {
60
+ assert.deepEqual(
61
+ [
62
+ rawFontOf("a", null),
63
+ rawFontOf("b", { x: 1 }),
64
+ rawFontOf("c", { loadedName: "L" }),
65
+ ],
66
+ [
67
+ null,
68
+ null,
69
+ {
70
+ id: "c",
71
+ name: "",
72
+ type: undefined,
73
+ subtype: undefined,
74
+ isType3Font: false,
75
+ missingFile: false,
76
+ },
77
+ ],
78
+ );
79
+ });
80
+
81
+ test("a document with no pages is `empty`; text drawn with no font is refused", async () => {
82
+ assert.deepEqual(await factsOf(doc([]), lib), {
83
+ ok: false,
84
+ reason: "empty",
85
+ detail: "the PDF has no pages",
86
+ });
87
+ const r = await factsOf(
88
+ doc([page([item("f0", [10, 0, 0, 10, 50, 700])], {})]),
89
+ lib,
90
+ );
91
+ assert.equal(r.ok, false);
92
+ assert.equal(!r.ok && r.reason, "zero-fonts-on-text");
93
+ });
94
+
95
+ test("metrics, sizes and names fall back when pdf.js gives none", async () => {
96
+ const fonts = {
97
+ // a font with a name and an ascent but no descent
98
+ f1: { loadedName: "f1", name: "Named", ascent: 0.8 },
99
+ // a font with no name and no metrics at all
100
+ f2: { loadedName: "f2" },
101
+ };
102
+ const r = await factsOf(
103
+ doc([
104
+ page(
105
+ [
106
+ item("f1", [10, 0, 0, 10, 50, 700]),
107
+ // zero horizontal scale: the size comes from the item's height…
108
+ item("f2", [0, 0, 0, 0, 100, 700], 12),
109
+ // …or, with no height either, from the vertical scale
110
+ item("f2", [0, 0, 0, 9, 150, 700], 0),
111
+ // an item whose font pdf.js never resolved
112
+ item("gone", [10, 0, 0, 10, 200, 700]),
113
+ ],
114
+ fonts,
115
+ ),
116
+ ]),
117
+ lib,
118
+ );
119
+ assert.equal(r.ok, true);
120
+ const boxes = r.ok ? r.facts.layout[0]!.boxes : [];
121
+ assert.deepEqual(
122
+ boxes.map((b) => [b.font, b.size, b.fill]),
123
+ [
124
+ ["Named", 10, { kind: "unknown" }],
125
+ ["f2", 12, { kind: "unknown" }],
126
+ ["f2", 9, { kind: "unknown" }],
127
+ ["gone", 10, { kind: "unknown" }],
128
+ ],
129
+ );
130
+ assert.deepEqual(r.ok ? r.facts.last.words.map((w) => w.text) : [], [
131
+ "word",
132
+ "word",
133
+ "word",
134
+ "word",
135
+ ]);
136
+ });
137
+
138
+ test("a failure that is not an Error is named `Error` with its text", () => {
139
+ assert.deepEqual(failureOf("boom"), {
140
+ ok: false,
141
+ reason: "unreadable",
142
+ detail: "Error: boom",
143
+ });
144
+ });
package/src/pdf-facts.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  } from "./pdf-geometry.ts";
34
34
  import type { PageLayout, TextBox } from "./domain/page-layout.ts";
35
35
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
36
- import { fillsOf, isUpright } from "./adapters/pdfjs/fill.ts";
36
+ import { fillsFor, isUpright } from "./adapters/pdfjs/fill.ts";
37
37
 
38
38
  type PdfJs = Awaited<ReturnType<typeof import("unpdf").getResolvedPDFJS>>;
39
39
  type Doc = Awaited<ReturnType<PdfJs["getDocument"]>["promise"]>;
@@ -241,20 +241,18 @@ function layoutOf(
241
241
  lib: PdfJs,
242
242
  ): PageLayout {
243
243
  const vp = page.getViewport({ scale: 1 });
244
- const fills = fillsOf(
245
- lib.OPS,
246
- ops,
247
- items.map((it) => it.str),
244
+ const boxes = fillsFor(lib.OPS, ops, items, (it) => it.str).map(
245
+ ({ item, fill }): TextBox => ({ ...boxOf(page, vp, item, lib), fill }),
248
246
  );
249
- const boxes = items.map((it, k): TextBox => ({
250
- ...boxOf(page, vp, it, lib),
251
- fill: fills[k] ?? { kind: "unknown" },
252
- }));
253
247
  return { widthPt: vp.width, heightPt: vp.height, boxes };
254
248
  }
255
249
 
256
- /** Every page's fonts, and the last page's words. */
257
- async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
250
+ /**
251
+ * Every page's fonts, and the last page's words. Exported with `failureOf` so a test can hand it a
252
+ * document whose objects pdf.js shapes only in rare files (a font without metrics, a zero-scale
253
+ * transform, no pages) — `readPdf` below is the one production caller.
254
+ */
255
+ export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
258
256
  const read: {
259
257
  page: Page;
260
258
  raw: RawPage;
@@ -290,7 +288,7 @@ async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
290
288
  }
291
289
 
292
290
  /** pdf.js's own exception names for the two conditions a caller can act on. */
293
- function failureOf(e: unknown): PdfRead {
291
+ export function failureOf(e: unknown): PdfRead {
294
292
  const err = e as { name?: string; message?: string };
295
293
  const detail = `${err.name ?? "Error"}: ${err.message ?? String(e)}`;
296
294
  return fail(
@@ -309,13 +307,13 @@ export const readPdf: PdfReader = async (path) => {
309
307
  }
310
308
  const lib = await loadPdfJs();
311
309
  const task = lib.getDocument({ data, ...PDFJS_OPTIONS });
312
- try {
313
- return await factsOf(await task.promise, lib);
314
- } catch (e) {
315
- return failureOf(e);
316
- } finally {
317
- await task.destroy();
318
- }
310
+ // A promise chain rather than try/catch/finally: every path returns a value here, and the code
311
+ // after such a statement is a block that can never run — which coverage reports as unrun.
312
+ const read = await task.promise
313
+ .then((doc) => factsOf(doc, lib))
314
+ .catch(failureOf);
315
+ await task.destroy();
316
+ return read;
319
317
  };
320
318
 
321
319
  /** The one wording of a failed read, as a build step or a CLI prints it. */