paperlint 3.1.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 (284) 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/bin/paperlint.test.mjs +45 -0
  6. package/dist/adapters/node/files.io.d.ts +2 -0
  7. package/dist/adapters/node/files.io.d.ts.map +1 -1
  8. package/dist/adapters/node/files.io.js +3 -2
  9. package/dist/adapters/node/files.io.js.map +1 -1
  10. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  11. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  12. package/dist/adapters/pdfjs/fill.js +31 -21
  13. package/dist/adapters/pdfjs/fill.js.map +1 -1
  14. package/dist/adapters/references/index.d.ts.map +1 -1
  15. package/dist/adapters/references/index.js +14 -5
  16. package/dist/adapters/references/index.js.map +1 -1
  17. package/dist/adapters/references/pool.d.ts +8 -0
  18. package/dist/adapters/references/pool.d.ts.map +1 -0
  19. package/dist/adapters/references/pool.js +19 -0
  20. package/dist/adapters/references/pool.js.map +1 -0
  21. package/dist/build.d.ts +10 -2
  22. package/dist/build.d.ts.map +1 -1
  23. package/dist/build.js +7 -8
  24. package/dist/build.js.map +1 -1
  25. package/dist/cli.d.ts +27 -1
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +71 -55
  28. package/dist/cli.js.map +1 -1
  29. package/dist/doctor.d.ts +27 -1
  30. package/dist/doctor.d.ts.map +1 -1
  31. package/dist/doctor.js +4 -3
  32. package/dist/doctor.js.map +1 -1
  33. package/dist/domain/result.d.ts +0 -2
  34. package/dist/domain/result.d.ts.map +1 -1
  35. package/dist/domain/result.js +0 -4
  36. package/dist/domain/result.js.map +1 -1
  37. package/dist/hooks-settings.d.ts +8 -5
  38. package/dist/hooks-settings.d.ts.map +1 -1
  39. package/dist/hooks-settings.js +32 -32
  40. package/dist/hooks-settings.js.map +1 -1
  41. package/dist/init.d.ts +8 -0
  42. package/dist/init.d.ts.map +1 -1
  43. package/dist/init.js +3 -3
  44. package/dist/init.js.map +1 -1
  45. package/dist/latex-log.d.ts +0 -2
  46. package/dist/latex-log.d.ts.map +1 -1
  47. package/dist/latex-log.js +7 -13
  48. package/dist/latex-log.js.map +1 -1
  49. package/dist/link-skills.d.ts +15 -2
  50. package/dist/link-skills.d.ts.map +1 -1
  51. package/dist/link-skills.js +36 -20
  52. package/dist/link-skills.js.map +1 -1
  53. package/dist/paper-files.d.ts.map +1 -1
  54. package/dist/paper-files.js +6 -4
  55. package/dist/paper-files.js.map +1 -1
  56. package/dist/pdf-facts.d.ts +11 -0
  57. package/dist/pdf-facts.d.ts.map +1 -1
  58. package/dist/pdf-facts.js +16 -18
  59. package/dist/pdf-facts.js.map +1 -1
  60. package/dist/pdf-geometry.js +1 -1
  61. package/dist/pdf-geometry.js.map +1 -1
  62. package/dist/presets.d.ts +2 -0
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +3 -1
  65. package/dist/presets.js.map +1 -1
  66. package/dist/references.js +2 -2
  67. package/dist/references.js.map +1 -1
  68. package/dist/tex-requirements.d.ts +8 -0
  69. package/dist/tex-requirements.d.ts.map +1 -1
  70. package/dist/tex-requirements.js +6 -5
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/toolchain.d.ts +3 -2
  73. package/dist/toolchain.d.ts.map +1 -1
  74. package/dist/toolchain.js +4 -5
  75. package/dist/toolchain.js.map +1 -1
  76. package/dist/venue-rules.d.ts.map +1 -1
  77. package/dist/venue-rules.js +11 -9
  78. package/dist/venue-rules.js.map +1 -1
  79. package/docs/prior-art/README.md +1 -1
  80. package/docs/prior-art/test-tooling.md +7 -30
  81. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  82. package/eslint-rules/latex-language.harness.mjs +3 -3
  83. package/eslint-rules/latex-language.mjs +12 -9
  84. package/eslint-rules/latex-language.test.mjs +200 -0
  85. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  86. package/eslint-rules/paper-stages.harness.mjs +9 -14
  87. package/eslint-rules/paper-stages.test.mjs +135 -0
  88. package/eslint-rules/paper-typography.mjs +9 -2
  89. package/eslint-rules/paper-typography.test.mjs +146 -1
  90. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  91. package/eslint-rules/review-frontmatter.mjs +2 -1
  92. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  93. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  94. package/eslint-rules/tex-build.mjs +13 -13
  95. package/eslint.config.mjs +6 -2
  96. package/hooks/hooks.harness.mjs +6 -11
  97. package/hooks/paper-edit-guard.hook.mjs +2 -2
  98. package/hooks/paper-edit-guard.test.mjs +61 -0
  99. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  100. package/hooks/paper-skills-nudge.test.mjs +38 -0
  101. package/hooks/paper-status-gates.test.mjs +42 -0
  102. package/lib/agent-cli-version.harness.mjs +7 -0
  103. package/lib/check.d.mts +17 -0
  104. package/lib/check.mjs +50 -0
  105. package/lib/check.test.ts +67 -0
  106. package/lib/markdown.mjs +16 -69
  107. package/lib/markdown.test.mjs +246 -0
  108. package/lib/paper-config.harness.mjs +28 -9
  109. package/lib/paper-config.mjs +7 -2
  110. package/lib/skill-checks.mjs +18 -46
  111. package/lib/skill-checks.test.mjs +297 -0
  112. package/lib/skill-corpus.mjs +11 -13
  113. package/lib/skill-corpus.test.mjs +54 -0
  114. package/lib/skill-trigger-cases.harness.mjs +57 -4
  115. package/package.json +6 -5
  116. package/scripts/check.harness.mjs +4 -10
  117. package/scripts/check.mjs +45 -42
  118. package/scripts/check.test.mjs +104 -0
  119. package/scripts/coverage-config.test.ts +406 -0
  120. package/scripts/eslint-report-guard.test.mjs +48 -0
  121. package/scripts/harness-api.frozen.json +0 -2
  122. package/scripts/layer-legacy-frozen.d.mts +9 -1
  123. package/scripts/layer-legacy-frozen.mjs +28 -11
  124. package/scripts/layer-legacy-frozen.test.ts +143 -1
  125. package/scripts/rules-are-content-only.harness.mjs +3 -11
  126. package/scripts/rules-are-content-only.mjs +29 -19
  127. package/scripts/rules-are-content-only.test.mjs +61 -0
  128. package/scripts/rules-see-files.mjs +11 -9
  129. package/scripts/rules-see-files.test.mjs +71 -0
  130. package/scripts/test.mjs +44 -0
  131. package/scripts/test.test.mjs +52 -0
  132. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  133. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  134. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  135. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  136. package/skills/paper-pipeline/scripts/README.md +13 -132
  137. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  138. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  139. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  140. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  141. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  142. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  145. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  148. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  149. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  150. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  151. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  152. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  153. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  154. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  155. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  156. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  157. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  158. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  159. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  160. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  161. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  162. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  163. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  164. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  165. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  166. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  167. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  168. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  169. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  170. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  171. package/skills/tighten-paper/structure.test.mjs +116 -0
  172. package/skills/verify-citations/SKILL.md +5 -5
  173. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  174. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  175. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  176. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  177. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  178. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  179. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  180. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  181. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  182. package/src/adapters/banal/failure.test.ts +70 -1
  183. package/src/adapters/banal/index.test.ts +16 -0
  184. package/src/adapters/banal/output.test.ts +5 -0
  185. package/src/adapters/curl/download.test.ts +43 -1
  186. package/src/adapters/memory/index.test.ts +25 -0
  187. package/src/adapters/node/files.io.ts +3 -2
  188. package/src/adapters/node/files.test.ts +13 -1
  189. package/src/adapters/node/process.test.ts +38 -0
  190. package/src/adapters/pdfjs/fill.test.ts +34 -0
  191. package/src/adapters/pdfjs/fill.ts +44 -25
  192. package/src/adapters/references/index.test.ts +228 -0
  193. package/src/adapters/references/index.ts +21 -6
  194. package/src/adapters/references/pool.test.ts +31 -0
  195. package/src/adapters/references/pool.ts +24 -0
  196. package/src/build-engine.harness.mjs +3 -7
  197. package/src/build-engine.test.ts +137 -0
  198. package/src/build.harness.mjs +5 -9
  199. package/src/build.test.ts +362 -0
  200. package/src/build.ts +13 -9
  201. package/src/cli.harness.mjs +10 -17
  202. package/src/cli.test.ts +584 -0
  203. package/src/cli.ts +112 -66
  204. package/src/doctor.harness.mjs +3 -8
  205. package/src/doctor.test.ts +64 -0
  206. package/src/doctor.ts +12 -4
  207. package/src/domain/result.test.ts +4 -0
  208. package/src/domain/result.ts +0 -5
  209. package/src/engine.harness.mjs +27 -7
  210. package/src/facts-file.test.ts +110 -0
  211. package/src/hooks-settings.harness.mjs +43 -13
  212. package/src/hooks-settings.ts +41 -35
  213. package/src/init.test.ts +308 -5
  214. package/src/init.ts +3 -3
  215. package/src/latex-log.harness.mjs +32 -7
  216. package/src/latex-log.ts +6 -13
  217. package/src/latex-loop.harness.mjs +8 -7
  218. package/src/link-skills.harness.mjs +3 -8
  219. package/src/link-skills.test.ts +105 -0
  220. package/src/link-skills.ts +75 -21
  221. package/src/new-paper.harness.mjs +3 -7
  222. package/src/new-paper.test.ts +31 -1
  223. package/src/paper-files.test.ts +12 -0
  224. package/src/paper-files.ts +9 -5
  225. package/src/paper-settings.test.ts +20 -0
  226. package/src/pdf-facts.harness.mjs +3 -6
  227. package/src/pdf-facts.test.ts +144 -0
  228. package/src/pdf-facts.ts +17 -19
  229. package/src/pdf-geometry.harness.mjs +3 -7
  230. package/src/pdf-geometry.ts +1 -1
  231. package/src/presets.test.ts +2 -0
  232. package/src/presets.ts +7 -1
  233. package/src/references.test.ts +97 -0
  234. package/src/references.ts +2 -2
  235. package/src/rules-config.harness.mjs +10 -8
  236. package/src/structure.harness.mjs +3 -7
  237. package/src/tex-requirements.harness.mjs +34 -7
  238. package/src/tex-requirements.ts +12 -6
  239. package/src/toolchain.harness.mjs +3 -7
  240. package/src/toolchain.test.ts +280 -0
  241. package/src/toolchain.ts +8 -7
  242. package/src/venue-rules.test.ts +60 -0
  243. package/src/venue-rules.ts +13 -11
  244. package/action.mutations.mjs +0 -162
  245. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  246. package/eslint-rules/latex-language.mutations.mjs +0 -196
  247. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  248. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  249. package/eslint-rules/papers.mutations.mjs +0 -186
  250. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  251. package/eslint-rules/tex-build.mutations.mjs +0 -258
  252. package/hooks/hooks.mutations.mjs +0 -337
  253. package/lib/agent-cli-version.mutations.mjs +0 -109
  254. package/lib/mutation-driver.harness.mjs +0 -227
  255. package/lib/mutation-driver.mjs +0 -397
  256. package/lib/mutation-driver.mutations.mjs +0 -68
  257. package/lib/paper-config.mutations.mjs +0 -143
  258. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  259. package/scripts/check.mutations.mjs +0 -110
  260. package/scripts/exclusive.mjs +0 -138
  261. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  262. package/scripts/mutation-batteries-frozen.mjs +0 -238
  263. package/scripts/mutation-batteries.frozen.json +0 -108
  264. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  265. package/scripts/rules-see-files.mutations.mjs +0 -131
  266. package/scripts/run-mutations.mjs +0 -100
  267. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  268. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  269. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  270. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  271. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  272. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  273. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  274. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  275. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  276. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  277. package/skills/skill-contract.mutations.mjs +0 -179
  278. package/src/build.mutations.mjs +0 -196
  279. package/src/cli.mutations.mjs +0 -414
  280. package/src/doctor.mutations.mjs +0 -175
  281. package/src/hooks-settings.mutations.mjs +0 -99
  282. package/src/link-skills.mutations.mjs +0 -140
  283. package/src/new-paper.mutations.mjs +0 -65
  284. package/src/structure.mutations.mjs +0 -83
@@ -0,0 +1,297 @@
1
+ /**
2
+ * skill-checks.mjs's two refusing halves, which the per-skill harnesses (all green on the real
3
+ * corpus) never reach: `checkSkill` on an unknown name and on a pipeline skill with a planted
4
+ * defect, and `checkProseSkill` — the check for skills WITHOUT the pipeline, which the package
5
+ * exports for a consumer's own skills and no harness here calls.
6
+ *
7
+ * The consumer is a temp root holding a COPY of the shipped skills, so a planted defect edits the
8
+ * copy, never the tracked files. It is set before import: the corpus resolves it at load time.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { cpSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { test } from "vitest";
15
+ import { useTempDir, writeTree } from "../test/support.mjs";
16
+
17
+ const SHIPPED = join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
18
+ const consumer = useTempDir("skill-checks-");
19
+ const SKILLS = join(consumer, ".claude", "skills");
20
+ cpSync(SHIPPED, SKILLS, { recursive: true, verbatimSymlinks: true });
21
+ process.env.CLAUDE_PROJECT_DIR = consumer;
22
+ const { checkProseSkill, checkSkill } = await import("./skill-checks.mjs");
23
+
24
+ /** The message of what `fn` throws — an AggregateError's messages joined, one per line. */
25
+ async function thrown(fn) {
26
+ try {
27
+ await fn();
28
+ } catch (e) {
29
+ return e instanceof AggregateError
30
+ ? [e.message, ...e.errors.map((x) => x.message.split("\n")[0])]
31
+ : e.message.split("\n")[0];
32
+ }
33
+ return null;
34
+ }
35
+
36
+ test("checkSkill passes a real shipped skill and refuses a name that is not a wired skill", async () => {
37
+ assert.deepEqual(
38
+ [
39
+ await checkSkill("cold-read-diff"),
40
+ await thrown(() => checkSkill("no-such-skill")),
41
+ ],
42
+ [
43
+ 1,
44
+ `"no-such-skill" is not a wired pipeline skill. Known: ${(await import("./skill-corpus.mjs")).SKILLS.join(", ")}`,
45
+ ],
46
+ );
47
+ });
48
+
49
+ test("checkSkill reports EVERY finding of a broken pipeline skill at once", async () => {
50
+ const md = join(SKILLS, "cold-read-diff", "SKILL.md");
51
+ const clean = readFileSync(md, "utf8");
52
+ writeFileSync(
53
+ md,
54
+ clean
55
+ .replace("name: cold-read-diff", "name: renamed")
56
+ .replace("## Run me", "## Not run"),
57
+ );
58
+ try {
59
+ const first = await thrown(() => checkSkill("cold-read-diff"));
60
+ assert.equal(
61
+ first,
62
+ '2 assertion(s) failed for pipeline skill "cold-read-diff":',
63
+ );
64
+ } finally {
65
+ writeFileSync(md, clean);
66
+ }
67
+ });
68
+
69
+ test("a pipeline skill whose record block is gone: ONE finding naming the heading — the ledger checks that read the block skip it", async () => {
70
+ const md = join(SKILLS, "cold-read-diff", "SKILL.md");
71
+ const clean = readFileSync(md, "utf8");
72
+ writeFileSync(md, clean.replaceAll("## Record the verdict", "## Afterwards"));
73
+ let message = "";
74
+ try {
75
+ await checkSkill("cold-read-diff");
76
+ } catch (e) {
77
+ message = e.message;
78
+ } finally {
79
+ writeFileSync(md, clean);
80
+ }
81
+ assert.equal(
82
+ message,
83
+ '1 assertion(s) failed for pipeline skill "cold-read-diff":\n\n' +
84
+ ' [1] cold-read-diff: no "Record the verdict" heading. A run with an announce row and no verdict row is indistinguishable from a skill that started and died.',
85
+ );
86
+ });
87
+
88
+ const PROSE = (extra = "") =>
89
+ [
90
+ "---",
91
+ "name: prose",
92
+ "description: a skill with no pipeline",
93
+ "allowed-tools: [Read, Write]",
94
+ "---",
95
+ "",
96
+ "<!-- vigiles:sha256:0000000000000000 compiled from SKILL.md.spec.ts -->",
97
+ "",
98
+ "Measured rule: always say which file. See [the notes](./notes.md) and [the web](https://x.org).",
99
+ extra,
100
+ "",
101
+ ].join("\n");
102
+ writeTree(SKILLS, { "prose/SKILL.md": PROSE(), "prose/notes.md": "notes\n" });
103
+
104
+ test("checkProseSkill passes a clean prose skill", async () => {
105
+ assert.equal(await checkProseSkill("prose"), 1);
106
+ });
107
+
108
+ test("checkProseSkill refuses an option it does not read, instead of silently skipping it", async () => {
109
+ assert.deepEqual(
110
+ await thrown(() =>
111
+ checkProseSkill("prose", { mustSay: [[/x/, "x"]], toolsNeeded: {} }),
112
+ ),
113
+ "prose: unknown option(s) mustSay — checkProseSkill takes only toolsNeeded",
114
+ );
115
+ });
116
+
117
+ test("checkProseSkill collects every finding: a banned tool, a dangling link, no compile mark", async () => {
118
+ writeTree(SKILLS, {
119
+ "broken/SKILL.md": [
120
+ "---",
121
+ "name: broken",
122
+ "description: d",
123
+ "allowed-tools: [Read, Bash]",
124
+ "---",
125
+ "See [gone](./gone.md). never mind the file.",
126
+ "",
127
+ ].join("\n"),
128
+ });
129
+ assert.deepEqual(await thrown(() => checkProseSkill("broken")), [
130
+ "broken: 3 findings",
131
+ "broken: Bash appeared in the contract. It was dropped deliberately; bringing it back restores a leg of the lethal trifecta (data + network + execution) on a skill that only writes text. If the tool IS needed IN SUBSTANCE — declare it in toolsNeeded WITH A REASON, not silently.",
132
+ "broken: dangling links → ./gone.md. A skill naming a path that does not exist is a dead instruction: the model reads it, does something adjacent and reports success.",
133
+ "broken: no compilation mark. That means SKILL.md was edited by hand rather than through .spec.ts — the next `vigiles compile` will overwrite the edit without warning.",
134
+ ]);
135
+ });
136
+
137
+ test("checkProseSkill: a tool exemption needs a reason, and an exemption for a tool not in the contract is stale", async () => {
138
+ writeTree(SKILLS, {
139
+ "needs-bash/notes.md": "notes\n",
140
+ "needs-bash/SKILL.md": PROSE()
141
+ .replace("name: prose", "name: needs-bash")
142
+ .replace("[Read, Write]", "[Read, Bash]"),
143
+ });
144
+ assert.deepEqual(
145
+ [
146
+ await thrown(() =>
147
+ checkProseSkill("needs-bash", { toolsNeeded: { Bash: "short" } }),
148
+ ),
149
+ await checkProseSkill("needs-bash", {
150
+ toolsNeeded: { Bash: "runs the measurement script it documents" },
151
+ }),
152
+ await thrown(() =>
153
+ checkProseSkill("prose", {
154
+ toolsNeeded: { Agent: "delegates the heavy read to a subagent" },
155
+ }),
156
+ ),
157
+ ],
158
+ [
159
+ "needs-bash: toolsNeeded.Bash is declared without an intelligible reason. An exemption must say WHY the tool is needed in substance — otherwise it is a silent disabling of the check.",
160
+ 1,
161
+ [
162
+ "prose: 1 findings",
163
+ "prose: toolsNeeded.Agent is declared, but Agent itself is NOT in the contract. The exemption is stale — remove it, otherwise it describes a different skill.",
164
+ ],
165
+ ],
166
+ );
167
+ });
168
+
169
+ test("checkProseSkill refuses a skill with no SKILL.md, an empty contract, and one that does not parse", async () => {
170
+ writeTree(SKILLS, {
171
+ "empty-tools/notes.md": "notes\n",
172
+ "bad-yaml/notes.md": "notes\n",
173
+ "empty-tools/SKILL.md": PROSE()
174
+ .replace("name: prose", "name: empty-tools")
175
+ .replace("allowed-tools: [Read, Write]", "allowed-tools: []"),
176
+ "bad-yaml/SKILL.md": PROSE()
177
+ .replace("name: prose", "name: bad-yaml")
178
+ .replace("allowed-tools: [Read, Write]", "allowed-tools: [Read, Write"),
179
+ });
180
+ assert.deepEqual(
181
+ [
182
+ await thrown(() => checkProseSkill("absent")),
183
+ await thrown(() => checkProseSkill("empty-tools")),
184
+ (await thrown(() => checkProseSkill("bad-yaml")))[0],
185
+ ],
186
+ [
187
+ `absent: no ${join(SKILLS, "absent", "SKILL.md")} — nothing to check`,
188
+ [
189
+ "empty-tools: 1 findings",
190
+ "empty-tools: empty allowed-tools — inherits everything",
191
+ ],
192
+ "bad-yaml: 2 findings",
193
+ ],
194
+ );
195
+ });
196
+
197
+ /** Rewrite a copied skill's SKILL.md with `edit`, run checkSkill, restore; the failure's first line. */
198
+ async function brokenBy(name, edit) {
199
+ const md = join(SKILLS, name, "SKILL.md");
200
+ const clean = readFileSync(md, "utf8");
201
+ writeFileSync(md, edit(clean));
202
+ try {
203
+ await checkSkill(name);
204
+ return null;
205
+ } catch (e) {
206
+ // The header, then the first 90 characters of each numbered finding.
207
+ const lines = e.message.split("\n");
208
+ return [
209
+ lines[0],
210
+ ...lines
211
+ .filter((l) => /^ \[\d+\] /.test(l))
212
+ .map((l) => l.trim().slice(0, 90)),
213
+ ];
214
+ } finally {
215
+ writeFileSync(md, clean);
216
+ }
217
+ }
218
+ const recordSection = /## Record the verdict[\s\S]*?(?=\n## |\n# |$)/;
219
+
220
+ test("checkSkill on the defects its own corpus never has: no description, no record block, a record block naming no kind", async () => {
221
+ assert.deepEqual(
222
+ [
223
+ await brokenBy("cold-read-diff", (s) =>
224
+ s.replace(/^description: .*\n/m, ""),
225
+ ),
226
+ await brokenBy("argument-arc", (s) =>
227
+ s.replace(
228
+ recordSection,
229
+ "## Record the verdict\n\nNothing named here.\n",
230
+ ),
231
+ ),
232
+ await brokenBy("draft-paper", (s) =>
233
+ s.replace(
234
+ recordSection,
235
+ "## Record the verdict\n\n**ABSTAINED** — and no reason is named.\n",
236
+ ),
237
+ ),
238
+ ],
239
+ [
240
+ [
241
+ '1 assertion(s) failed for pipeline skill "cold-read-diff":',
242
+ "[1] cold-read-diff: description is missing or trivially short (0 chars). The description i",
243
+ ],
244
+ [
245
+ '3 assertion(s) failed for pipeline skill "argument-arc":',
246
+ '[1] argument-arc: the "Record the verdict" block instructs no `node .claude/skills/paper-p',
247
+ "[2] argument-arc: its record block names [nothing] and not FINDING, and does not admit tha",
248
+ "[3] argument-arc: its record block names no abstention reason from [started, no-witness, i",
249
+ ],
250
+ [
251
+ '5 assertion(s) failed for pipeline skill "draft-paper":',
252
+ '[1] draft-paper: the "Record the verdict" block instructs no `node .claude/skills/paper-pi',
253
+ "[2] draft-paper: its record block names [ABSTAINED] and not FINDING, and does not admit th",
254
+ "[3] draft-paper: its record block names no abstention reason from [started, no-witness, in",
255
+ "[4] draft-paper: the honest admission is gone from its record block. It is the one skill i",
256
+ "[5] ABSTAINED needs a reason from started|no-witness|input-missing|blocked|crashed, got nu",
257
+ ],
258
+ ],
259
+ );
260
+ });
261
+
262
+ test("a skill may show `ledger.mjs status` — only a `record` call must file under the skill", async () => {
263
+ assert.equal(
264
+ await brokenBy("cold-read-diff", (s) =>
265
+ s.replace(
266
+ "node .claude/skills/paper-pipeline/scripts/announce.mjs cold-read-diff <paper-dir>",
267
+ "node .claude/skills/paper-pipeline/scripts/announce.mjs cold-read-diff <paper-dir>\n" +
268
+ "node .claude/skills/paper-pipeline/scripts/ledger.mjs status <paper-dir>",
269
+ ),
270
+ ),
271
+ null,
272
+ );
273
+ });
274
+
275
+ test("a skill whose only ledger call is `status` records nothing, and is told so", async () => {
276
+ assert.deepEqual(
277
+ await brokenBy("cold-read-diff", (s) =>
278
+ s.replaceAll(
279
+ /ledger\.mjs record cold-read-diff <paper-dir> \w+[^\n]*/g,
280
+ "ledger.mjs status <paper-dir>",
281
+ ),
282
+ ),
283
+ [
284
+ '1 assertion(s) failed for pipeline skill "cold-read-diff":',
285
+ '[1] cold-read-diff: the "Record the verdict" block instructs no `node .claude/skills/paper',
286
+ ],
287
+ );
288
+ });
289
+
290
+ test("the record CALL is the contract, not the heading: a renamed record section still passes", async () => {
291
+ assert.equal(
292
+ await brokenBy("camera-ready", (s) =>
293
+ s.replace("## Record the verdict", "## Something else"),
294
+ ),
295
+ null,
296
+ );
297
+ });
@@ -86,9 +86,9 @@ export const GATE_SKILLS = new Set(
86
86
  // command and is in neither list. That is DELIBERATE (see EXCLUDED), but nothing checked that the
87
87
  // deliberateness was still true, and the old comment asserting it was the only thing holding it.
88
88
  //
89
- // The mutation driver's case 12b passed the whole time, because it removes a skill from EXPECTED_GATES
90
- // while leaving it in the pinned array. A green mutation proving a narrower property than its own
91
- // error message claims is exactly the vacuity this file was built to hunt, sitting in this file.
89
+ // A defect that removes a skill from EXPECTED_GATES while leaving it in the pinned array passed
90
+ // silently the whole time. A check proving a narrower property than its own error message claims
91
+ // is exactly the vacuity this file was built to hunt, sitting in this file.
92
92
  //
93
93
  // The old comment's objection to globbing was real and is answered rather than ignored: `.claude/skills/`
94
94
  // does hold daily skills (search-krisha, food-log-entry, …) with no pipeline contract, so the filter
@@ -127,14 +127,15 @@ export const EXCLUDED = new Map([
127
127
  // defects this file hunts, and a defect that shrinks the checked set is worse than the defect itself:
128
128
  // the harness goes green by no longer looking.
129
129
  //
130
- // Both narrower versions were written and both were killed by the mutation driver within minutes:
130
+ // Both narrower versions were written and both failed within minutes, on the same two planted
131
+ // defects:
131
132
  //
132
- // filter `announce.mjs <own name>` -> case 13 (typo the announced NAME) dropped harden-paper out
133
+ // filter `announce.mjs <own name>` -> a typo in the announced NAME dropped harden-paper out
133
134
  // of scope; harness printed "20 pipeline skills", said nothing
134
- // filter `announce.mjs` path -> case 5b (typo the announce PATH) dropped camera-ready the
135
+ // filter `announce.mjs` path -> a typo in the announce PATH dropped camera-ready the
135
136
  // same way
136
137
  //
137
- // Each time, the mutation that used to turn the harness red went GREEN because the planted defect
138
+ // Each time, the same planted defect that used to turn the harness red went GREEN because it
138
139
  // erased its own subject. Scope decided by a property the defect corrupts is not scope.
139
140
  //
140
141
  // The union cannot be shrunk by a single-point edit: break the wiring and the gate table still holds
@@ -395,9 +396,6 @@ export const load = (name) => {
395
396
  // the header of `skill-checks.mjs`). The real, declared carrier of this check is `checkSkill()`,
396
397
  // which does it one skill at a time and with a legible message.
397
398
  //
398
- // `ONLY` stays: it is read by the check one line up — the only thing here that survived.
399
- const ONLY = process.env.PIPELINE_SKILL_ONLY ?? "";
400
- if (ONLY && !SKILLS.includes(ONLY))
401
- throw new Error(
402
- `PIPELINE_SKILL_ONLY="${ONLY}" is not a wired pipeline skill. Known: ${SKILLS.join(", ")}`,
403
- );
399
+ // `PIPELINE_SKILL_ONLY` used to stay here, validated against SKILLS. Nothing read the variable
400
+ // any more — the validation was all that was left of the feature — so it was removed with it
401
+ // (2026-09-27).
@@ -0,0 +1,54 @@
1
+ /**
2
+ * skill-corpus.mjs's pure readers on the inputs the shipped corpus never contains: frontmatter
3
+ * that is not a mapping or not YAML, an `allowed-tools` that is absent, a skill with no record
4
+ * block and one whose record block ends the file.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { test } from "vitest";
8
+ import { parseFm, recordBlock, toolList } from "./skill-corpus.mjs";
9
+
10
+ const refusal = (fn) => {
11
+ try {
12
+ fn();
13
+ return null;
14
+ } catch (e) {
15
+ return e.message.split(". Almost")[0];
16
+ }
17
+ };
18
+
19
+ test("parseFm refuses a list, a bare string and broken YAML, naming where", () => {
20
+ assert.deepEqual(
21
+ [
22
+ refusal(() => parseFm("- a\n- b", "x/SKILL.md")),
23
+ refusal(() => parseFm("just words")),
24
+ refusal(() => parseFm("a: b: c")),
25
+ ],
26
+ [
27
+ "x/SKILL.md: frontmatter is not valid YAML — parsed to a list, not a mapping",
28
+ "frontmatter: frontmatter is not valid YAML — parsed to string, not a mapping",
29
+ "frontmatter: frontmatter is not valid YAML — bad indentation of a mapping entry (1:5)",
30
+ ],
31
+ );
32
+ });
33
+
34
+ test("toolList of nothing is an empty contract, and a flow list or a comma scalar is split", () => {
35
+ assert.deepEqual(
36
+ [
37
+ toolList(undefined),
38
+ toolList("[Bash, Read]"),
39
+ toolList(" Read , Write "),
40
+ toolList(["Read ", ""]),
41
+ ],
42
+ [[], ["Bash", "Read"], ["Read", "Write"], ["Read"]],
43
+ );
44
+ });
45
+
46
+ test("recordBlock: none without the heading; to the end of the file when it is the last section", () => {
47
+ assert.deepEqual(
48
+ [
49
+ recordBlock("# S\n\n## Run me\n\nx\n"),
50
+ recordBlock("# S\n\n## Record the verdict\n\nlast\n"),
51
+ ],
52
+ [null, "## Record the verdict\n\nlast\n"],
53
+ );
54
+ });
@@ -14,7 +14,6 @@
14
14
  * be holding them itself".
15
15
  *
16
16
  * Run: `npx vigiles test lib/skill-trigger-cases.harness.mjs`
17
- * Killed by: `lib/skill-trigger-cases.mutations.mjs`
18
17
  */
19
18
  import assert from "node:assert/strict";
20
19
  import { execFileSync } from "node:child_process";
@@ -41,10 +40,10 @@ const soft = (label, fn) => {
41
40
  * (top-level await) — a second import in this process would be served from the module cache and
42
41
  * would measure the first root again while appearing to measure the second.
43
42
  */
44
- function loadAt(root) {
43
+ function loadAt(root, then = `console.log("OK " + m.CASES.length)`) {
45
44
  const script =
46
45
  `import("${join(HERE, "skill-trigger-cases.mjs").replaceAll("\\", "/")}")` +
47
- `.then((m) => console.log("OK " + m.CASES.length))` +
46
+ `.then((m) => { ${then}; })` +
48
47
  `.catch((e) => { console.log("THROW " + String(e.message).split("\\n")[0]); });`;
49
48
  return execFileSync(process.execPath, ["--input-type=module", "-e", script], {
50
49
  cwd: root,
@@ -160,12 +159,66 @@ soft("II. a consumer may not redefine a case this package owns", () => {
160
159
  );
161
160
  });
162
161
 
162
+ soft(
163
+ "II. a declaration that is not a non-empty string THROWS, naming what it got",
164
+ () => {
165
+ for (const [declared, shown] of [
166
+ ["", '""'],
167
+ [7, "7"],
168
+ ]) {
169
+ const out = loadAt(fixture({ triggerCases: declared }, null));
170
+ assert.equal(
171
+ out,
172
+ `THROW paperlint: "triggerCases" must be a non-empty string, got ${shown}`,
173
+ );
174
+ }
175
+ },
176
+ );
177
+
178
+ soft(
179
+ "II. a default-location file that does not load THROWS, saying it is the default",
180
+ () => {
181
+ for (const broken of [
182
+ "export const CASES = [\n",
183
+ 'throw "not an Error";\n',
184
+ ]) {
185
+ const out = loadAt(fixture(null, broken));
186
+ assert.match(out, /^THROW .*could not load the consumer's trigger cases/);
187
+ assert.match(
188
+ out,
189
+ /\(the default location; declare another in paperlint\.json/,
190
+ );
191
+ }
192
+ },
193
+ );
194
+
195
+ soft("II. a cases file whose CASES is not an array THROWS", () => {
196
+ const out = loadAt(fixture(null, "export const CASES = {};\n"));
197
+ assert.match(out, /^THROW .* must export an array named CASES$/);
198
+ });
199
+
200
+ soft(
201
+ "II. irrelevantFor refuses an unknown skill, and a collider that names one",
202
+ () => {
203
+ assert.throws(
204
+ () => irrelevantFor("no-such-skill"),
205
+ /^Error: unknown skill: no-such-skill$/,
206
+ );
207
+ const ghost = `export const CASES = [{ skill: "local-x", why: "x", prompts: ["a","b","c","d"], collides: ["ghost"] }];\n`;
208
+ const out = loadAt(
209
+ fixture(null, ghost),
210
+ `m.irrelevantFor("local-x"); console.log("OK")`,
211
+ );
212
+ assert.equal(out, 'THROW local-x collides with unknown skill "ghost"');
213
+ },
214
+ );
215
+
163
216
  if (failures.length) {
164
217
  console.error(`${failures.length} assertion(s) failed:`);
165
218
  for (const f of failures) console.error(` ✗ ${f}`);
166
219
  process.exit(1);
167
220
  }
168
221
  console.log(
169
- `trigger-case carrier: ${CASES.length} package case(s), consumer merge in four states ✓`,
222
+ `trigger-case carrier: ${CASES.length} package case(s), consumer merge in every state ✓`,
170
223
  );
171
224
  recordCheck();
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
9
  "prepare": "tsc",
10
- "test": "node scripts/exclusive.mjs vitest run && node scripts/exclusive.mjs vigiles test --min=1",
11
- "lint": "node scripts/exclusive.mjs eslint .",
10
+ "test": "node scripts/test.mjs",
11
+ "coverage": "NODE_OPTIONS=\"--import=./test/coverage-src.mjs $NODE_OPTIONS\" c8 --check-coverage npm test --",
12
+ "lint": "eslint .",
12
13
  "check": "node scripts/check.mjs",
13
- "test:e2e": "node scripts/exclusive.mjs node test/e2e/install.mjs && node scripts/exclusive.mjs node test/e2e/build.mjs",
14
+ "test:e2e": "node test/e2e/install.mjs && node test/e2e/build.mjs",
14
15
  "fmt": "prettier --write .",
15
16
  "fmt:check": "prettier --check ."
16
17
  },
@@ -33,6 +34,7 @@
33
34
  "@semantic-release/commit-analyzer": "13.0.1",
34
35
  "@semantic-release/release-notes-generator": "14.1.1",
35
36
  "@types/node": "^26.6.1",
37
+ "c8": "^12.0.0",
36
38
  "conventional-changelog-conventionalcommits": "9.3.1",
37
39
  "eslint-plugin-boundaries": "^7.2.0",
38
40
  "eslint-plugin-n": "^18.3.0",
@@ -75,7 +77,6 @@
75
77
  "CLAUDE.md",
76
78
  "CONTRIBUTING.md",
77
79
  "action.harness.mjs",
78
- "action.mutations.mjs",
79
80
  "action.yml",
80
81
  "bin",
81
82
  "dist",
@@ -14,11 +14,11 @@
14
14
  * ⚠️ Assertions at the TOP LEVEL: `vigiles test` imports the file and counts "did not throw"
15
15
  * as a pass.
16
16
  */
17
- import assert from "node:assert/strict";
18
17
  import { existsSync, readdirSync, readFileSync } from "node:fs";
19
18
  import { dirname, join } from "node:path";
20
19
  import { fileURLToPath } from "node:url";
21
20
  import { load } from "js-yaml";
21
+ import { createChecker } from "../lib/check.mjs";
22
22
 
23
23
  const HERE = dirname(fileURLToPath(import.meta.url));
24
24
  const ROOT = dirname(HERE);
@@ -26,11 +26,7 @@ const ROOT = dirname(HERE);
26
26
  const { GATES, NOT_COVERED, outcome, SKIP_EXIT, commandOf } =
27
27
  await import("./check.mjs");
28
28
 
29
- let n = 0;
30
- const check = (label, cond) => {
31
- n++;
32
- assert.ok(cond, label);
33
- };
29
+ const check = createChecker();
34
30
 
35
31
  // ── the workflow is the ORACLE, not a copy of it ───────────────────────────────────────────
36
32
  // A declared skip is a THIRD outcome. Folding it into "pass" printed "all gates passed" on a
@@ -135,9 +131,7 @@ for (const g of GATES) {
135
131
  // every program other than `node` must be installed in node_modules/.bin.
136
132
  const argv = commandOf(g);
137
133
  const files = argv.filter((a) => /\.m?js$/.test(a));
138
- const programs = argv.filter(
139
- (a, i) => i === 0 || argv[i - 1] === "scripts/exclusive.mjs",
140
- );
134
+ const programs = [argv[0]];
141
135
  // Guards: a gate runs SOMETHING — a script of ours, or an installed program such as `tsc`.
142
136
  check(
143
137
  `gate «${g.name}» runs files that exist (${files.join(", ")})`,
@@ -172,6 +166,6 @@ check(
172
166
  );
173
167
 
174
168
  console.log(
175
- `✓ ${String(n)} assertions passed — npm run check: ${GATES.length} gates, ` +
169
+ `✓ ${String(check.count)} assertions passed — npm run check: ${GATES.length} gates, ` +
176
170
  `${ciJobs.length} CI job(s) all accounted for`,
177
171
  );