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
@@ -45,8 +45,7 @@
45
45
  * from the neighbouring `CLAUDE.md` exactly as `pdf/profile` reads a venue profile, and the address
46
46
  * of the finding is the `## Abstract` line in the paper itself. Parity was taken BEFORE the deletion
47
47
  * on `compile-rules-2026/paper.md`: 306 words against a ceiling of 305, one finding in both places,
48
- * the same four numbers. The test and eight mutations are in
49
- * `eslint-rules/paper-craft.{harness,mutations}.mjs`.
48
+ * the same four numbers. The test is `eslint-rules/paper-craft.harness.mjs`.
50
49
  *
51
50
  * WHAT IS LEFT AND WHY — TWO. The paragraph below was written when there were three, and is kept as
52
51
  * a record: they read a SECOND ENTITY on the filesystem, while an ESLint rule sees one linted file:
@@ -313,11 +312,9 @@ const NOMINAL = /\b\w{4,}(tion|sion|ment|ance|ence|ity|ness)s?\b/gi;
313
312
 
314
313
  // JARGON — moved into `eslint-rules/paper-craft.mjs` (the `unexplained-jargon` rule, 2026-08-26).
315
314
 
315
+ /** Matches of every pattern in the lexicon `pats`, summed. Every caller passes a lexicon array. */
316
316
  const count = (t, pats) =>
317
- (Array.isArray(pats) ? pats : [pats]).reduce(
318
- (n, p) => n + (t.match(p) || []).length,
319
- 0,
320
- );
317
+ pats.reduce((n, p) => n + (t.match(p) || []).length, 0);
321
318
  // 🔴 Next to this lived `listHits(t, pats)` — the same walk, but returning THE MATCHES THEMSELVES
322
319
  // rather than their count. Nobody called it (`no-unused-vars`, 2026-08-28), and that is not a
323
320
  // trifle: the paragraph below explains that a metric was ignored precisely because it could not NAME
@@ -368,14 +365,17 @@ function splitSentences(t) {
368
365
  }
369
366
  const words = (t) => (t.match(/[A-Za-z0-9%.'’-]+/g) || []).length;
370
367
 
371
- function analyse(text) {
368
+ /**
369
+ * The metrics of `text`, whose sentences the caller split and found non-empty — the same array is
370
+ * passed in rather than split again, so the check the caller made is the one the division relies on.
371
+ */
372
+ function analyse(text, sents) {
372
373
  const W = words(text),
373
374
  per1k = (n) => +((n / W) * 1000).toFixed(1);
374
- const sents = splitSentences(text);
375
375
  const lens = sents.map(words);
376
- const mean = lens.reduce((a, b) => a + b, 0) / (lens.length || 1);
376
+ const mean = lens.reduce((a, b) => a + b, 0) / lens.length;
377
377
  const sd = Math.sqrt(
378
- lens.reduce((a, b) => a + (b - mean) ** 2, 0) / (lens.length || 1),
378
+ lens.reduce((a, b) => a + (b - mean) ** 2, 0) / lens.length,
379
379
  );
380
380
 
381
381
  const cats = {
@@ -652,7 +652,21 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
652
652
  const raw = fs.readFileSync(f, "utf8");
653
653
  const isPdfText = f.endsWith(".txt");
654
654
  const prep = isPdfText ? pdfTextOnly : bodyOnly;
655
- const r = analyse(prep(raw));
655
+ // 🔴 NOTHING LEFT IS NOT "CLEAN". A body that strips to no words used to be analysed anyway: every
656
+ // metric is a fraction of the word count, so the report printed NaN, FLAGged three metrics and
657
+ // exited 0. Refused like `.tex` above — no number is better than a meaningless one.
658
+ // A body of headings only has words but no sentence (headings are struck out before splitting),
659
+ // and a zero sentence count divides just the same, so the test is on sentences.
660
+ const text = prep(raw);
661
+ const sentences = splitSentences(text);
662
+ if (sentences.length === 0) {
663
+ console.error(
664
+ `prose-lint: ${f} has no prose to measure — after the frontmatter, comments, code ` +
665
+ "blocks, tables and the reference section are stripped, no word is left.",
666
+ );
667
+ process.exit(2);
668
+ }
669
+ const r = analyse(text, sentences);
656
670
  if (flagsOnly) {
657
671
  const lines = [];
658
672
  // Citation density in a paragraph and a pile-up of jargon in a sentence moved on 2026-08-26 into
@@ -678,7 +692,12 @@ for (const f of args.filter((a) => !a.startsWith("--"))) {
678
692
  // `hedge-density`, `discourse-subject`, `undefined-coinage`.
679
693
  if (lines.length) {
680
694
  flagged += lines.length;
681
- console.error(`✍️ prose-lint — ${f.split("/").pop()}:`);
695
+ // The header STATES the count. run-mechanical.mjs prefers a stated "— N finding(s)" over
696
+ // counting output lines, and without it recorded header + findings + footer: one flagged
697
+ // caption became three findings in the ledger.
698
+ console.error(
699
+ `✍️ prose-lint — ${f.split("/").pop()} — ${lines.length} finding(s):`,
700
+ );
682
701
  lines.forEach((l) => console.error(l));
683
702
  console.error(
684
703
  " run `node .claude/skills/grade-paper-writing/prose-lint.mjs <file>` for the sentences",
@@ -0,0 +1,153 @@
1
+ /**
2
+ * prose-lint.mjs as a process — the way the skill and `run-mechanical.mjs` run it. Every mode, on
3
+ * fixtures written here, with the WHOLE output compared: the report is the product, so a changed
4
+ * number or a dropped line is a changed result.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { join } from "node:path";
8
+ import { dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { expect, test } from "vitest";
11
+ import { runNode, useTempDir, writeTree } from "../../test/support.mjs";
12
+
13
+ const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "prose-lint.mjs");
14
+ const root = useTempDir("prose-lint-");
15
+
16
+ const LONG_CAPTION_SENTENCE = Array.from(
17
+ { length: 45 },
18
+ (_, i) => `word${i}`,
19
+ ).join(" ");
20
+ const PAPER = `---
21
+ title: A title the linter must not read as a sentence
22
+ ---
23
+ # The paper
24
+
25
+ <!-- TIGHTEN: a working note, not prose -->
26
+
27
+ ## Introduction
28
+
29
+ In this paper we present a linter. It might possibly help, but this is not a proof. Clearly the
30
+ first result matters (Section 3). Prior work agrees [1, 2]. The speedup was 40% on average across the
31
+ full suite of papers, which suggests the method might hold generally.
32
+
33
+ - The first item is short.
34
+ - The second item is not long either.
35
+
36
+ \`\`\`
37
+ # a heading inside a code block is code
38
+ \`\`\`
39
+
40
+ | a | table |
41
+ |---|---|
42
+ | is | data |
43
+
44
+ ### Method
45
+
46
+ We believe that rather than guessing, we measure. Instead of prose, the numbers speak.
47
+
48
+ ## References
49
+
50
+ [1] A reference that is not prose.
51
+ `;
52
+
53
+ writeTree(root, {
54
+ "p/paper.md": PAPER,
55
+ "p/figures/fig1.tex": `\\begin{figure}\\caption{\\textbf{Big.} ${LONG_CAPTION_SENTENCE}. ${"More words here. ".repeat(20)}}\\end{figure}`,
56
+ // Empty once LaTeX commands and braces are stripped: skipped, not counted as a zero-word caption.
57
+ "p/figures/fig2.tex": "\\caption{\\centering}",
58
+ // A lone "#": no word at all, and no sentence either.
59
+ "p/figures/fig4.tex": "\\caption{{#}}",
60
+ "p/figures/notes.txt": "not a figure",
61
+ // Reads as a markdown heading once LaTeX is stripped ("# Big"), so it splits into NO sentence:
62
+ // the caption scan must not flag it and must not crash.
63
+ "p/figures/fig3.tex": "\\caption{{#} Big}",
64
+ "clean/paper.md": "## Intro\n\nShort and plain. Nothing else.\n",
65
+ "page.txt":
66
+ "A rendered page. It was hyphen-\nated across a line.\n\n12\n\fNext page text here.\nReferences\n[1] cut here.\n",
67
+ "paper.tex": "\\documentclass{article}",
68
+ });
69
+
70
+ test("no arguments: the usage line, exit 0", () => {
71
+ assert.deepEqual(runNode(SCRIPT), {
72
+ status: 0,
73
+ stdout: "",
74
+ stderr: "usage: node prose-lint.mjs <file.md|file.txt> [more files]\n",
75
+ });
76
+ });
77
+
78
+ test("a .tex file is refused with exit 2, and nothing is measured", () => {
79
+ const r = runNode(SCRIPT, [join(root, "paper.tex")]);
80
+ assert.deepEqual(
81
+ { status: r.status, stdout: r.stdout },
82
+ { status: 2, stdout: "" },
83
+ );
84
+ expect(r.stderr.replaceAll(root, "<root>")).toMatchSnapshot();
85
+ });
86
+
87
+ test("the full report over markdown and rendered text", () => {
88
+ const r = runNode(SCRIPT, [
89
+ join(root, "p", "paper.md"),
90
+ join(root, "page.txt"),
91
+ ]);
92
+ assert.deepEqual(
93
+ { status: r.status, stderr: r.stderr },
94
+ { status: 0, stderr: "" },
95
+ );
96
+ expect(r.stdout).toMatchSnapshot();
97
+ });
98
+
99
+ test("--headings lists every heading from level 2 to 4, in order", () => {
100
+ const r = runNode(SCRIPT, ["--headings", join(root, "p", "paper.md")]);
101
+ assert.deepEqual(
102
+ { status: r.status, stderr: r.stderr },
103
+ { status: 0, stderr: "" },
104
+ );
105
+ expect(r.stdout).toMatchSnapshot();
106
+ });
107
+
108
+ test("--flags-only: an over-long caption flags and exits 1", () => {
109
+ const r = runNode(SCRIPT, ["--flags-only", join(root, "p", "paper.md")]);
110
+ assert.deepEqual(
111
+ { status: r.status, stdout: r.stdout },
112
+ { status: 1, stdout: "" },
113
+ );
114
+ expect(r.stderr).toMatchSnapshot();
115
+ });
116
+
117
+ test("--flags-only: a paper with no figures directory is clean, exit 0 and silent", () => {
118
+ assert.deepEqual(
119
+ runNode(SCRIPT, ["--flags-only", join(root, "clean", "paper.md")]),
120
+ {
121
+ status: 0,
122
+ stdout: "",
123
+ stderr: "",
124
+ },
125
+ );
126
+ });
127
+
128
+ test("a file with no prose left to measure is refused with exit 2, not reported as NaN", () => {
129
+ writeTree(root, {
130
+ "empty/paper.md":
131
+ "---\ntitle: only frontmatter\n---\n<!-- and a note -->\n",
132
+ });
133
+ const file = join(root, "empty", "paper.md");
134
+ assert.deepEqual(runNode(SCRIPT, [file]), {
135
+ status: 2,
136
+ stdout: "",
137
+ stderr:
138
+ `prose-lint: ${file} has no prose to measure — after the frontmatter, comments, code ` +
139
+ "blocks, tables and the reference section are stripped, no word is left.\n",
140
+ });
141
+ });
142
+
143
+ test("a body of headings only has no sentence to measure, and is refused the same way", () => {
144
+ writeTree(root, { "headings/paper.md": "## Intro\n\n### Method\n" });
145
+ const file = join(root, "headings", "paper.md");
146
+ assert.deepEqual(runNode(SCRIPT, [file]), {
147
+ status: 2,
148
+ stdout: "",
149
+ stderr:
150
+ `prose-lint: ${file} has no prose to measure — after the frontmatter, comments, code ` +
151
+ "blocks, tables and the reference section are stripped, no word is left.\n",
152
+ });
153
+ });
@@ -752,9 +752,9 @@ function buildArm(a) {
752
752
  // WRITING is the opposite case, and a parser is the wrong tool: `yaml.dump` would
753
753
  // reformat the whole block, so the fixture would differ from the real file in ways
754
754
  // the ablation is not measuring. The line is READ OUT of the file instead of
755
- // assumed — the idiom skills.mutations.mjs already uses — and a missing line is an
756
- // error rather than a silent no-op, because a mutation that does not apply scores
757
- // as a pass over a fixture nobody changed.
755
+ // assumed, and a missing line is an error rather than a silent no-op, because an
756
+ // edit that fails to apply would otherwise score as a pass over a fixture nobody
757
+ // changed.
758
758
  const setLine = (text, key, value) => {
759
759
  const line = new RegExp(`^${key}:.*\\n`, "m").exec(text); // kb-lint:markdown-regex-ok — editing a LINE, not parsing
760
760
  if (!line)
@@ -10,7 +10,7 @@ each mechanical gate and check the gate says no (see **Harnesses**, added 2026-0
10
10
  > here, and **their tests, which until that day lay in another top-level directory**. That is,
11
11
  > colocation was missing precisely where we preach it: checker in the skill, its harness — outside.
12
12
  >
13
- > What remained without an owner (shared library of 24 skills, `markdown.mjs`, mutations engine) —
13
+ > What remained without an owner (shared library of 24 skills, `markdown.mjs`) —
14
14
  > in [`.claude/lib/`](../../../lib/README.md), also there the breakdown of four forms of reference, from which
15
15
  > path-based search sees only one.
16
16
 
@@ -153,7 +153,7 @@ finished. Neither row present means it did not run, whatever any status file cla
153
153
  - `EXPECTED_GATES` in `status.mjs` is the table. A check that records rows and is absent there
154
154
  writes rows nobody sees. Add a new skill AND its mechanical checks; `unlistedGates()` catches the
155
155
  omission after the fact, and `pipeline-corpus.harness.mjs` catches it before.
156
- - `ledger.selftest.mjs` and every harness redirect `PIPELINE_LEDGER` to a temp file **before**
156
+ - `ledger.test.mjs` and every harness redirect `PIPELINE_LEDGER` to a temp file **before**
157
157
  importing `ledger.mjs`. Save-and-restore is not isolation; it lost that race on its first day.
158
158
 
159
159
  ## Harnesses (2026-08-07)
@@ -184,133 +184,14 @@ These two are the only ones aimed at the **skills** rather than the scripts they
184
184
  is what `vigiles audit`'s `Tested` metric actually counts — "34 surfaces with no vigiles test/eval"
185
185
  was never about `structure.mjs`. It also fails differently: a script with a bad path crashes, a
186
186
  **skill** with a bad path is read by a model that quietly does something adjacent and reports success.
187
- Its non-vacuity proof is a separate file, `skills.mutations.mjs` — 21 planted defects, one per
188
- assertion class, each on a throwaway copy of `.claude/`, plus a control asserting the clean corpus
189
- produces _only_ the four known-red YAML findings so no case can pass on noise. Run it by hand after
190
- touching the harness (`node .claude/skills/paper-pipeline/scripts/skills.mutations.mjs`, ~3 s); CI runs it too.
191
-
192
- > **2026-09-24 — hand-written mutation batteries are deprecated (#52).** Do not write a new
193
- > `*.mutations.mjs` or add cases to an existing one; say what an assertion guards in a comment
194
- > directly above it. `npm run check` enforces this through `scripts/mutation-batteries.frozen.json`,
195
- > which may only shrink. The history below is kept as a record, not as an instruction.
196
-
197
- Nine harnesses now carry a mutations file of their own — `delivered-pdf.mutations.mjs` (25 rows), `bound-numbers.mutations.mjs` (12 rows),
198
- `round-diff.mutations.mjs` (28 rows), `ledger.mutations.mjs` (28 rows, covering the 2026-08-10
199
- refactor across both `gates.harness.mjs` and `skill-checks.mjs`), `artifact-coverage.mutations.mjs`
200
- (22 rows), `generated-code.mutations.mjs` (22 rows), `textidote.mutations.mjs` (20 rows) and
201
- `uncited-refs.mutations.mjs` (8 rows). They are run by
202
- hand, not in CI, because each one rewrites a source file dozens of times and takes minutes.
203
-
204
- > **2026-08-26 — `verify-refs.mjs` moved to the engine and deleted from here** (along with its harness and
205
- > mutations). Bibliography measurement now `extract-ref-facts.mjs` in the same folder, judgment — twelve
206
- > rules `refs/*` in `eslint-rules/ref-facts.mjs`, mutations — `eslint-rules/ref-facts.mutations.mjs`
207
- > (21 rows). Analysis — `the author's private research notes`.
208
-
209
- **2026-08-14 — the engine was split out into `mutation-driver.mjs`; the tables stayed.** Each file
210
- now holds only its cases (which defect, which bytes, which message — data about ITS checker) and
211
- calls `runMutations()`. The reason is not line count: the ten copies of the driver had DRIFTED, and
212
- the protections lived in whichever file happened to earn them. Counted against the committed
213
- versions — the no-op guard (a replacement equal to the original leaves a green harness proving
214
- nothing) was in **4 of 10**; the retry-once on a non-kill was in **1 of 10**; the strict rule that a
215
- mutation must be killed by its OWN named assertion, not merely by _something_ going red, was in
216
- **1 of 10**. All ten now have all three. Two dead-path defects were closed on the way: six `ledger`
217
- cases named `skills.harness.mjs`, deleted by the 2026-08-11 colocation, and `vigiles test` on a path
218
- matching nothing exits 0 — so those six reported SURVIVED on every run since (they now go red at
219
- their own assertions, verified by running them). And the end-of-run "restore failed" line had been
220
- firing on `gates.harness.mjs`, which is red **on purpose** — the driver now baselines the harnesses
221
- before touching anything, so that claim means what it says. `skills.mutations.mjs` deliberately
222
- stays outside the engine: it mutates a COPY of `.claude/`, which is right for cases whose subject
223
- IS the skill corpus, and it is the only one CI runs. **The
224
- first run of a mutations file has never yet been clean, and that is the argument for writing one.**
225
- `delivered-pdf`'s first run left five survivors and three wrong-case hits, and unpicking them found
226
- three fixtures that tested nothing they claimed to (a window assertion satisfied by ordering, a
227
- digit-width guard the counter never reached), two mutations that did not mutate, and **one dead rule
228
- in the checker itself** — a table of sixteen Unicode space separators whose removal changed no
229
- verdict, because Python's `\s` already matched every one of them. It was deleted rather than tested.
230
-
231
- The two 2026-08-10 files kept the streak, and both findings were about the TEST rather than the
232
- checker. `uncited-refs`'s heading-offset row SURVIVED, which said either that the offset was dead
233
- code or that nothing watched it; reading `checkcites.lua` settled it — line 359 prints `=> <name>`
234
- for an auxiliary file it could not resolve and then CARRIES ON into the report, and LaTeX writes
235
- `\@input{sub.aux}` for every `\include`, so without the offset a partial build makes the wrapper
236
- report `nope.aux` as an uncited bibliography entry. A fabricated finding, in a report somebody
237
- would act on. `textidote`'s run reported `key/rule` as a WRONG-CASE kill three times running, and
238
- each fix exposed the next layer: the key assertions sat after the delta block (so a key defect was
239
- named by the delta's message), then a finding's rule was being read back out of the key (so a key
240
- defect was named by the PARSER's message), then the two warnings in the fixture were in the order
241
- that hides a collapsing key. **And the third fix silently deleted two assertion blocks** — a
242
- scripted reorder dropped the delta and case blocks, the harness stayed green, and the only signal
243
- was the next mutation run reporting `key/case` as SURVIVED. Deleting assertions can never turn a
244
- harness red; nothing but a mutation run can see it.
245
-
246
- The two 2026-08-10 arrivals kept the streak, and one of them found a defect in the CHECKER rather
247
- than in a test. `generated-code`'s first run left **three survivors**:
248
-
249
- - **A quiet case that was quiet for the wrong reason.** The fixture proving `--seed` in an argparse
250
- call is recognised drew with `rnd.sample(...)` off a local `random.Random(...)`, which the
251
- draw pattern does not recognise as a draw at all. So the case passed because _nothing was
252
- detected_, not because the seed was — neutering the seed rule changed no verdict. This is the
253
- same shape as `delivered-pdf`'s window assertion satisfied by ordering, and it is invisible on
254
- inspection: the fixture reads like a correct script, because it is one.
255
- - **🔴 A dead flag in the checker.** The absolute-path rule is written case-sensitive on purpose —
256
- without it an Express route `/users/export` matches `/Users/`. But the matcher was built with
257
- `new RegExp(ABS.source + …, 'g')`, which takes the source and **throws the flags away**. Adding
258
- an `i` to the pattern therefore changed nothing, i.e. a mutation that did not mutate, i.e. the
259
- property the harness asserted lived nowhere the harness could reach. Fixed by threading
260
- `ABS.flags` through, which makes the flag load-bearing for the first time.
261
- - **A mutation that could not change the answer.** Loosening the READ side of the in-place rule
262
- alone can never produce a finding, because the WRITE side still demands a quoted literal and no
263
- operand can pair. That row now spans both arrays, and says so.
264
-
265
- `artifact-coverage`'s 22 rows all killed on the first run, but four killed at a DIFFERENT assertion
266
- than named, and two of those were the expectation string matching a fragment of prose that appears
267
- in more than one message (`"that is the"`) — the `no-witness` defect from `ledger.mutations.mjs`,
268
- committed again three days later. Two rows legitimately kill at an earlier case that asserts the
269
- same property, and each says which, so a future reader does not read it as off-target. One harness
270
- case was also restructured because two rows were landing on a shared assertion: a case that tests
271
- an EXCLUSION should not assert the finding count, or a mutation of the header lands there and hides
272
- which property actually broke.
273
-
274
- The reverse leg's own first run on the live paper found a third defect, this time in the port:
275
- matching a directory name with `includes()` acquitted a fixture directory called `a-2026-01-01` on
276
- the strength of the letter "a" appearing in the prose. Substring matching in a cherry-picking
277
- detector fails in the dangerous direction — it invents evidence that a result was mentioned — and
278
- it is now whole-token with a minimum length.
279
-
280
- `round-diff`'s first run was six rows wrong out of twenty-six, and one of those was a hole in the
281
- checker rather than in the test: disabling the number-prefix rule in `covers()` changed **no**
282
- verdict, because a substring fallback had been silently doing its work — which also meant
283
- `touches: ["2"]` covered "Section 12" and every heading containing a 2, so a one-character
284
- declaration authorised most of the paper while `overbroad-scope` stayed quiet because the _list_ was
285
- short. Two more rows were the test's own fault in the two documented ways: a `find` string written
286
- in its already-mutated form (a mutation that did not mutate), and a fixture that MOVED text between
287
- body and appendix — which keeps the total constant and therefore cannot test the boundary at all.
288
-
289
- `ledger.mutations.mjs` kept the streak: five survivors and two wrong-case kills on its first run,
290
- and **every one was a defect in the assertion rather than in the checker.** Two matched a bare word
291
- (`no-witness`, `input-missing`) that the CLI's usage block prints unconditionally, so the refusal
292
- message could have said anything at all. The dead-report-path case asserted only a non-zero exit —
293
- which the next line's `ENOENT` supplies once the check is deleted. The status view's reason
294
- assertion matched the explanatory PARAGRAPH rather than the check's own row, so the reason could
295
- have been stripped from every row and the test stayed green. And the legacy fixture recorded `PASS`
296
- with `findings: 0`, which made "a retired acquittal carries nothing forward" true by accident —
297
- real `PASS` rows carry counts (`sweep-design-space`, 8). One mutation also turned out to be
298
- **inexpressible finely**: disabling only the no-report-path arm makes the next line `resolve()` a
299
- null and throw, so the run goes red for a reason unrelated to the check. That row disables the whole
300
- evidence contract instead and relies on assertion order, which is written down in the row's note.
301
-
302
- The `--blocking` flag was found the same way and is worth naming separately, because it is not a
303
- test defect: it shipped **documented in the usage text and in all 22 SKILL.md files while nothing in
304
- the CLI parsed it**. Every skill instructing `--blocking` for its desk-reject case would have
305
- recorded an ordinary advisory finding. That is the repository's oldest failure — a documented
306
- mechanism nothing implements, the same shape as three hooks dead on arrival and a nudge dead for
307
- twelve days — arriving _inside_ the refactor written to end it. Block 14 of `gates.harness.mjs` now
308
- asserts every advertised flag from outside the process, in both directions.
309
-
310
- ⚠️ A mutations file refuses to start on a dirty working tree, because it rewrites the source it is
311
- proving. Commit first. (`ledger.mutations.mjs` touches `status.mjs` and `run-mechanical.mjs`, which
312
- another session may be editing; running it against a copy of `.claude/` with `CLAUDE_PROJECT_DIR`
313
- pointed at the copy works and is how it was last verified.)
187
+ Its non-vacuity proof is `lib/skill-checks.test.mjs` — a copy of the shipped skills with defects
188
+ planted on the copy, checked against both `checkSkill` (pipeline skills) and `checkProseSkill`
189
+ (skills without the pipeline). Run it with `npx vitest run lib/skill-checks.test.mjs`; CI runs it
190
+ too.
191
+
192
+ > **2026-09-26 — hand-written per-checker mutation batteries are REMOVED (#52).** Do not write a
193
+ > `*.mutations.mjs`; see a new test fail before the fix and assert whole values instead (the
194
+ > repository's CLAUDE.md, § Testing).
314
195
 
315
196
  Five rules, each learned by getting it wrong here:
316
197
 
@@ -321,10 +202,10 @@ Five rules, each learned by getting it wrong here:
321
202
  importing `ledger.mjs`. Save-and-restore is not isolation; it loses the race on the first crash.
322
203
  3. **Run them by explicit path.** `npx vigiles test` with no arguments finds **nothing** here: its
323
204
  glob does not descend into dot-directories. Every harness is therefore named explicitly in
324
- `.github/workflows/paper-gates.yml` (`hooks` job) — one not named there does not run, and its
205
+ the `hooks` job of `.github/workflows/ci.yml` — one not named there does not run, and its
325
206
  silence looks exactly like having nothing to say. Add the line in the same commit as the file.
326
- 4. **Prove non-vacuity by MUTATION, never by reading.** An assertion you cannot see fail is an
327
- assertion you have not tested. `skill-checks.mjs` (then `skills.harness.mjs`) shipped its first draft with a record-block
207
+ 4. **Prove non-vacuity by SEEING IT FAIL, never by reading.** An assertion you cannot see fail is
208
+ an assertion you have not tested. `skill-checks.mjs` (then `skills.harness.mjs`) shipped its first draft with a record-block
328
209
  slicer matching `^#` against an already-sliced string; `^` hit offset 0, every block came back as
329
210
  the single character `#`, one assertion then fired on all 21 skills while its neighbour checked
330
211
  nothing at all. Both were invisible on inspection and obvious on the first planted defect.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * announce.mjs — the first line of every wired skill's `## Run me` block: a banner on stderr naming
3
+ * the paper and skill versions, and a `started` abstention in the ledger. Run as the skills run it,
4
+ * against a temp consumer and a temp ledger.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { cpSync, readFileSync, rmSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { afterEach, test } from "vitest";
11
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const SCRIPT = join(HERE, "announce.mjs");
15
+ const root = useTempDir("announce-");
16
+ const consumer = join(root, "consumer");
17
+ const LEDGER = join(root, "runs.jsonl");
18
+ writeTree(root, { "consumer/papers/p/paper.md": "# P\n\ntext\n" });
19
+ cpSync(
20
+ join(HERE, "..", "..", "cold-read-diff"),
21
+ join(consumer, ".claude", "skills", "cold-read-diff"),
22
+ {
23
+ recursive: true,
24
+ verbatimSymlinks: true,
25
+ },
26
+ );
27
+ const env = { CLAUDE_PROJECT_DIR: consumer, PIPELINE_LEDGER: LEDGER };
28
+ process.env.CLAUDE_PROJECT_DIR = consumer;
29
+ process.env.PIPELINE_LEDGER = LEDGER;
30
+ const { inputHash, skillHash } = await import("./ledger.mjs");
31
+
32
+ const rows = () =>
33
+ readFileSync(LEDGER, "utf8")
34
+ .trim()
35
+ .split("\n")
36
+ .map((l) => JSON.parse(l))
37
+ .map(({ skill, check, paper, kind, reason, note }) => ({
38
+ skill,
39
+ check,
40
+ paper,
41
+ kind,
42
+ reason,
43
+ note,
44
+ }));
45
+ afterEach(() => rmSync(LEDGER, { force: true }));
46
+
47
+ test("no skill name: the usage line, exit 2, nothing recorded", () => {
48
+ assert.deepEqual(runNode(SCRIPT, [], { env }), {
49
+ status: 2,
50
+ stdout: "",
51
+ stderr: "usage: announce.mjs <skill-name> [paper-dir]\n",
52
+ });
53
+ });
54
+
55
+ test("a skill on a paper: the banner names both versions, and a `started` row is recorded", () => {
56
+ const paper = join(consumer, "papers", "p");
57
+ const r = runNode(SCRIPT, ["cold-read-diff", paper], { env });
58
+ assert.deepEqual(
59
+ { ...r, rows: rows() },
60
+ {
61
+ status: 0,
62
+ stdout: "",
63
+ stderr:
64
+ "▶ cold-read-diff — p\n" +
65
+ ` paper ${inputHash(paper).slice(0, 8)} · skill ${skillHash("cold-read-diff").slice(0, 8)}\n`,
66
+ rows: [
67
+ {
68
+ skill: "cold-read-diff",
69
+ check: "cold-read-diff",
70
+ paper: "p",
71
+ kind: "ABSTAINED",
72
+ reason: "started",
73
+ note: "no verdict recorded yet",
74
+ },
75
+ ],
76
+ },
77
+ );
78
+ });
79
+
80
+ test("no paper directory and an unknown skill still announce, with placeholders", () => {
81
+ const r = runNode(SCRIPT, ["no-such-skill", join(root, "nowhere")], { env });
82
+ assert.deepEqual(
83
+ { status: r.status, stderr: r.stderr, kind: rows()[0].kind },
84
+ {
85
+ status: 0,
86
+ stderr:
87
+ "▶ no-such-skill — no paper dir\n paper (none) · skill ????????\n",
88
+ kind: "ABSTAINED",
89
+ },
90
+ );
91
+ });
92
+
93
+ test("no paper argument means the current directory", () => {
94
+ const r = runNode(SCRIPT, ["cold-read-diff"], {
95
+ env,
96
+ cwd: join(consumer, "papers", "p"),
97
+ });
98
+ assert.deepEqual(
99
+ { status: r.status, firstLine: r.stderr.split("\n")[0] },
100
+ { status: 0, firstLine: "▶ cold-read-diff — p" },
101
+ );
102
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * artifact-coverage.mjs on the inputs its harness does not build: no argument, no markdown paper,
3
+ * a free-section heading on the paper's very first line, a numbered section without subsections,
4
+ * an index naming the same path twice / a glob / a URL / a repro path, no repro/ at all, a
5
+ * grandfather row without a reason, and a plain file among the repro/ entries.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { test } from "vitest";
11
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
12
+
13
+ const SCRIPT = join(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ "artifact-coverage.mjs",
16
+ );
17
+ const root = useTempDir("artifact-coverage-");
18
+ const run = (...args) => runNode(SCRIPT, args);
19
+
20
+ test("no argument prints the usage; a paper with no markdown source is a named skip", () => {
21
+ assert.deepEqual(run(), {
22
+ status: 0,
23
+ stdout: "",
24
+ stderr: "usage: node artifact-coverage.mjs <paper-dir> [--flags-only]\n",
25
+ });
26
+ const tex = writeTree(join(root, "tex"), { "paper.tex": "x" });
27
+ const r = run(tex);
28
+ assert.equal(r.status, 0);
29
+ assert.match(
30
+ r.stderr,
31
+ /artifact coverage SKIPPED for .* — no paper\.md\/draft\.md/,
32
+ );
33
+ });
34
+
35
+ test("a section without subsections is checked against its own number; the index's odd paths are passed over", () => {
36
+ const dir = writeTree(join(root, "odd"), {
37
+ "paper.md": "## 3. Results\n\nWe measured **42 %** of runs.\n",
38
+ "artifact/README.md": [
39
+ "Section 7 only.",
40
+ "`data/rows.tsv` and again `data/rows.tsv`",
41
+ "`runs/*/out.json`",
42
+ "`https://example.org/x.json`",
43
+ "`repro/work/tmp.json`",
44
+ ].join("\n"),
45
+ });
46
+ const r = run(dir);
47
+ assert.match(
48
+ r.stdout,
49
+ /§3 reports 1 bolded figure\(s\) and is named NOWHERE/,
50
+ );
51
+ assert.match(
52
+ r.stdout,
53
+ /the index names `data\/rows\.tsv` and `data` is not in the bundle/,
54
+ );
55
+ assert.equal(r.stdout.match(/rows\.tsv/g)?.length, 1);
56
+ assert.doesNotMatch(r.stdout, /out\.json|example\.org|tmp\.json/);
57
+ assert.match(
58
+ r.stdout,
59
+ /reverse check \(results on disk nobody reports\): SKIPPED — no repro\/ directory/,
60
+ );
61
+ });
62
+
63
+ test("a free-section heading on the first line does not end the body at offset 0", () => {
64
+ const dir = writeTree(join(root, "first-line"), {
65
+ "paper.md": "## Limitations\n\nNone.\n\n## 2. Method\n\nIt took **9 s**.\n",
66
+ "artifact/README.md": "nothing\n",
67
+ });
68
+ assert.match(run(dir).stdout, /§2 reports 1 bolded figure\(s\)/);
69
+ });
70
+
71
+ test("repro/: a plain file is not a candidate, a dot-directory is swallowed and named; a grandfather row with no reason says so", () => {
72
+ const dir = writeTree(join(root, "grandfathered"), {
73
+ "paper.md": "## 1. Intro\n\nText.\n",
74
+ "artifact/README.md": "§1\n",
75
+ "repro/notes.txt": "a file, not an analysis",
76
+ "repro/.cache/RESULTS.md": "a dot-directory, swallowed before counting",
77
+ "repro/old-run/RESULTS.md": "done\n",
78
+ "repro/unreported-grandfathered.txt": "old-run\n",
79
+ });
80
+ const out = run(dir).stdout;
81
+ assert.match(out, / {7}old-run {2}→ {2}\(NO REASON GIVEN\)/);
82
+ assert.match(
83
+ out,
84
+ /• 1 before they were counted — dot-directories and build\/dependency trees[^\n]*\n {7}\.cache/,
85
+ );
86
+ });
@@ -23,7 +23,14 @@ export interface DanglingSkillLink {
23
23
  * `SKILL.md`, symlinks followed. Throws an error with `code: "DANGLING_SKILL_LINK"` and a
24
24
  * `dangling: DanglingSkillLink[]` property when any entry is a link that does not resolve.
25
25
  */
26
- export declare function installedSkills(dir: string): string[];
26
+ /** The four filesystem calls `installedSkills` makes — a test passes a fake. */
27
+ export interface SkillsFs {
28
+ readdirSync(dir: string): string[];
29
+ statSync(path: string): { isDirectory(): boolean };
30
+ existsSync(path: string): boolean;
31
+ readlinkSync(path: string): string;
32
+ }
33
+ export declare function installedSkills(dir: string, fs?: SkillsFs): string[];
27
34
 
28
35
  /** The directory of venue TeX files this package ships; `paperlint build` prepends it to TEXINPUTS. */
29
36
  export declare function packageVenuesDir(): string;
@@ -298,9 +298,9 @@ assert.equal(
298
298
 
299
299
  // 🔴 RELATIVE, AND `/`-SEPARATED, ASSERTED BEFORE THE EQUALITIES BELOW — and the order is a
300
300
  // finding, not a preference. `assert` aborts at the first failure, so with this line placed
301
- // after them a resolver that returned an absolute path died on "must be returned as written":
302
- // the mutation run reported RED-but-a-DIFFERENT-case, i.e. this assertion was never reached and
303
- // nothing showed it can fail. The narrow property goes first; the equalities then cover the rest.
301
+ // after them a resolver that returned an absolute path died on "must be returned as written" —
302
+ // a RED result, but for a different case than this one, i.e. this assertion was never reached
303
+ // and nothing showed it can fail. The narrow property goes first; the equalities then cover the rest.
304
304
  // The value is compared against text a human typed inside a SKILL.md, so an absolute path makes
305
305
  // every `startsWith` false — not an error, an empty loop body.
306
306
  assert.equal(