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
@@ -1,110 +0,0 @@
1
- /**
2
- * Battery for `npm run check` — and the thing under test is NOT "does it run every gate".
3
- * That part is visible: you watch it happen. What is invisible, and therefore what this kills,
4
- * is whether the harness beside it can still tell when the gate list has fallen behind CI.
5
- *
6
- * 🔴 The failure mode this whole file exists against, measured 2026-09-19: a list of gates kept
7
- * by hand becomes a fossil the day after it is written. `check.harness.mjs` is the mechanism that
8
- * stops that — it reads job names out of the workflow rather than trusting a copy — and its
9
- * success state is silence. "It passed" and "it cannot fail" look identical from outside.
10
- *
11
- * Each case removes ONE property and names the assertion that must notice.
12
- */
13
- import { fileURLToPath } from "node:url";
14
- import { dirname, join, resolve } from "node:path";
15
- import { runMutations } from "../lib/mutation-driver.mjs";
16
-
17
- const HERE = dirname(fileURLToPath(import.meta.url));
18
- const ROOT = resolve(HERE, "..");
19
- const CHECK = join(HERE, "check.mjs");
20
- const HARNESS = join(HERE, "check.harness.mjs");
21
-
22
- process.exit(
23
- runMutations({
24
- root: ROOT,
25
- runner: "node",
26
- cases: [
27
- {
28
- name: "a CI job stops being accounted for — the tail goes quiet about its own boundary",
29
- harness: HARNESS,
30
- // No job name here: the harness reads every workflow in directory order, so which job
31
- // fails first depends on file names, not on what this mutation proves.
32
- expect:
33
- "is either reproduced by a gate or named in NOT_COVERED with a reason",
34
- disables:
35
- "the reason this command is not just a shell alias. Emptying NOT_COVERED removes the " +
36
- "one sentence that tells a reader what the green does NOT mean — and a check silent " +
37
- "about its boundary reads as complete, which is the counter-that-counts-what-it-ignores " +
38
- "class this repository keeps re-finding",
39
- edits: [
40
- [
41
- CHECK,
42
- "export const NOT_COVERED = {",
43
- "export const NOT_COVERED = { }; const _unused = {",
44
- ],
45
- ],
46
- },
47
- {
48
- name: "a gate names a CI job that does not exist — coverage on paper, none in fact",
49
- harness: HARNESS,
50
- expect: "names a job that really exists in a workflow",
51
- disables:
52
- "the link between the list and the workflow. A dead job name is WORSE than a missing " +
53
- "one: it reads as coverage and delivers nothing, and nothing else in the repository " +
54
- "would ever contradict it",
55
- // 🔴 The anchor is a gate whose job is ALSO claimed by other gates (`gates`), and that
56
- // is not incidental. Renaming a solely-claimed job — `build-e2e` — kills the FIRST
57
- // assertion instead, because that job becomes uncovered before anyone asks whether the
58
- // new name exists. Measured: the driver reported "RED but a DIFFERENT case". Two
59
- // assertions overlapping is fine; a battery that cannot tell them apart is not.
60
- edits: [
61
- [
62
- CHECK,
63
- 'job: "gates",\n run: locked("vigiles", "lint"',
64
- 'job: "gates-renamed-in-ci",\n run: locked("vigiles", "lint"',
65
- ],
66
- ],
67
- },
68
- {
69
- name: "a gate points at an npm script nobody can run",
70
- harness: HARNESS,
71
- expect: "maps to a script that exists",
72
- disables:
73
- "the guarantee that every listed gate is runnable. A renamed script fails at the exact " +
74
- "moment someone is trusting the list — which is the worst possible moment to find out",
75
- edits: [[CHECK, 'script: "fmt:check"', 'script: "fmt:check-renamed"']],
76
- },
77
- {
78
- name: "a gate runs a file that is not on disk",
79
- harness: HARNESS,
80
- expect: "runs files that exist",
81
- disables:
82
- "the same guarantee for gates that name their command directly instead of an npm " +
83
- "script. A moved or renamed file would only show up when someone runs the whole list",
84
- edits: [
85
- [
86
- CHECK,
87
- '"scripts/rules-are-content-only.mjs"',
88
- '"scripts/rules-are-content-only-renamed.mjs"',
89
- ],
90
- ],
91
- },
92
- {
93
- name: "a gate drops out of CI silently, with no reason given",
94
- harness: HARNESS,
95
- expect: "carries a reason",
96
- disables:
97
- "the cost of removing something from CI. Without this, `job: null` is the quiet way to " +
98
- "take a check out of the pipeline: the local run still shows it green and nothing says " +
99
- "it stopped being enforced anywhere else",
100
- edits: [
101
- [
102
- CHECK,
103
- 'reason:\n "source-only property',
104
- 'reasonRemoved:\n "source-only property',
105
- ],
106
- ],
107
- },
108
- ],
109
- }),
110
- );
@@ -1,138 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * MUTUAL EXCLUSION FOR RUNS THAT TOUCH THE SAME FILES.
4
- *
5
- * ── WHY ──────────────────────────────────────────────────────────────────────────────────────
6
- * The mutation batteries (`scripts/run-mutations.mjs`) edit the sources IN PLACE — a deliberate strategy, explained in the header of
7
- * `lib/mutation-driver.mjs`: nine cases rewrite the real file, and copying the whole repository
8
- * per mutation would mean minutes instead of seconds.
9
- *
10
- * The strategy has one cost: while the battery runs, ANY parallel `npm test` reads a file in its
11
- * mutilated state and fails — with a message about an assertion, not about a mutation. The
12
- * failure is FALSE, NON-DETERMINISTIC (it depends on which second you caught) and reads as "the
13
- * tests are flaky", i.e. it leads in exactly the direction where there is nothing to fix. This
14
- * has already cost an investigation: two runs in a row gave DIFFERENT error messages, and the
15
- * conclusion "I broke round-diff" was wrong.
16
- *
17
- * ── WHY A LOCK AND NOT AN INSTRUCTION "DON'T RUN THEM AT THE SAME TIME" ───────────────────────
18
- * The instruction already existed — as prose, in the consumer's CLAUDE.md. Prose does not
19
- * execute, so it is not checked and does not apply to anyone who has not read it: the person in
20
- * the other terminal, the agent, the editor with tests on save. A lock refuses LOUDLY and names
21
- * the reason at the moment it matters.
22
- *
23
- * ── HOW ─────────────────────────────────────────────────────────────────────────────────────
24
- * `mkdirSync` is atomic: either we created the directory or it already existed — there is no
25
- * race, unlike "check existsSync, then write". Inside it lie the holder's pid and command; if the
26
- * process with that pid is dead, the lock is taken over with an explicit message, otherwise an
27
- * interrupted run would block the repository forever, and the first cure would be "delete the
28
- * lock by hand", i.e. switching the mechanism off.
29
- *
30
- * The lock is released in `finally` AND on signals — for exactly the same reason the mutation
31
- * driver restores the sources on SIGINT/SIGTERM.
32
- */
33
- import {
34
- mkdirSync,
35
- writeFileSync,
36
- readFileSync,
37
- rmSync,
38
- existsSync,
39
- } from "node:fs";
40
- import { spawnSync } from "node:child_process";
41
- import { dirname, join } from "node:path";
42
- import { fileURLToPath } from "node:url";
43
-
44
- const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
45
- const LOCK = join(ROOT, ".vigiles", "exclusive.lock");
46
- const INFO = join(LOCK, "holder.json");
47
-
48
- const argv = process.argv.slice(2);
49
- if (argv.length === 0) {
50
- console.error("usage: node scripts/exclusive.mjs <command> [args…]");
51
- process.exit(2);
52
- }
53
-
54
- // The PARENT is created recursively; only the lock itself must be a non-recursive mkdir, because
55
- // that is the atomic step. Nothing else guarantees `.vigiles/` exists: once
56
- // `.vigiles/coverage.json` stopped being tracked, a fresh checkout has no such directory, and the
57
- // lock's mkdir failed with ENOENT before any gate ran (CI on e389916).
58
- mkdirSync(dirname(LOCK), { recursive: true });
59
-
60
- /** Is the process alive. `kill(pid, 0)` sends nothing — it only checks existence and rights. */
61
- function alive(pid) {
62
- try {
63
- process.kill(pid, 0);
64
- return true;
65
- } catch (e) {
66
- return e.code === "EPERM"; // exists but belongs to someone else — counted as alive
67
- }
68
- }
69
-
70
- function readHolder() {
71
- try {
72
- return JSON.parse(readFileSync(INFO, "utf8"));
73
- } catch {
74
- return null; // the lock exists, its description is unreadable — resolved below as "dead"
75
- }
76
- }
77
-
78
- function acquire() {
79
- try {
80
- mkdirSync(LOCK, { recursive: false });
81
- } catch (e) {
82
- if (e.code !== "EEXIST") throw e;
83
- const holder = readHolder();
84
- if (holder && alive(holder.pid)) {
85
- console.error(
86
- `🔴 refused: this repository is busy with a run that EDITS FILES IN PLACE.\n` +
87
- ` held by: pid ${holder.pid}, "${holder.cmd}", since ${holder.at}\n` +
88
- ` A parallel run would produce FALSE failures — a test would read the source in its\n` +
89
- ` mutilated state and complain about an assertion, not a mutation. Wait for it to finish.`,
90
- );
91
- process.exit(3);
92
- }
93
- console.error(
94
- `⚠️ the lock was left behind by a run that no longer exists` +
95
- (holder
96
- ? ` (pid ${holder.pid}, "${holder.cmd}")`
97
- : " (description unreadable)") +
98
- ` — taking it over.`,
99
- );
100
- rmSync(LOCK, { recursive: true, force: true });
101
- mkdirSync(LOCK, { recursive: false });
102
- }
103
- writeFileSync(
104
- INFO,
105
- JSON.stringify(
106
- { pid: process.pid, cmd: argv.join(" "), at: new Date().toISOString() },
107
- null,
108
- 2,
109
- ),
110
- );
111
- }
112
-
113
- const release = () => {
114
- if (existsSync(LOCK)) rmSync(LOCK, { recursive: true, force: true });
115
- };
116
-
117
- acquire();
118
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
119
- process.on(sig, () => {
120
- release();
121
- process.exit(sig === "SIGINT" ? 130 : 143);
122
- });
123
- }
124
-
125
- // 🔴 `process.on("exit")`, NOT `finally`, AND THIS IS NOT STYLE. The first edition released the
126
- // lock in `finally` — and it STAYED ON DISK: `process.exit()` terminates the process immediately,
127
- // the stack is not unwound, `finally` does not run. Caught by a run (the lock outlived a
128
- // successful launch), not by reading. The cost of the defect would have been exactly the inverse
129
- // of the intent: every run would leave the repository "busy", and the first cure would be "delete
130
- // the lock by hand", i.e. switching the mechanism off.
131
- process.on("exit", release);
132
-
133
- const r = spawnSync(argv[0], argv.slice(1), { stdio: "inherit", shell: false });
134
- if (r.error) {
135
- console.error(`🔴 failed to launch "${argv.join(" ")}": ${r.error.message}`);
136
- process.exit(127);
137
- }
138
- process.exit(r.status ?? 1);
@@ -1,204 +0,0 @@
1
- /**
2
- * Colocated test for `scripts/mutation-batteries-frozen.mjs` — the shrink-only guard (#52).
3
- * Run: `npx vigiles test scripts/mutation-batteries-frozen.harness.mjs`
4
- *
5
- * Both halves:
6
- * I. QUIET on this repository — every battery on disk is listed, every listed one exists,
7
- * and every count matches. Plus a non-vacuity row: the walk found batteries at all.
8
- * II. RED on a planted tree — a new battery, a grown one, a shrunk one, a stale list entry
9
- * and an uncountable table are each named; an unchanged battery beside them is not.
10
- * III. The case counter on each table shape the repository actually uses.
11
- *
12
- * ⚠️ Assertions at the TOP LEVEL: `vigiles test` imports the file and counts "did not throw"
13
- * as a pass.
14
- */
15
- import assert from "node:assert/strict";
16
- import {
17
- mkdirSync,
18
- mkdtempSync,
19
- realpathSync,
20
- rmSync,
21
- symlinkSync,
22
- writeFileSync,
23
- } from "node:fs";
24
- import { tmpdir } from "node:os";
25
- import { dirname, join } from "node:path";
26
- import { fileURLToPath } from "node:url";
27
-
28
- const HERE = dirname(fileURLToPath(import.meta.url));
29
- const ROOT = dirname(HERE);
30
- const { checkFrozen, countCases, findBatteries, FROZEN_FILE } = await import(
31
- join(HERE, "mutation-batteries-frozen.mjs")
32
- );
33
-
34
- let n = 0;
35
- const check = (label, cond, detail = "") => {
36
- n++;
37
- assert.ok(cond, detail ? `${label}\n${detail}` : label);
38
- };
39
-
40
- // ── I. quiet on this repository ─────────────────────────────────────────────────────────
41
- {
42
- const { problems, found, frozen } = checkFrozen(ROOT);
43
- check(
44
- "the walk finds batteries in this repository — otherwise the quiet below proves nothing",
45
- found.length > 0,
46
- );
47
- check(
48
- "this repository passes: nothing new, nothing grown, nothing stale",
49
- problems.length === 0,
50
- problems.join("\n"),
51
- );
52
- check(
53
- "the frozen list names exactly the batteries on disk",
54
- JSON.stringify(Object.keys(frozen).sort()) === JSON.stringify(found),
55
- );
56
- }
57
-
58
- // ── II. red on a planted tree ───────────────────────────────────────────────────────────
59
- const driverBattery = (k) =>
60
- `import { runMutations } from "./lib/mutation-driver.mjs";\n` +
61
- `process.exit(runMutations({ root: ".", cases: [${Array.from({ length: k }, (_, i) => `{ name: "c${String(i)}" }`).join(", ")}] }));\n`;
62
-
63
- const TMP = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-frozen-h-")));
64
- process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
65
- mkdirSync(join(TMP, "scripts"), { recursive: true });
66
- mkdirSync(join(TMP, "src"), { recursive: true });
67
- mkdirSync(join(TMP, "node_modules", "dep"), { recursive: true });
68
- writeFileSync(join(TMP, "src", "same.mutations.mjs"), driverBattery(3));
69
- writeFileSync(join(TMP, "src", "grown.mutations.mjs"), driverBattery(4));
70
- writeFileSync(join(TMP, "src", "shrunk.mutations.mjs"), driverBattery(1));
71
- writeFileSync(join(TMP, "src", "planted.mutations.mjs"), driverBattery(1));
72
- writeFileSync(
73
- join(TMP, "src", "opaque.mutations.mjs"),
74
- `const ALL = [{}, {}];\nrunMutations({ cases: ALL.filter(Boolean) });\n`,
75
- );
76
- // Not the repository's own files: a dependency, and a symlinked view of `src/`.
77
- writeFileSync(
78
- join(TMP, "node_modules", "dep", "theirs.mutations.mjs"),
79
- driverBattery(9),
80
- );
81
- symlinkSync(join(TMP, "src"), join(TMP, "view"), "dir");
82
- writeFileSync(
83
- join(TMP, FROZEN_FILE),
84
- JSON.stringify({
85
- batteries: {
86
- "src/same.mutations.mjs": { cases: 3 },
87
- "src/grown.mutations.mjs": { cases: 2 },
88
- "src/shrunk.mutations.mjs": { cases: 2 },
89
- "src/opaque.mutations.mjs": { cases: 2 },
90
- "src/gone.mutations.mjs": { cases: 5 },
91
- },
92
- }),
93
- );
94
- {
95
- const { problems, found } = checkFrozen(TMP);
96
- const about = (file) => problems.filter((p) => p.startsWith(`${file}:`));
97
- const all = problems.join("\n");
98
- check(
99
- "the walk skips node_modules and does not follow symlinks — each battery once",
100
- JSON.stringify(found) ===
101
- JSON.stringify([
102
- "src/grown.mutations.mjs",
103
- "src/opaque.mutations.mjs",
104
- "src/planted.mutations.mjs",
105
- "src/same.mutations.mjs",
106
- "src/shrunk.mutations.mjs",
107
- ]),
108
- JSON.stringify(found),
109
- );
110
- check(
111
- "🔴 a NEW battery fails, pointing at #52",
112
- about("src/planted.mutations.mjs").some((p) =>
113
- p.includes(
114
- "new hand-written mutation batteries are not accepted, see #52",
115
- ),
116
- ),
117
- all,
118
- );
119
- check(
120
- "🔴 a battery that GREW fails, naming both counts",
121
- about("src/grown.mutations.mjs").some(
122
- (p) => p.includes("4 cases, frozen at 2") && p.includes("#52"),
123
- ),
124
- all,
125
- );
126
- check(
127
- "🔴 a listed file that no longer exists fails: remove it from the frozen list",
128
- about("src/gone.mutations.mjs").some(
129
- (p) =>
130
- p.includes("remove it from") &&
131
- p.includes("the list must shrink with the code"),
132
- ),
133
- all,
134
- );
135
- check(
136
- "a battery that SHRANK fails until its recorded count is lowered to the new one",
137
- about("src/shrunk.mutations.mjs").some((p) =>
138
- p.includes('lower "cases" to 1'),
139
- ),
140
- all,
141
- );
142
- check(
143
- "a table the counter cannot read is a failure, never a guessed number",
144
- about("src/opaque.mutations.mjs").some((p) =>
145
- p.includes("cannot be counted"),
146
- ),
147
- all,
148
- );
149
- check(
150
- "control: an unchanged battery beside them is not reported",
151
- about("src/same.mutations.mjs").length === 0,
152
- all,
153
- );
154
- check(
155
- "exactly the five planted defects, nothing else",
156
- problems.length === 5,
157
- all,
158
- );
159
- }
160
-
161
- // ── III. the counter, on each table shape in use ────────────────────────────────────────
162
- const cases = (src) => countCases(src, "x.mutations.mjs");
163
- check("inline `cases: [...]` literal", cases(driverBattery(3)).count === 3);
164
- check(
165
- "`cases: TABLE.map(...)` counts TABLE",
166
- cases(
167
- `const T = [[1], [2], [3], [4]];\nrunMutations({ cases: T.map(([a]) => ({ a })) });`,
168
- ).count === 4,
169
- );
170
- check(
171
- "`const CASES = A.map(...)` then `cases: CASES`, and the shorthand `{ cases }`",
172
- cases(
173
- `const A = [1, 2];\nconst CASES = A.map((x) => x);\nrunMutations({ cases: CASES });`,
174
- ).count === 2 &&
175
- cases(`const cases = [1, 2, 3];\nrunMutations({ root, cases });`).count ===
176
- 3,
177
- );
178
- check(
179
- "a spread of another const table adds its length",
180
- cases(`const A = [1, 2];\nrunMutations({ cases: [...A, 3] });`).count === 3,
181
- );
182
- check(
183
- "a self-contained battery: the loop over M counts, the results loop over `rows = []` does not",
184
- cases(
185
- `const M = [["a"], ["b"], ["c"]];\nconst rows = [];\nfor (const [x] of M) rows.push(x);\nfor (const r of rows) console.log(r);`,
186
- ).count === 3,
187
- );
188
- check(
189
- "a file with no table is an error, not zero",
190
- "error" in cases(`console.log("hi");`),
191
- );
192
- check(
193
- "two driver calls are ambiguous, not summed",
194
- "error" in
195
- cases(`runMutations({ cases: [1] });\nrunMutations({ cases: [2] });`),
196
- );
197
- check(
198
- "findBatteries on this repository matches only `*.mutations.mjs` names",
199
- findBatteries(ROOT).every((f) => f.endsWith(".mutations.mjs")),
200
- );
201
-
202
- console.log(
203
- `✓ ${String(n)} assertions passed — mutation-batteries-frozen: quiet here, red on a new, grown, shrunk, stale or uncountable battery`,
204
- );
@@ -1,238 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * mutation-batteries-frozen.mjs — the hand-written mutation batteries may only SHRINK.
4
- *
5
- * Issue #52 retires the `*.mutations.mjs` pattern: string replacements of source lines, run
6
- * through `lib/mutation-driver.mjs`. The idea behind it stays (a test must be seen going red
7
- * when the code breaks); the vehicle is being replaced by a real mutation-testing tool, or by
8
- * nothing — that is decided in #52, not here. Until the last battery is gone, this check holds
9
- * the line so the pattern cannot grow back while it is being removed:
10
- *
11
- * 1. a `*.mutations.mjs` on disk that is not in the frozen list fails — a NEW battery;
12
- * 2. a listed file that no longer exists fails — the list must shrink with the code;
13
- * 3. a listed battery whose case table grew fails — no new cases in an old battery;
14
- * 4. a listed battery whose case table shrank fails until the recorded count is lowered —
15
- * otherwise a removed case leaves room for a different one to be added later.
16
- *
17
- * The frozen list and the counts live in `mutation-batteries.frozen.json`, next to this file.
18
- *
19
- * ── HOW CASES ARE COUNTED, AND WHY NOT BY IMPORTING ───────────────────────────────────────
20
- * A battery runs on import and ends with `process.exit`, so it cannot be imported to read its
21
- * table. The file is PARSED instead (TypeScript's parser, already a dependency) and the case
22
- * table is found structurally:
23
- *
24
- * - batteries on the shared driver pass `cases` to `runMutations({ ... })`;
25
- * - the older self-contained batteries loop `for (const [...] of M)` over a top-level table.
26
- *
27
- * The table expression is then resolved to an array literal: an identifier goes to its
28
- * top-level `const` initializer, `X.map(...)` goes to `X` (map keeps the length), and a spread
29
- * `...X` inside a literal adds X's count. Anything else — `.filter`, a function call, a table
30
- * built in a loop — is NOT guessed at: the battery is reported as uncountable, which fails the
31
- * check. A count that silently stood for something else would be the green zero this repo
32
- * keeps guarding against.
33
- *
34
- * Run: `node scripts/mutation-batteries-frozen.mjs` (also part of `npm run check`)
35
- */
36
- import { readFileSync, readdirSync, existsSync } from "node:fs";
37
- import { dirname, join, relative, sep } from "node:path";
38
- import { fileURLToPath } from "node:url";
39
- import ts from "typescript";
40
-
41
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
42
- export const FROZEN_FILE = join("scripts", "mutation-batteries.frozen.json");
43
-
44
- // Build output and dependencies are not the repository's own files. Symlinks are not followed:
45
- // `.claude/skills/<name>` points back into `skills/`, and following it would list every skill
46
- // battery twice under two spellings.
47
- const SKIP = new Set(["node_modules", ".git", "dist", "_build"]);
48
-
49
- /** Every `*.mutations.mjs` under `root`, as sorted POSIX-style repo-relative paths. */
50
- export function findBatteries(root) {
51
- const found = [];
52
- const walk = (dir) => {
53
- for (const e of readdirSync(dir, { withFileTypes: true })) {
54
- if (e.isSymbolicLink() || SKIP.has(e.name)) continue;
55
- const p = join(dir, e.name);
56
- if (e.isDirectory()) walk(p);
57
- else if (e.isFile() && e.name.endsWith(".mutations.mjs"))
58
- found.push(relative(root, p).split(sep).join("/"));
59
- }
60
- };
61
- walk(root);
62
- return found.sort();
63
- }
64
-
65
- /**
66
- * The number of cases in a battery's table, read from its source without running it.
67
- * Returns `{ count }` or `{ error }` — never a guess.
68
- */
69
- export function countCases(source, fileName = "battery.mjs") {
70
- const sf = ts.createSourceFile(
71
- fileName,
72
- source,
73
- ts.ScriptTarget.Latest,
74
- true,
75
- ts.ScriptKind.JS,
76
- );
77
-
78
- // Top-level `const NAME = <init>` — the only bindings a table name is resolved through.
79
- const consts = new Map();
80
- for (const st of sf.statements) {
81
- if (!ts.isVariableStatement(st)) continue;
82
- if (!(st.declarationList.flags & ts.NodeFlags.Const)) continue;
83
- for (const d of st.declarationList.declarations)
84
- if (ts.isIdentifier(d.name) && d.initializer)
85
- consts.set(d.name.text, d.initializer);
86
- }
87
-
88
- const tables = [];
89
- const visit = (node) => {
90
- if (
91
- ts.isCallExpression(node) &&
92
- ts.isIdentifier(node.expression) &&
93
- node.expression.text === "runMutations"
94
- ) {
95
- const arg = node.arguments[0];
96
- const prop =
97
- arg && ts.isObjectLiteralExpression(arg)
98
- ? arg.properties.find((p) => p.name?.getText(sf) === "cases")
99
- : undefined;
100
- if (prop && ts.isPropertyAssignment(prop)) tables.push(prop.initializer);
101
- else if (prop && ts.isShorthandPropertyAssignment(prop))
102
- tables.push(prop.name);
103
- else tables.push(null);
104
- }
105
- ts.forEachChild(node, visit);
106
- };
107
- visit(sf);
108
-
109
- const size = (expr, seen = new Set()) => {
110
- if (!expr) return null;
111
- while (
112
- ts.isParenthesizedExpression(expr) ||
113
- ts.isAsExpression?.(expr) ||
114
- ts.isSatisfiesExpression?.(expr)
115
- )
116
- expr = expr.expression;
117
- if (ts.isArrayLiteralExpression(expr)) {
118
- let n = 0;
119
- for (const el of expr.elements) {
120
- if (ts.isSpreadElement(el)) {
121
- const inner = size(el.expression, seen);
122
- if (inner === null) return null;
123
- n += inner;
124
- } else if (ts.isOmittedExpression(el)) return null;
125
- else n += 1;
126
- }
127
- return n;
128
- }
129
- if (ts.isIdentifier(expr)) {
130
- if (seen.has(expr.text) || !consts.has(expr.text)) return null;
131
- seen.add(expr.text);
132
- return size(consts.get(expr.text), seen);
133
- }
134
- if (
135
- ts.isCallExpression(expr) &&
136
- ts.isPropertyAccessExpression(expr.expression) &&
137
- expr.expression.name.text === "map"
138
- )
139
- return size(expr.expression.expression, seen);
140
- return null;
141
- };
142
-
143
- // The self-contained batteries: a top-level `for (... of TABLE)`. They also loop over a
144
- // results accumulator (`const rows = []; for (const r of rows) ...`), which is empty in the
145
- // source — so a candidate counts only when its table is non-empty.
146
- if (tables.length === 0)
147
- for (const st of sf.statements)
148
- if (ts.isForOfStatement(st) && (size(st.expression) ?? 0) > 0)
149
- tables.push(st.expression);
150
-
151
- if (tables.length !== 1)
152
- return {
153
- error:
154
- tables.length === 0
155
- ? "no case table found (neither `runMutations({ cases })` nor a top-level `for ... of` over a non-empty table)"
156
- : `${String(tables.length)} candidate case tables — which one counts is ambiguous`,
157
- };
158
-
159
- const count = size(tables[0]);
160
- return count === null
161
- ? {
162
- error: `the case table (${tables[0]?.getText(sf).slice(0, 60) ?? "?"}) is not an array literal this check can count`,
163
- }
164
- : { count };
165
- }
166
-
167
- /**
168
- * Compare what is on disk with the frozen list. Pure: every input is passed in.
169
- * `found` — paths from findBatteries; `frozen` — `{ path: { cases } }`;
170
- * `counted` — `{ path: { count } | { error } }` for the paths that exist.
171
- */
172
- export function judge({ found, frozen, counted }) {
173
- const problems = [];
174
- const listed = new Set(Object.keys(frozen));
175
- for (const f of found)
176
- if (!listed.has(f))
177
- problems.push(
178
- `${f}: new hand-written mutation batteries are not accepted, see #52. ` +
179
- `Record what a test guards as a comment above its assertion instead.`,
180
- );
181
- const onDisk = new Set(found);
182
- for (const f of listed)
183
- if (!onDisk.has(f))
184
- problems.push(
185
- `${f}: no longer exists — remove it from ${FROZEN_FILE} (the list must shrink with the code).`,
186
- );
187
- for (const f of found) {
188
- if (!listed.has(f)) continue;
189
- const want = frozen[f].cases;
190
- const got = counted[f];
191
- if (!got || "error" in got) {
192
- problems.push(
193
- `${f}: cases cannot be counted — ${got?.error ?? "not read"}. ` +
194
- `The table must stay an array literal (optionally through a const or .map) so it can only shrink.`,
195
- );
196
- continue;
197
- }
198
- if (got.count > want)
199
- problems.push(
200
- `${f}: ${String(got.count)} cases, frozen at ${String(want)} — ` +
201
- `no new cases in existing batteries, see #52.`,
202
- );
203
- else if (got.count < want)
204
- problems.push(
205
- `${f}: ${String(got.count)} cases, frozen at ${String(want)} — ` +
206
- `lower "cases" to ${String(got.count)} in ${FROZEN_FILE} (the list must shrink with the code).`,
207
- );
208
- }
209
- return problems;
210
- }
211
-
212
- /** Run the whole check against `root`. Returns the problems found. */
213
- export function checkFrozen(root = ROOT) {
214
- const data = JSON.parse(readFileSync(join(root, FROZEN_FILE), "utf8"));
215
- const frozen = data.batteries ?? {};
216
- const found = findBatteries(root);
217
- const counted = {};
218
- for (const f of found) {
219
- const p = join(root, f);
220
- if (existsSync(p)) counted[f] = countCases(readFileSync(p, "utf8"), f);
221
- }
222
- return { problems: judge({ found, frozen, counted }), found, frozen };
223
- }
224
-
225
- if (import.meta.url === `file://${process.argv[1]}`) {
226
- const { problems, found, frozen } = checkFrozen(ROOT);
227
- if (problems.length) {
228
- console.error(
229
- `🔴 mutation batteries are frozen and may only shrink (#52) — ${String(problems.length)} problem(s):`,
230
- );
231
- for (const p of problems) console.error(` ${p}`);
232
- process.exit(1);
233
- }
234
- const cases = Object.values(frozen).reduce((s, v) => s + v.cases, 0);
235
- console.log(
236
- `✓ ${String(found.length)} frozen mutation batteries, ${String(cases)} cases, none new, none grown (#52)`,
237
- );
238
- }