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
package/scripts/check.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * ── WHY IT EXISTS, AND IT IS A MEASURED FAILURE, NOT A TIDINESS IDEA ───────────────────────
6
6
  * On 2026-09-19 a change to `paper/research-question` was pushed that broke the test suite.
7
7
  * The gates were run afterwards — `lint`, `check:readme`, `check:globs`, `check:content-only`,
8
- * and the rule's own mutation battery, all green — but `npm test` and `test:install` were not,
8
+ * and the rule's own (since-removed) mutation battery, all green — but `npm test` and `test:install` were not,
9
9
  * because there were eleven separate scripts and no way to run them except from memory. Five
10
10
  * callers across two files had been left behind by the change, and the suite said so; nobody
11
11
  * asked it.
@@ -37,6 +37,7 @@ import { spawnSync } from "node:child_process";
37
37
  import { existsSync } from "node:fs";
38
38
  import { delimiter, dirname, join } from "node:path";
39
39
  import { fileURLToPath } from "node:url";
40
+ import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
40
41
 
41
42
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
42
43
 
@@ -52,8 +53,6 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
52
53
  * Programs installed by npm (`vigiles`, `eslint`) are found because `node_modules/.bin` is put
53
54
  * first on PATH below, the same way `npm run` does it.
54
55
  */
55
- const locked = (...cmd) => ["node", "scripts/exclusive.mjs", ...cmd];
56
-
57
56
  export const GATES = [
58
57
  {
59
58
  name: "the package compiles",
@@ -70,7 +69,7 @@ export const GATES = [
70
69
  {
71
70
  name: "skills lint, and the vigiles marks in README.md and CLAUDE.md",
72
71
  job: "gates",
73
- run: locked("vigiles", "lint", ".", "README.md"),
72
+ run: ["vigiles", "lint", ".", "README.md"],
74
73
  // README.md is passed to `vigiles lint` by name: it is not an instruction file, so vigiles
75
74
  // would not open it on its own. The marks tie the `paperlint init` and `paperlint new` sections to the
76
75
  // functions that implement them, and the lint fails when either function is renamed.
@@ -85,7 +84,7 @@ export const GATES = [
85
84
  {
86
85
  name: "every declared rule is enabled for a file on disk",
87
86
  job: "gates",
88
- run: locked("node", "scripts/rules-see-files.mjs"),
87
+ run: ["node", "scripts/rules-see-files.mjs"],
89
88
  },
90
89
  {
91
90
  name: "rules read content, not the filesystem",
@@ -96,12 +95,15 @@ export const GATES = [
96
95
  // locally buys a defect that reaches review.
97
96
  reason:
98
97
  "source-only property — identical in every environment, so CI adds nothing",
99
- run: locked("node", "scripts/rules-are-content-only.mjs"),
98
+ run: ["node", "scripts/rules-are-content-only.mjs"],
100
99
  },
101
100
  {
102
- name: "every test — vitest over *.test.ts, then the vigiles harnesses (npm test)",
101
+ name: "every test, under coverage — vitest, then the vigiles harnesses, with c8's thresholds (npm run coverage)",
103
102
  job: "gates",
104
- script: "test",
103
+ script: "coverage",
104
+ // `npm run coverage` IS `npm test`, run under c8 with `--check-coverage` (.c8rc.json holds
105
+ // the thresholds). One run, not two: the tests pass and the coverage floor holds, or the gate
106
+ // is red and says which.
105
107
  },
106
108
  {
107
109
  // vitest transpiles without type-checking, so the tests' types are checked here.
@@ -109,40 +111,30 @@ export const GATES = [
109
111
  job: "gates",
110
112
  run: ["tsc", "-p", "tsconfig.test.json"],
111
113
  },
112
- {
113
- name: "mutation batteries are frozen — none new, none grown (#52)",
114
- job: "gates",
115
- run: locked("node", "scripts/mutation-batteries-frozen.mjs"),
116
- },
117
114
  {
118
115
  name: "legacy layer exemptions are frozen — none new, none grown (#76)",
119
116
  job: "gates",
120
- run: locked("node", "scripts/layer-legacy-frozen.mjs"),
121
- },
122
- {
123
- name: "mutation batteries — every guard is killed by its own assertion",
124
- job: "gates",
125
- run: locked("node", "scripts/run-mutations.mjs"),
117
+ run: ["node", "scripts/layer-legacy-frozen.mjs"],
126
118
  },
127
119
  {
128
120
  name: "install e2e — pack, install under npm and pnpm, run the binary",
129
121
  job: "gates",
130
- run: locked("node", "test/e2e/install.mjs"),
122
+ run: ["node", "test/e2e/install.mjs"],
131
123
  },
132
124
  {
133
125
  name: "build e2e — a real pdflatex, and the PDF's fonts are measured",
134
126
  job: "build-e2e",
135
- run: locked("node", "test/e2e/build.mjs"),
127
+ run: ["node", "test/e2e/build.mjs"],
136
128
  },
137
129
  {
138
130
  name: "banal e2e — the real banal on pdf.js-written XML gives banal-on-pdftohtml's numbers",
139
131
  job: "build-e2e",
140
- run: locked("node", "test/e2e/banal.mjs"),
132
+ run: ["node", "test/e2e/banal.mjs"],
141
133
  },
142
134
  {
143
135
  name: "toolchain e2e — real TeX Live into $PAPERLINT_TEXLIVE_DIR, then a build with only it on PATH",
144
136
  job: "build-e2e",
145
- run: locked("node", "test/e2e/toolchain.mjs"),
137
+ run: ["node", "test/e2e/toolchain.mjs"],
146
138
  },
147
139
  ];
148
140
 
@@ -170,8 +162,7 @@ export const NOT_COVERED = {
170
162
  };
171
163
 
172
164
  /**
173
- * Exit 77 means "declared skip" — the same code vigiles' runner uses (`SKIP_EXIT_CODE`), and the
174
- * one lib/mutation-driver.mjs already recognizes. The e2e steps exit 77 when a tool they need is
165
+ * Exit 77 means "declared skip" — the same code vigiles' runner uses (`SKIP_EXIT_CODE`). The e2e steps exit 77 when a tool they need is
175
166
  * absent and --strict is off. Anything else nonzero is a failure.
176
167
  *
177
168
  * 🔴 A SKIP IS A THIRD OUTCOME. Until the Codex review on #45 the e2e steps exited 0 on a skip,
@@ -185,17 +176,29 @@ export function outcome(status) {
185
176
  return "fail";
186
177
  }
187
178
 
188
- if (import.meta.url === `file://${process.argv[1]}`) {
179
+ /**
180
+ * Run every gate in order and print the verdict and THE TAIL. `spawn`, `log`, `err` and `write`
181
+ * are injected so a test can drive every outcome without running eleven real gates.
182
+ *
183
+ * @returns the exit code: 1 when a gate failed, else 0 (a skip is not a failure, and says so).
184
+ */
185
+ export function runGates({
186
+ gates = GATES,
187
+ spawn = spawnSync,
188
+ log = console.log,
189
+ err = console.error,
190
+ write = (s) => process.stdout.write(s),
191
+ } = {}) {
189
192
  const BIN_FIRST_PATH = [join(ROOT, "node_modules", ".bin"), process.env.PATH]
190
193
  .filter(Boolean)
191
194
  .join(delimiter);
192
195
  const failed = [];
193
196
  const skipped = [];
194
197
 
195
- for (const g of GATES) {
196
- process.stdout.write(`── ${g.name}\n`);
198
+ for (const g of gates) {
199
+ write(`── ${g.name}\n`);
197
200
  const [cmd, ...args] = commandOf(g);
198
- const r = spawnSync(cmd, args, {
201
+ const r = spawn(cmd, args, {
199
202
  cwd: ROOT,
200
203
  stdio: "inherit",
201
204
  encoding: "utf8",
@@ -211,29 +214,29 @@ if (import.meta.url === `file://${process.argv[1]}`) {
211
214
  else failed.push(`${g.name} (${shown} → ${r.status})`);
212
215
  }
213
216
 
214
- console.log("");
217
+ log("");
215
218
  if (failed.length) {
216
- console.error("🔴 gates failed:");
217
- for (const f of failed) console.error(` ${f}`);
219
+ err("🔴 gates failed:");
220
+ for (const f of failed) err(` ${f}`);
218
221
  } else {
219
- const passed = GATES.length - skipped.length;
220
- console.log(
222
+ const passed = gates.length - skipped.length;
223
+ log(
221
224
  `✓ ${passed} gate(s) passed${skipped.length ? `, ${skipped.length} SKIPPED — not run, not passed` : ""}`,
222
225
  );
223
226
  }
224
- if (skipped.length) for (const s of skipped) console.log(`⏳ skipped: ${s}`);
227
+ for (const s of skipped) log(`⏳ skipped: ${s}`);
225
228
 
226
229
  // THE TAIL. Not decoration — the reason this file exists rather than a list in a doc.
227
- console.log("\nWhat this command does NOT cover:");
230
+ log("\nWhat this command does NOT cover:");
228
231
  for (const [job, why] of Object.entries(NOT_COVERED)) {
229
- console.log(` CI job «${job}» — ${why}`);
232
+ log(` CI job «${job}» — ${why}`);
230
233
  }
231
- const localOnly = GATES.filter((g) => g.job === null);
232
- for (const g of localOnly) {
233
- console.log(` (and «${g.name}» runs ONLY here — ${g.reason})`);
234
+ for (const g of gates.filter((x) => x.job === null)) {
235
+ log(` (and «${g.name}» runs ONLY here — ${g.reason})`);
234
236
  }
235
-
236
- process.exit(failed.length ? 1 : 0);
237
+ return failed.length ? 1 : 0;
237
238
  }
238
239
 
240
+ if (isMain(import.meta.url)) process.exit(runGates());
241
+
239
242
  export { ROOT, existsSync, join };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * check.mjs's runGates — the one command run before a push — on every outcome, with an injected
3
+ * spawn: a pass, a declared skip (77) and a failure, and THE TAIL that names what it does not cover.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { test } from "vitest";
9
+ import { GATES, NOT_COVERED, runGates } from "./check.mjs";
10
+
11
+ const capture = () => {
12
+ const out = [];
13
+ const io = { log: (s) => out.push(s), err: (s) => out.push(`E ${s}`) };
14
+ return { out, io };
15
+ };
16
+
17
+ test("runGates: a pass, a declared skip (77) and a failure, then the tail naming what is not covered", () => {
18
+ const gates = [
19
+ { name: "passes", job: "gates", run: ["true"] },
20
+ { name: "skips", job: "gates", run: ["skipper", "--x"] },
21
+ {
22
+ name: "fails",
23
+ job: null,
24
+ reason: "runs only here, a reason long enough",
25
+ run: ["false"],
26
+ },
27
+ ];
28
+ const status = { true: 0, skipper: 77, false: 3 };
29
+ const written = [];
30
+ const { out, io } = capture();
31
+ const code = runGates({
32
+ gates,
33
+ spawn: (cmd) => ({ status: status[cmd] }),
34
+ write: (s) => written.push(s),
35
+ ...io,
36
+ });
37
+ assert.deepEqual(
38
+ { code, written, out },
39
+ {
40
+ code: 1,
41
+ written: ["── passes\n", "── skips\n", "── fails\n"],
42
+ out: [
43
+ "",
44
+ "E 🔴 gates failed:",
45
+ "E fails (false → 3)",
46
+ "⏳ skipped: skips (skipper --x → 77)",
47
+ "\nWhat this command does NOT cover:",
48
+ ...Object.entries(NOT_COVERED).map(
49
+ ([job, why]) => ` CI job «${job}» — ${why}`,
50
+ ),
51
+ " (and «fails» runs ONLY here — runs only here, a reason long enough)",
52
+ ],
53
+ },
54
+ );
55
+ });
56
+
57
+ test("runGates: all passing, with a skip, says how many passed and that the skip is not a pass", () => {
58
+ const { out, io } = capture();
59
+ const code = runGates({
60
+ gates: [
61
+ { name: "a", job: "gates", script: "build" },
62
+ { name: "b", job: "gates", run: ["skipper"] },
63
+ ],
64
+ spawn: (cmd, args) => ({
65
+ status: cmd === "npm" && args[2] === "build" ? 0 : 77,
66
+ }),
67
+ write: () => {},
68
+ ...io,
69
+ });
70
+ assert.deepEqual(
71
+ [code, out[1], out[2]],
72
+ [
73
+ 0,
74
+ "✓ 1 gate(s) passed, 1 SKIPPED — not run, not passed",
75
+ "⏳ skipped: b (skipper → 77)",
76
+ ],
77
+ );
78
+ assert.ok(GATES.length > 0);
79
+ });
80
+
81
+ test("run as a program with every gate passing: the plain verdict, exit 0", () => {
82
+ // spawnSync is replaced before check.mjs loads, so the real file runs end to end — every gate
83
+ // "passes" in microseconds instead of the eleven real ones running.
84
+ const allPass = [
85
+ 'import cp from "node:child_process";',
86
+ 'import { syncBuiltinESMExports } from "node:module";',
87
+ "cp.spawnSync = () => ({ status: 0 });",
88
+ "syncBuiltinESMExports();",
89
+ ].join("\n");
90
+ const r = spawnSync(
91
+ process.execPath,
92
+ [
93
+ "--import",
94
+ `data:text/javascript,${encodeURIComponent(allPass)}`,
95
+ fileURLToPath(new URL("./check.mjs", import.meta.url)),
96
+ ],
97
+ { encoding: "utf8" },
98
+ );
99
+ assert.equal(r.status, 0, r.stderr);
100
+ assert.match(
101
+ r.stdout,
102
+ new RegExp(`\\n✓ ${String(GATES.length)} gate\\(s\\) passed\\n`),
103
+ );
104
+ });