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
package/lib/markdown.mjs CHANGED
@@ -49,6 +49,9 @@ try {
49
49
  */
50
50
  export const MD_AVAILABLE = () => md !== null;
51
51
 
52
+ /** markdown-it's block tokens for `text`, or null when it did not resolve. A test passes its own. */
53
+ const defaultParse = () => (md === null ? null : (text) => md.parse(text, {}));
54
+
52
55
  /**
53
56
  * The frontmatter is BLANKED with spaces, not cut out: the offsets must stay the
54
57
  * same, because the checks cut the document by offset. Blanking is necessary — inside
@@ -118,15 +121,17 @@ function lineStarts(text) {
118
121
  * under without a blank line would become a level-2 heading per CommonMark. The former
119
122
  * regexps knew only ATX, so this preserves the contract rather than narrowing it.
120
123
  */
121
- export function headings(text) {
122
- if (md === null) return [];
123
- const toks = md.parse(blankFrontmatter(text), {});
124
+ export function headings(text, { parse = defaultParse() } = {}) {
125
+ if (parse === null) return [];
126
+ const toks = parse(blankFrontmatter(text));
124
127
  const starts = lineStarts(text);
125
128
  const out = [];
126
129
  for (let i = 0; i < toks.length; i++) {
127
130
  const t = toks[i];
128
131
  if (t.type !== "heading_open" || !t.map) continue;
129
132
  if (!t.markup || !t.markup.startsWith("#")) continue; // setext — see the caveat above
133
+ // markdown-it follows `heading_open` with its `inline` token and maps blocks to lines of the
134
+ // parsed text; the fallbacks are for a token stream that does not (another parser version).
130
135
  const inline = toks[i + 1];
131
136
  out.push({
132
137
  depth: Number(t.tag.slice(1)),
@@ -138,64 +143,6 @@ export function headings(text) {
138
143
  return out;
139
144
  }
140
145
 
141
- /** Offsets of the `---` thematic breaks (in this knowledge base — a prose separator). */
142
- export function thematicBreaks(text) {
143
- if (md === null) return [];
144
- const starts = lineStarts(text);
145
- return md
146
- .parse(blankFrontmatter(text), {})
147
- .filter((t) => t.type === "hr" && t.map)
148
- .map((t) => starts[t.map[0]] ?? 0);
149
- }
150
-
151
- /**
152
- * A section's body: from the line AFTER the heading matching `match`, up to the next
153
- * heading of THE SAME OR A LOWER level. `{ heading, body }` or `null`.
154
- *
155
- * `match` — a regex over the heading text OR a predicate over `{depth,text,line,offset}`.
156
- *
157
- * Level-or-lower, not "exactly `##`": a `###` subsection inside the section does not
158
- * end it — that is exactly how the former expressions with the `(?=\n## )` lookahead
159
- * behaved. A regex cannot know this distinction in principle: it has no notion of level.
160
- *
161
- * `depth` — the CEILING on the level of the heading itself. Without it an `### Abstract`
162
- * quoted in an appendix becomes the abstract — and the abstract is judged with a
163
- * threshold of ZERO.
164
- *
165
- * `stopAtRule` additionally cuts the section off at the first `---`. That is NOT markdown
166
- * structure but a paper convention, and it is preserved deliberately: the conclusion has
167
- * a `---` before the appendices, and without that boundary a section with no following
168
- * `##` would reach to the end of the file.
169
- *
170
- * 🔴 A MERGED VERSION. On 2026-08-11 this function appeared SIMULTANEOUSLY in two places:
171
- * here (for `kb-lint`) and in `paper-lint.mjs` (richer — with the level ceiling, the
172
- * heading in the result and `stopAtRule`). Both were written within one hour by two
173
- * parallel passes, neither saw the other. Merged into the rich version: the weak one was
174
- * a subset, and keeping both would have meant diverging in behaviour at the very next edit.
175
- */
176
- export function sectionBody(
177
- text,
178
- match,
179
- { depth = 6, stopAtRule = false } = {},
180
- ) {
181
- const test = typeof match === "function" ? match : (h) => match.test(h.text);
182
- const hs = headings(text);
183
- const i = hs.findIndex((h) => h.depth <= depth && test(h));
184
- if (i === -1) return null;
185
- const h = hs[i];
186
- const next = hs.slice(i + 1).find((n) => n.depth <= h.depth);
187
- let end = next ? next.offset : text.length;
188
- if (stopAtRule) {
189
- const hr = thematicBreaks(text).find((o) => o > h.offset && o < end);
190
- if (hr !== undefined) end = hr;
191
- }
192
- // The body starts on the line AFTER the heading: the heading itself is not part of the
193
- // section, just as the former expressions' group did not capture it.
194
- const nl = text.indexOf("\n", h.offset);
195
- const start = nl === -1 || nl + 1 > end ? end : nl + 1;
196
- return { heading: h, body: text.slice(start, end) };
197
- }
198
-
199
146
  /**
200
147
  * Slicing the document into chunks "a heading + everything up to the next heading FROM
201
148
  * THE SAME RANGE of levels". → `[{ heading, raw, body }]`.
@@ -302,15 +249,15 @@ export function stripFences(text, { blank = false } = {}) {
302
249
  * indistinguishable from "the paper has no sections". A confident zero has already been
303
250
  * read as a finding four times in this repo; here it is inexpressible.
304
251
  */
305
- export function requireMarkdown() {
252
+ export function requireMarkdown({ from = import.meta.url } = {}) {
306
253
  if (MD_AVAILABLE()) return;
307
- // 🔴 THE CURE DEPENDS ON THE DELIVERY CHANNEL, AND NAMING THE WRONG ONE WASTES THE ERROR.
308
- // Measured 2026-09-19: `npm pack` strips `package-lock.json` unconditionally, and a plugin copy
309
- // gets `npm ci` only when a lockfile is present in the fetched root — so a plugin installed from
310
- // npm has NO `node_modules`, with no log entry anywhere. Telling that user to run `npm i at the
311
- // repo root` points at a repository they do not have. The channel is readable from this file's
312
- // own location, so the message can name it instead of guessing.
313
- const fromPluginCache = import.meta.url.includes("/.claude/plugins/");
254
+ // 🔴 THE CURE DEPENDS ON WHERE THIS COPY RUNS FROM. Measured 2026-09-19: a Claude Code plugin
255
+ // copy is installed without dependencies (`npm pack` strips `package-lock.json`, and the plugin
256
+ // cache runs `npm ci` only with a lockfile). The plugin channel was removed in #82, but a copy
257
+ // installed before then still runs from there, and "npm i at the repo root" would point its user
258
+ // at a repository they do not have. The location is readable from this file's own URL (`from`,
259
+ // a parameter only so a test can name a plugin-cache location without copying the file there).
260
+ const fromPluginCache = from.includes("/.claude/plugins/");
314
261
  console.error(
315
262
  "markdown-it does not resolve — this script parses markup with a parser and without it " +
316
263
  "would produce a confident zero instead of an error.\n" +
@@ -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();