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,246 @@
1
+ /**
2
+ * markdown.mjs — the markup readers every script shares, on the shapes their callers rely on:
3
+ * frontmatter blanked (offsets kept) or cut, headings from the parser (not from a `#` inside a
4
+ * fence), sections by level, fences, paragraphs and tables. And the one thing a test run never
5
+ * sees by accident: markdown-it failing to resolve, where every reader must degrade to its
6
+ * documented empty value and `requireMarkdown` must refuse with exit 2.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { copyFileSync, mkdirSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { test } from "vitest";
13
+ import { runNode, useTempDir, writeTree } from "../test/support.mjs";
14
+ import {
15
+ blankFrontmatter,
16
+ fences,
17
+ frontmatterBlock,
18
+ headings,
19
+ requireMarkdown,
20
+ paragraphs,
21
+ splitSections,
22
+ stripFences,
23
+ stripFrontmatter,
24
+ tables,
25
+ } from "./markdown.mjs";
26
+
27
+ const DOC = [
28
+ "---", // 0
29
+ "title: T", // 1
30
+ " ## not a heading, inside the frontmatter", // 2
31
+ "---", // 3
32
+ "# Title", // 4
33
+ "", // 5
34
+ "Intro para.", // 6
35
+ "", // 7
36
+ "## One", // 8
37
+ "Body one.", // 9
38
+ "", // 10
39
+ "---", // 11
40
+ "", // 12
41
+ "after the rule", // 13
42
+ "", // 14
43
+ "```", // 15
44
+ "## a heading in a fence", // 16
45
+ "```", // 17
46
+ "", // 18
47
+ " indented code", // 19
48
+ "", // 20
49
+ "### One.a", // 21
50
+ "| a | b |", // 22
51
+ "|---|:-:|", // 23
52
+ "| 1 | 2 |", // 24
53
+ "", // 25
54
+ "## Two", // 26
55
+ "last", // 27
56
+ ].join("\n");
57
+
58
+ test("frontmatter: blanked with offsets kept, its body, cut; untouched when there is none", () => {
59
+ assert.deepEqual(
60
+ {
61
+ blank: blankFrontmatter("---\na: 1\n---\nx").split("\n"),
62
+ body: frontmatterBlock(DOC),
63
+ none: [
64
+ frontmatterBlock("x"),
65
+ blankFrontmatter("x"),
66
+ stripFrontmatter("x"),
67
+ ],
68
+ cut: stripFrontmatter("---\na: 1\n---\n\nx"),
69
+ },
70
+ {
71
+ blank: [" ", " ", " ", "x"],
72
+ body: "title: T\n ## not a heading, inside the frontmatter",
73
+ none: [null, "x", "x"],
74
+ cut: "\nx",
75
+ },
76
+ );
77
+ });
78
+
79
+ test("headings come from the parser: not from the frontmatter, not from a fence, not setext", () => {
80
+ const offset = (line) => DOC.split("\n").slice(0, line).join("\n").length + 1;
81
+ assert.deepEqual(headings(DOC + "\n\nSetext\n======\n"), [
82
+ { depth: 1, text: "Title", line: 4, offset: offset(4) },
83
+ { depth: 2, text: "One", line: 8, offset: offset(8) },
84
+ { depth: 3, text: "One.a", line: 21, offset: offset(21) },
85
+ { depth: 2, text: "Two", line: 26, offset: offset(26) },
86
+ ]);
87
+ });
88
+
89
+ test("an empty ATX heading has empty text", () => {
90
+ assert.deepEqual(
91
+ headings("#\n").map((h) => [h.depth, h.text]),
92
+ [[1, ""]],
93
+ );
94
+ });
95
+
96
+ test("splitSections: a preamble always first, then each heading of the level range", () => {
97
+ assert.deepEqual(
98
+ splitSections(DOC, { min: 2, max: 2 }).map((s) => [
99
+ s.heading?.text ?? null,
100
+ s.body.split("\n")[0],
101
+ ]),
102
+ [
103
+ [null, "---"],
104
+ ["One", "Body one."],
105
+ ["Two", "last"],
106
+ ],
107
+ );
108
+ assert.deepEqual(
109
+ splitSections("## A").map((s) => [s.heading?.text ?? null, s.body]),
110
+ [
111
+ [null, ""],
112
+ ["A", ""],
113
+ ],
114
+ );
115
+ });
116
+
117
+ test("fences and indented code, as content; stripped as lines, or blanked in place", () => {
118
+ const src = "a\n```\nx\n```\n\n y\nb";
119
+ assert.deepEqual(
120
+ {
121
+ fences: fences(src),
122
+ stripped: stripFences(src),
123
+ blanked: stripFences(src, { blank: true }),
124
+ },
125
+ { fences: ["x\n", "y\n"], stripped: "a\n\nb", blanked: "a\n\n\n\n\n\nb" },
126
+ );
127
+ });
128
+
129
+ test("paragraphs are line ranges; tables are rows of cells, the alignment row skipped", () => {
130
+ assert.deepEqual(
131
+ { paragraphs: paragraphs(DOC), tables: tables(DOC) },
132
+ {
133
+ paragraphs: [
134
+ { line: 6, endLine: 7 },
135
+ { line: 9, endLine: 10 },
136
+ { line: 13, endLine: 14 },
137
+ { line: 27, endLine: 28 },
138
+ ],
139
+ tables: [
140
+ {
141
+ line: 22,
142
+ endLine: 25,
143
+ rows: [
144
+ ["a", "b"],
145
+ ["1", "2"],
146
+ ],
147
+ },
148
+ ],
149
+ },
150
+ );
151
+ });
152
+
153
+ const root = useTempDir("markdown-no-parser-");
154
+ writeTree(root, {
155
+ // Registered with --import: resolving `markdown-it` fails, as it does with no node_modules.
156
+ "no-markdown-it.mjs":
157
+ "import { register } from 'node:module';\n" +
158
+ "register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === 'markdown-it') throw Object.assign(new Error('gone'), { code: 'ERR_MODULE_NOT_FOUND' }); return next(s, c); }\"));\n",
159
+ "probe.mjs":
160
+ `const m = await import(${JSON.stringify(join(dirname(fileURLToPath(import.meta.url)), "markdown.mjs"))});\n` +
161
+ "const t = '# H\\n\\n```\\nx\\n```\\n';\n" +
162
+ "console.log(JSON.stringify([m.MD_AVAILABLE(), m.headings(t), m.fences(t), m.stripFences(t), m.paragraphs(t), m.tables(t)]));\n" +
163
+ "m.requireMarkdown();\n" +
164
+ "console.log('not reached');\n",
165
+ "probe-plugin.mjs":
166
+ `const m = await import(${JSON.stringify(join(dirname(fileURLToPath(import.meta.url)), "markdown.mjs"))});\n` +
167
+ 'm.requireMarkdown({ from: "file:///home/u/.claude/plugins/cache/paperlint/lib/markdown.mjs" });\n',
168
+ });
169
+
170
+ test("this file, told it runs from a plugin cache, sends its user to the npm package", () => {
171
+ const r = runNode(join(root, "probe-plugin.mjs"), [], {
172
+ nodeArgs: ["--import", join(root, "no-markdown-it.mjs")],
173
+ });
174
+ assert.deepEqual(
175
+ [r.status, r.stderr.split("\n").slice(1, 2)],
176
+ [
177
+ 2,
178
+ [
179
+ " Running from a PLUGIN copy, which is installed without dependencies. Install the npm",
180
+ ],
181
+ ],
182
+ );
183
+ });
184
+
185
+ test("with no markdown-it, every reader degrades to its empty value and requireMarkdown exits 2", () => {
186
+ const r = runNode(join(root, "probe.mjs"), [], {
187
+ nodeArgs: ["--import", join(root, "no-markdown-it.mjs")],
188
+ });
189
+ assert.deepEqual(r, {
190
+ status: 2,
191
+ stdout:
192
+ JSON.stringify([false, [], [], "# H\n\n```\nx\n```\n", [], []]) + "\n",
193
+ stderr:
194
+ "markdown-it does not resolve — this script parses markup with a parser and without it would produce a confident zero instead of an error.\n" +
195
+ " Cured by `npm i` at the repo root.\n",
196
+ });
197
+ });
198
+
199
+ test("splitSections of a document with no heading is the preamble alone; requireMarkdown is silent with a parser", () => {
200
+ assert.deepEqual(
201
+ [
202
+ splitSections("just text").map((s) => [s.heading, s.body]),
203
+ requireMarkdown(),
204
+ ],
205
+ [[[null, "just text"]], undefined],
206
+ );
207
+ });
208
+
209
+ test("headings read a token stream that breaks markdown-it's shape as empty text and offset 0", () => {
210
+ // A heading not followed by its inline token, mapped past the end of the text.
211
+ const parse = () => [
212
+ { type: "heading_open", map: [9, 10], markup: "##", tag: "h2" },
213
+ { type: "paragraph_open" },
214
+ ];
215
+ assert.deepEqual(headings("x", { parse }), [
216
+ { depth: 2, text: "", line: 9, offset: 0 },
217
+ ]);
218
+ });
219
+
220
+ test("a copy running from a plugin cache without markdown-it is told to install the npm package", () => {
221
+ // A plugin copy is installed without dependencies: here, a copy under `.claude/plugins/` with
222
+ // no node_modules above it, so `markdown-it` does not resolve for real.
223
+ const lib = join(root, ".claude", "plugins", "paperlint", "lib");
224
+ mkdirSync(lib, { recursive: true });
225
+ copyFileSync(
226
+ join(dirname(fileURLToPath(import.meta.url)), "markdown.mjs"),
227
+ join(lib, "markdown.mjs"),
228
+ );
229
+ writeTree(lib, {
230
+ "probe.mjs":
231
+ 'const m = await import("./markdown.mjs");\nm.requireMarkdown();\n',
232
+ });
233
+ const r = runNode(join(lib, "probe.mjs"), []);
234
+ assert.deepEqual(
235
+ [r.status, r.stderr.split("\n").slice(1)],
236
+ [
237
+ 2,
238
+ [
239
+ " Running from a PLUGIN copy, which is installed without dependencies. Install the npm",
240
+ " package in the project as well (`npm i paperlint`) and call it through",
241
+ " `npx paperlint`, which resolves from the project's own tree.",
242
+ "",
243
+ ],
244
+ ],
245
+ );
246
+ });
@@ -23,21 +23,16 @@
23
23
  * while the value itself is still taken by import.
24
24
  *
25
25
  * Run: npx vigiles test lib/paper-config.harness.mjs
26
- * Killed by: lib/paper-config.mutations.mjs
27
26
  */
28
- import assert from "node:assert/strict";
29
27
  import { readFileSync, readdirSync } from "node:fs";
30
28
  import { dirname, join, relative, resolve } from "node:path";
31
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
+ import { createChecker } from "./check.mjs";
32
31
 
33
32
  const HERE = dirname(fileURLToPath(import.meta.url));
34
33
  const ROOT = resolve(HERE, "..");
35
34
 
36
- let n = 0;
37
- const check = (label, cond) => {
38
- n++;
39
- assert.ok(cond, label);
40
- };
35
+ const check = createChecker();
41
36
 
42
37
  /**
43
38
  * Every place that publishes at least one of the two constants. The three hooks duplicate them
@@ -181,7 +176,7 @@ const loaded = await Promise.all(
181
176
  if (e.isDirectory()) walk(p);
182
177
  else if (
183
178
  /\.(mjs|ts)$/.test(e.name) &&
184
- !/\.(harness|mutations)\.(mjs|ts)$/.test(e.name)
179
+ !/\.harness\.(mjs|ts)$/.test(e.name)
185
180
  ) {
186
181
  if (declares.test(readFileSync(p, "utf8")))
187
182
  found.push(relative(ROOT, p));
@@ -264,6 +259,30 @@ const loaded = await Promise.all(
264
259
  );
265
260
  }
266
261
 
262
+ // ── the two readers' fallbacks: no marker anywhere up the tree, and JSON that is not an object ──
263
+ {
264
+ const { findProjectRoot, settingsOf } = await import(
265
+ join(HERE, "paper-config.mjs")
266
+ );
267
+ check(
268
+ "findProjectRoot: no paperlint.json and no package.json up to / — the start directory itself",
269
+ findProjectRoot("/nowhere/deep", () => false) === "/nowhere/deep",
270
+ );
271
+ const { mkdtempSync, realpathSync, rmSync, writeFileSync } =
272
+ await import("node:fs");
273
+ const { tmpdir } = await import("node:os");
274
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "paper-config-")));
275
+ try {
276
+ writeFileSync(join(dir, "paperlint.json"), "[]\n");
277
+ check(
278
+ "settingsOf: a paperlint.json that is JSON but not an object is no settings",
279
+ settingsOf(dir) === undefined,
280
+ );
281
+ } finally {
282
+ rmSync(dir, { recursive: true, force: true });
283
+ }
284
+ }
285
+
267
286
  console.log(
268
- `✓ ${n} assertions passed — one source for the consumer's config key`,
287
+ `✓ ${check.count} assertions passed — one source for the consumer's config key`,
269
288
  );
@@ -64,11 +64,16 @@ export const PAPER_MARKERS = Object.freeze([
64
64
  export function findProjectRoot(startDir, isFile = existsSync) {
65
65
  const start = resolve(startDir);
66
66
  const isPaper = (d) => PAPER_MARKERS.some((m) => isFile(join(d, m)));
67
+ // Up from `start` to the first directory `found` accepts; null past the filesystem root. A
68
+ // loop with an exit condition rather than `for (;;)`: the code after an endless loop is a block
69
+ // that can never run, and coverage counts it as one that did not.
67
70
  const walk = (found) => {
68
- for (let d = start; ; d = dirname(d)) {
69
- if (found(d)) return d;
71
+ let d = start;
72
+ while (!found(d)) {
70
73
  if (dirname(d) === d) return null;
74
+ d = dirname(d);
71
75
  }
76
+ return d;
72
77
  };
73
78
  return (
74
79
  walk((d) => isFile(join(d, CONFIG_FILE)) && !isPaper(d)) ??
@@ -137,8 +137,8 @@ export async function checkSkill(name) {
137
137
  // ═══════════════════════════════════════════════════════════════════════════════════════════════
138
138
  // 1. GONE, AND THAT IS THE RESULT (2026-08-19). This slot held a strict-YAML assertion whose whole
139
139
  // job was to police the gap between how the corpus was READ here (a hand-rolled lenient reader)
140
- // and how every other consumer reads it (a real YAML parser). The corpus owner, on being shown the mutation
141
- // suite: "sounds like a hack… let's think about how to do it properly". The right fix was not a better
140
+ // and how every other consumer reads it (a real YAML parser). The corpus owner, on being shown the gap:
141
+ // "sounds like a hack… let's think about how to do it properly". The right fix was not a better
142
142
  // check — it was to stop being lenient: the two remaining unparseable descriptions were quoted and
143
143
  // `parseFm` in skill-corpus.mjs now throws on anything a real parser rejects.
144
144
  //
@@ -213,7 +213,12 @@ export async function checkSkill(name) {
213
213
  ["announce", PIPELINE.announce, "Run me"],
214
214
  ["record", PIPELINE.ledger, "Record the verdict"],
215
215
  ]) {
216
- const hits = cmds.filter((c) => c.script === script);
216
+ // For the ledger, only a `record` call is the wired call: `ledger.mjs status <dir>` is a
217
+ // read, files under nothing, and until 2026-09-27 was misreported as "files under status".
218
+ const hits = cmds.filter(
219
+ (c) =>
220
+ c.script === script && (label !== "record" || c.args[0] === "record"),
221
+ );
217
222
  soft(() =>
218
223
  assert.ok(
219
224
  hits.length > 0,
@@ -231,12 +236,7 @@ export async function checkSkill(name) {
231
236
  ),
232
237
  );
233
238
  // arg[0] for announce is the skill; for ledger.mjs it is the literal `record`, then the skill.
234
- const passed =
235
- label === "announce"
236
- ? h.args[0]
237
- : h.args[0] === "record"
238
- ? h.args[1]
239
- : h.args[0];
239
+ const passed = label === "announce" ? h.args[0] : h.args[1];
240
240
  soft(() =>
241
241
  assert.equal(
242
242
  passed,
@@ -551,10 +551,7 @@ function danglingLinks(src, dir) {
551
551
  * Check the prose skill `name`.
552
552
  *
553
553
  * @param {string} name — the directory name in `.claude/skills/`
554
- * @param {{mustSay?: [RegExp,string][], mustNotSay?: [RegExp,string][],
555
- * toolsNeeded?: Record<string,string>}} opts
556
- * `mustSay` — that whose DISAPPEARANCE is a regression (a rule obtained by measurement).
557
- * `mustNotSay` — that whose APPEARANCE is a regression (a refuted claim).
554
+ * @param {{toolsNeeded?: Record<string,string>}} opts
558
555
  * `toolsNeeded` — {tool: REASON}. Lifts the ban on Bash/Agent/Task for a skill that
559
556
  * needs the tool IN SUBSTANCE. The reason is mandatory and non-empty.
560
557
  *
@@ -583,6 +580,14 @@ export async function checkProseSkill(name, opts = {}) {
583
580
  }
584
581
  };
585
582
 
583
+ // Any other option is refused by name: an option this function no longer reads would be a
584
+ // check the caller believes runs and that does nothing.
585
+ const unknown = Object.keys(opts).filter((k) => k !== "toolsNeeded");
586
+ assert.deepEqual(
587
+ unknown,
588
+ [],
589
+ `${name}: unknown option(s) ${unknown.join(", ")} — checkProseSkill takes only toolsNeeded`,
590
+ );
586
591
  assert.ok(existsSync(md), `${name}: no ${md} — nothing to check`);
587
592
  const src = readFileSync(md, "utf8");
588
593
 
@@ -650,12 +655,6 @@ export async function checkProseSkill(name, opts = {}) {
650
655
  `is a dead instruction: the model reads it, does something adjacent and reports success.`,
651
656
  ),
652
657
  );
653
- // mutation: a planted broken link must be caught
654
- assert.equal(
655
- danglingLinks(src + "\n[x](./net-takogo-fayla.md)\n", dir).length,
656
- dangling.length + 1,
657
- `${name}: the link check did not fire on a planted broken link — it is dead`,
658
- );
659
658
 
660
659
  // 3. Whether it is compiled. Editing the markdown by hand desynchronises the sha and gets
661
660
  // quietly rolled back by the next `vigiles compile`.
@@ -667,33 +666,6 @@ export async function checkProseSkill(name, opts = {}) {
667
666
  ),
668
667
  );
669
668
 
670
- // 4-5. Substantive anchors, each with BOTH halves.
671
- for (const [re, why] of opts.mustSay || []) {
672
- soft(() => assert.ok(re.test(src), `${name}: "${why}" is gone (${re})`));
673
- // Mutation: without the matched fragment the predicate must fail. The replacement is GLOBAL —
674
- // `replace` with a non-global regex removes only the first occurrence, and an anchor occurring
675
- // twice (for example both in the description and in the body) would "pass" on the mutated text
676
- // too. Then this assertion would fail rather than the anchor itself — that is, the message
677
- // would point in the wrong direction.
678
- const g = new RegExp(
679
- re.source,
680
- re.flags.includes("g") ? re.flags : re.flags + "g",
681
- );
682
- assert.equal(
683
- re.test(src.replace(g, "")),
684
- false,
685
- `${name}: the anchor ${re} passes even on text WITHOUT it — the check pins nothing down`,
686
- );
687
- }
688
- for (const [re, why] of opts.mustNotSay || []) {
689
- soft(() => assert.ok(!re.test(src), `${name}: "${why}" came back (${re})`));
690
- assert.equal(
691
- re.test(src + "\n" + why + "\n"),
692
- true,
693
- `${name}: the ban ${re} does not fire on the planted line "${why}" — the check is dead`,
694
- );
695
- }
696
-
697
669
  if (deferred.length)
698
670
  throw new AggregateError(deferred, `${name}: ${deferred.length} findings`);
699
671
  recordCheck();