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
@@ -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
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * THE LINTED SET IS EXACTLY THE OWNED FILES — checked on the whole result, not one route in.
3
+ *
4
+ * Each test in `paper-files.test.ts` closes one way a stray file got into `paperlint lint`: vendored
5
+ * JavaScript under a paper (#99), a rule source that reached the siblings index (#101). This one
6
+ * does not care about the route. A paper with every kind of neighbour a real one has, settings from
7
+ * every level that generates blocks (a venue preset with `rules`, the root `rules`), and one
8
+ * assertion: the files ESLint returned are exactly the four paperlint owns — set equality, so a
9
+ * route nobody has thought of yet shows up as an extra member.
10
+ */
11
+ import {
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ realpathSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join, relative } from "node:path";
20
+ import { afterEach, describe, expect, it } from "vitest";
21
+ import { run } from "./cli.ts";
22
+
23
+ const dirs: string[] = [];
24
+ afterEach(() => {
25
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
26
+ });
27
+
28
+ const TEX = "\\documentclass{article}\n\\begin{document}x\\end{document}\n";
29
+ const JS = "export const x = 1;\n";
30
+
31
+ /** The files paperlint owns in the fixture paper. */
32
+ const OWNED = [
33
+ "papers/a/paper.tex",
34
+ "papers/a/PIPELINE-STATUS.md",
35
+ "papers/a/reviews/r1.md",
36
+ "papers/a/siblings/smith2025.md",
37
+ ];
38
+
39
+ /** Everything else a real paper folder and project carry. None of it may be linted. */
40
+ const NEIGHBOURS = {
41
+ "papers/a/siblings/README.md": "# Siblings\n\n- [Smith](smith2025.md)\n",
42
+ "papers/a/README.md": "# Paper a\n",
43
+ "papers/a/notes/idea.md": "# An idea\n",
44
+ "papers/a/repro/x.js": "return 1;\n",
45
+ "papers/a/repro/lib/y.mjs": JS,
46
+ "papers/a/repro/z.cjs": "module.exports = 1;\n",
47
+ "papers/a/repro/t.ts": "const t: number = 1;\n",
48
+ "script.js": JS,
49
+ "node_modules/pkg/index.js": JS,
50
+ };
51
+
52
+ function project(): string {
53
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-set-")));
54
+ dirs.push(root);
55
+ const all: Record<string, string> = {
56
+ "package.json": JSON.stringify({ name: "c", private: true }),
57
+ // A root `rules` block over every file of the project: the widest glob a consumer can write.
58
+ "paperlint.json": JSON.stringify({
59
+ rules: [
60
+ {
61
+ files: ["**/*.md", "**/*.tex", "**/*.js"],
62
+ rules: {
63
+ "pdf/last-page-balance": "warn",
64
+ "paper/section-word": "warn",
65
+ },
66
+ },
67
+ ],
68
+ }),
69
+ // A venue preset that carries `rules` of its own.
70
+ "papers/a/paperlint.json": JSON.stringify({
71
+ extends: "paperlint:agenticdev",
72
+ }),
73
+ "papers/a/paper.tex": TEX,
74
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
75
+ "papers/a/reviews/r1.md": "# review\n",
76
+ "papers/a/siblings/smith2025.md": "---\nread: full\n---\n# Smith 2025\n",
77
+ ...NEIGHBOURS,
78
+ };
79
+ for (const [p, text] of Object.entries(all)) {
80
+ mkdirSync(join(root, p, ".."), { recursive: true });
81
+ writeFileSync(join(root, p), text);
82
+ }
83
+ return root;
84
+ }
85
+
86
+ /** `paperlint lint --json <args>` → the files it linted, relative to the project, sorted. */
87
+ async function lintedSet(root: string, args: string[]): Promise<string[]> {
88
+ const out: string[] = [];
89
+ await run(["lint", "--json", ...args], {
90
+ cwd: root,
91
+ log: (s: string) => out.push(s),
92
+ err: () => {},
93
+ });
94
+ type Result = { filePath: string; messages: { ruleId: string | null }[] };
95
+ return (
96
+ (JSON.parse(out.join("\n")) as Result[])
97
+ // A structure finding reports a MISSING file in the same shape; it was not linted.
98
+ .filter(
99
+ (r) => !r.messages.some((m) => m.ruleId === "structure/required-file"),
100
+ )
101
+ .map((r) => relative(root, r.filePath))
102
+ .sort()
103
+ );
104
+ }
105
+
106
+ describe("🔴 paperlint lint — the linted set is EXACTLY the owned files", () => {
107
+ it.each([
108
+ ["the papers directory from the config", []],
109
+ ["the paper named on the command line", ["papers/a"]],
110
+ // The widest scope: ESLint is handed the project root, so it enumerates `script.js` and
111
+ // `node_modules/` too, and only the scope keeps them out.
112
+ ["the whole project named on the command line", ["."]],
113
+ ])("%s", async (_, args) => {
114
+ // Guards: every route in at once — vendored JS/TS, a root script, node_modules, a README next
115
+ // to the paper, the siblings index, notes. An extra member names the file that got through.
116
+ expect(await lintedSet(project(), args)).toEqual([...OWNED].sort());
117
+ });
118
+ });
@@ -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");
@@ -191,6 +187,8 @@ try {
191
187
  "---\n---\n# House scorecard for {{name}}\n",
192
188
  );
193
189
  const own = newPaper(papers, "house", "md");
190
+ // Guards: the override slot — `fromTemplate` in new-paper.ts reads `<papers>/.template/<file>`
191
+ // before the package's copy. Replace that lookup with the package's alone and this goes red.
194
192
  check(
195
193
  "🔴 <papers>/.template/ is preferred for the file it holds, the package fills the rest",
196
194
  own.ok &&
@@ -215,5 +213,5 @@ try {
215
213
  }
216
214
 
217
215
  console.log(
218
- `✓ ${String(n)} assertions passed — paperlint new scaffolds what paperlint lint accepts`,
216
+ `✓ ${String(check.count)} assertions passed — paperlint new scaffolds what paperlint lint accepts`,
219
217
  );
@@ -14,9 +14,12 @@ import {
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { afterEach, describe, expect, it } from "vitest";
17
- import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
17
+ import { existsSync } from "node:fs";
18
+ import { newPaper, OVERRIDE_DIR, reportNewPaper } from "./new-paper.ts";
18
19
  import { parsePaperSettings } from "./paper-settings.ts";
19
- import { run } from "./cli.ts";
20
+ import { chooseVenue, run } from "./cli.ts";
21
+ import { shippedPresets } from "./presets.ts";
22
+ import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
20
23
 
21
24
  const dirs: string[] = [];
22
25
  afterEach(() => {
@@ -131,3 +134,201 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
131
134
  );
132
135
  });
133
136
  });
137
+
138
+ /** `paperlint new <args>` in a fresh project (not a terminal: vitest's stdin is not a TTY). */
139
+ async function newIn(args: string[], files: Record<string, string> = {}) {
140
+ const root = tmp();
141
+ const all = {
142
+ "package.json": JSON.stringify({ name: "c", private: true }),
143
+ ...files,
144
+ };
145
+ for (const [p, text] of Object.entries(all)) {
146
+ mkdirSync(join(root, p, ".."), { recursive: true });
147
+ writeFileSync(join(root, p), text);
148
+ }
149
+ const out: string[] = [];
150
+ const err: string[] = [];
151
+ const code = await run(["new", "demo", ...args], {
152
+ cwd: root,
153
+ log: (s: string) => out.push(s),
154
+ err: (s: string) => err.push(s),
155
+ });
156
+ const dir = join(root, "papers", "demo");
157
+ return {
158
+ code,
159
+ out: out.join("\n"),
160
+ err: err.join("\n"),
161
+ dir,
162
+ settings: () => settingsOf(dir),
163
+ };
164
+ }
165
+
166
+ describe("paperlint new --venue / --kind", () => {
167
+ const PRESET = `{
168
+ "extends": "paperlint:acm-sigconf",
169
+ "format": { "kinds": { "short": { "body_pages_max": 4 } } },
170
+ }
171
+ `;
172
+
173
+ it("a shipped venue and its kind → extends and kind in paperlint.json, lint resolves it", async () => {
174
+ const r = await newIn(["--venue", "agenticdev", "--kind", "short"]);
175
+ expect(r.settings()).toEqual({
176
+ extends: "paperlint:agenticdev",
177
+ kind: "short",
178
+ });
179
+ expect(r.out).not.toMatch(/pdf\/profile/);
180
+ expect(r.code).toBe(0);
181
+ });
182
+
183
+ it("🔴 a path is relative to where you run it, and written relative to the paper's file", async () => {
184
+ const r = await newIn(
185
+ ["--venue", "./venues/my-workshop.jsonc", "--kind", "short"],
186
+ {
187
+ "venues/my-workshop.jsonc": PRESET,
188
+ },
189
+ );
190
+ // Guards: written as typed, `./venues/…` would name papers/demo/venues/…, which does not exist.
191
+ expect(r.settings()).toEqual({
192
+ extends: "../../venues/my-workshop.jsonc",
193
+ kind: "short",
194
+ });
195
+ expect(r.out).not.toMatch(/pdf\/profile/);
196
+ expect(r.code).toBe(0);
197
+ });
198
+
199
+ it("an unknown venue exits 2, lists the shipped presets, and creates nothing", async () => {
200
+ const r = await newIn(["--venue", "icse"]);
201
+ expect(r.code).toBe(2);
202
+ // Guards: the list is read from the shipped presets, the same list lint prints.
203
+ expect(r.err).toContain(shippedPresets(packageVenuesDir()).join(", "));
204
+ expect(r.err).toMatch(/--venue icse: no such venue preset/);
205
+ expect(existsSync(r.dir)).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe("paperlint new --venue / --kind — refusals and defaults", () => {
210
+ it.each([
211
+ [
212
+ "a kind the preset lacks",
213
+ ["--venue", "agenticdev", "--kind", "long"],
214
+ /`agenticdev` has no kind `long`; its kinds: short, full, demo/,
215
+ ],
216
+ ["a kind without a venue", ["--kind", "short"], /--kind needs --venue/],
217
+ [
218
+ "a kind for a preset with no kinds",
219
+ ["--venue", "acm-sigconf", "--kind", "short"],
220
+ /`acm-sigconf` has no kinds/,
221
+ ],
222
+ [
223
+ "a path that does not exist",
224
+ ["--venue", "./venues/nope.jsonc"],
225
+ /no such file .* relative to where you run the command/,
226
+ ],
227
+ ])("%s → exit 2, nothing created", async (_, args, message) => {
228
+ const r = await newIn(args);
229
+ expect(r.err).toMatch(message);
230
+ expect(r.code).toBe(2);
231
+ expect(existsSync(r.dir)).toBe(false);
232
+ });
233
+
234
+ it("a venue with kinds and no --kind: written, and new says what lint will report", async () => {
235
+ const r = await newIn(["--venue", "agenticdev"]);
236
+ expect(r.settings()).toEqual({ extends: "paperlint:agenticdev" });
237
+ expect(r.out).toMatch(
238
+ /kind: not set — `agenticdev` sets a page limit per kind/,
239
+ );
240
+ expect(r.out).toMatch(/pdf\/profile/);
241
+ });
242
+
243
+ it("without a terminal and no --venue: as before, and the hint names --venue", async () => {
244
+ const r = await newIn([]);
245
+ expect(r.settings()["extends"]).toBeNull();
246
+ expect(r.out).toMatch(/venue: none yet .*--venue <preset>/);
247
+ expect(r.code).toBe(0);
248
+ });
249
+
250
+ it("an existing paperlint.json is never overwritten: --venue is refused", async () => {
251
+ const r = await newIn(["--venue", "aisec"], {
252
+ "papers/demo/paperlint.json": '{ "extends": null }\n',
253
+ });
254
+ expect(r.code).toBe(2);
255
+ expect(r.err).toMatch(/already exists and is never overwritten/);
256
+ expect(r.settings()).toEqual({ extends: null });
257
+ });
258
+ });
259
+
260
+ describe("chooseVenue — on a terminal", () => {
261
+ const at = (answers: string[]) => {
262
+ const asked: string[] = [];
263
+ return {
264
+ asked,
265
+ opts: {
266
+ paperDir: "/p/papers/demo",
267
+ cwd: "/p",
268
+ interactive: true,
269
+ ask: async (q: string) => (asked.push(q), answers.shift() ?? ""),
270
+ },
271
+ };
272
+ };
273
+
274
+ it("asks the venue from the shipped list, then its kind", async () => {
275
+ const t = at(["agenticdev", "full"]);
276
+ const r = await chooseVenue({ venue: null, kind: null }, t.opts);
277
+ expect(r).toMatchObject({
278
+ ok: true,
279
+ value: { extends: "paperlint:agenticdev", kind: "full" },
280
+ });
281
+ expect(t.asked[0]).toContain(
282
+ [...shippedPresets(packageVenuesDir()), "none"].join(" / "),
283
+ );
284
+ expect(t.asked[1]).toMatch(/kind: short \/ full \/ demo \/ later/);
285
+ });
286
+
287
+ it("the default answer is none: no venue written", async () => {
288
+ const t = at([""]);
289
+ expect(await chooseVenue({ venue: null, kind: null }, t.opts)).toEqual({
290
+ ok: true,
291
+ value: null,
292
+ });
293
+ });
294
+
295
+ it("without a terminal nothing is asked", async () => {
296
+ const t = at(["agenticdev"]);
297
+ const r = await chooseVenue(
298
+ { venue: null, kind: null },
299
+ { ...t.opts, interactive: false },
300
+ );
301
+ expect(r).toEqual({ ok: true, value: null });
302
+ expect(t.asked).toEqual([]);
303
+ });
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
+ });