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,337 +0,0 @@
1
- /**
2
- * Mutation battery for the three shipped hooks and their tool.
3
- * Run: `node hooks/hooks.mutations.mjs` (not `vigiles test` — this is not a harness).
4
- *
5
- * 🔴 EVERY MUTATION CARRIES A "THE PATCH LANDED" ASSERTION: the file is re-read FROM DISK, the
6
- * mutant must be in it, and after the rollback the contents must return byte for byte. A green
7
- * mutation in this repository has repeatedly meant not "the defence holds" but "the patch never
8
- * landed" / "the test is looking somewhere else".
9
- *
10
- * WHAT A GREEN HARNESS UNDER A MUTATION MEANS: a finding about the TEST, not a conclusion about
11
- * the defence. Rank the causes in this order — the test greps PROSE ABOUT a value instead of the
12
- * value · the test looks in the wrong place · the test is the wrong test · the patch did not
13
- * land · the set is too narrow · the run was already red BEFORE the mutation. The last is closed
14
- * by the baseline run below.
15
- *
16
- * ⚠️ WHY THESE HOOKS ARE WORTH A BATTERY MORE THAN MOST. Two of the three are ADVISORY — silence
17
- * is their success state — and the third is a `PreToolUse` gate whose failure mode is to refuse
18
- * EVERYTHING. In both directions the broken state resembles the working one from outside: a
19
- * silent nudge looks like a clean edit, and a gate that refuses everything looks like a gate
20
- * doing its job until you notice `echo hi` is also blocked. The harness is the only instrument
21
- * that tells them apart, so the harness itself needs one.
22
- *
23
- * 🔴 ONE MUTATION IS DELIBERATELY ABSENT, and naming it is part of the record: replacing
24
- * `require.resolve("<this package>/hooks/paper-status-gates.sh")` with a hard-coded
25
- * `node_modules/<name>/hooks/…` path is NOT KILLABLE here, because the harness's fixture
26
- * consumer installs this package at exactly that path (a symlink, which is what `npm install`
27
- * of a local package produces). The mutant would be wrong for a consumer using an install alias
28
- * or a pnpm layout, and right for every fixture. Rather than assert it with a test that cannot
29
- * fail, it is left out and written down.
30
- *
31
- * 🔴 A SECOND ABSENCE, and this one CHANGED THE SOURCE. The nudge used to normalise the papers
32
- * root to one trailing slash, with a comment claiming `under()` needed it. The mutation that
33
- * removed the normalise left the harness GREEN — and the cause was none of the six ranked above:
34
- * the CLAIM was false. vigiles normalises a prefix itself (`normalizePrefix` →
35
- * `trimTrailingSeparators`), so the line defended nothing. It was deleted rather than given a
36
- * test, because a line no mutation can kill is a line documenting a defence that does not exist.
37
- * The guard keeps ITS normalise — that one hand-writes `startsWith(p + "/")` and is killable,
38
- * and the mutation for it is below.
39
- */
40
- import { readFileSync, writeFileSync } from "node:fs";
41
- import { execFileSync } from "node:child_process";
42
-
43
- const HARNESS = "hooks/hooks.harness.mjs";
44
- const GUARD = "hooks/paper-edit-guard.hook.mjs";
45
- const NUDGE = "hooks/paper-skills-nudge.hook.mjs";
46
- const GATES = "hooks/paper-status-gates.hook.mjs";
47
- const SH = "hooks/paper-status-gates.sh";
48
-
49
- // Coverage mode — see `scripts/run-mutations.mjs` guard 2. Says what this battery can kill,
50
- // without touching a file.
51
- if (process.env.MUTATIONS_REPORT_COVERAGE) {
52
- console.log(`MUTATION-COVERS\t${HARNESS}`);
53
- process.exit(0);
54
- }
55
-
56
- /** [file, label, what it would break, from, to] */
57
- const M = [
58
- // ── the carrier, in the GATE, where an unreadable root must REFUSE ──────────
59
- [
60
- GUARD,
61
- "guard carrier/null read as absence",
62
- 'go back to `??` — an explicit `"papersDir": null` is read as «nothing was declared» and ' +
63
- "silently falls back to the default, i.e. a typed keystroke treated as an absence",
64
- ' const root = declared === undefined ? DEFAULT_PAPERS_ROOT : declared;\n if (typeof root !== "string" || root.length === 0)\n return deny(',
65
- ' const root = declared ?? DEFAULT_PAPERS_ROOT;\n if (typeof root !== "string" || root.length === 0)\n return deny(',
66
- ],
67
- [
68
- GUARD,
69
- "guard carrier/empty root accepted",
70
- "accept any string including the empty one — every prefix test becomes vacuous and the gate " +
71
- "passes every paper write while still looking installed",
72
- ' if (typeof root !== "string" || root.length === 0)\n return deny(',
73
- ' if (typeof root !== "string")\n return deny(',
74
- ],
75
- [
76
- GUARD,
77
- "guard carrier/parse failure DEFAULTS instead of denying",
78
- "treat an unreadable paperlint.json as «nothing declared» — the measured failure: the provider " +
79
- "hands back the EMPTY STRING in silence, so the gate quietly re-roots at the default",
80
- " } catch {\n return deny(\n `${CONFIG_KEY}: this gate could not read",
81
- " } catch {\n config = {};\n void deny(\n `${CONFIG_KEY}: this gate could not read",
82
- ],
83
- [
84
- GUARD,
85
- "guard carrier/trailing slash not normalised",
86
- 'keep the slash the consumer typed — `prefix + "/"` becomes `//`, which matches NOTHING, ' +
87
- "and the gate is a silent no-op (this exact defect shipped once)",
88
- ' return root.replace(/\\/+$/, "");\n};',
89
- " return root;\n};",
90
- ],
91
- // ── the guard's four measured bypasses ──────────────────────────────────────
92
- [
93
- GUARD,
94
- "guard/redirection targets invisible",
95
- "stop scanning redirections — `echo x > paper.md` is the canonical write and its target is " +
96
- "not an argv token, so `touches` cannot see it",
97
- " const hits = writesViaArgv || redirectsInto(e.command.raw, PAPER_SOURCES);",
98
- " const hits = writesViaArgv;",
99
- ],
100
- [
101
- GUARD,
102
- "guard/quoted path deleted instead of unquoted",
103
- "go back to DELETING quoted spans — a quoted path vanishes before it can be matched, and the " +
104
- "gate was one quote from open",
105
- ' .replace(/\'([^\']*)\'/g, "$1")\n .replace(/"((?:[^"\\\\]|\\\\.)*)"/g, "$1");',
106
- ' .replace(/\'([^\']*)\'/g, "")\n .replace(/"((?:[^"\\\\]|\\\\.)*)"/g, "");',
107
- ],
108
- [
109
- GUARD,
110
- "guard/the mutator list loses `sed -i`",
111
- "drop the commonest in-place editor from MUTATORS — the write is no longer recognised as a " +
112
- "write, so `sed -i` on a paper sails past the gate that exists for it",
113
- 'const MUTATORS = [\n "sed -i",\n',
114
- "const MUTATORS = [\n",
115
- ],
116
- [
117
- GUARD,
118
- "guard/the whole subtree guarded, not just sources",
119
- "drop the extension filter — copying a script into a paper's repro bundle is denied, which is " +
120
- "ordinary work being refused, and a guard that refuses ordinary work gets muted",
121
- // 🔴 The target moved on 09-16: `isPaperSource` was split into "not a snapshot" AND "has a
122
- // source extension" once `versions/` got its carve-out. This mutation targets exactly the
123
- // EXTENSION FILTER — removing the carve-out is a different property with its own mutation
124
- // below.
125
- 'const hasSourceExtension = (p) =>\n p.endsWith(".tex") ||\n /\\/(paper|draft)\\.md$/.test(p) ||\n /^(paper|draft)\\.md$/.test(p);',
126
- "const hasSourceExtension = (p) => p.length > 0;",
127
- ],
128
- [
129
- GUARD,
130
- "guard/manifest read goes relative again",
131
- "drop the anchor to the project root — the read fails once cwd drifts, and under the rule " +
132
- '"an unreadable declaration denies" the guard blocks ANY Bash command, including the one ' +
133
- "that would fix it",
134
- 'const READ_CONFIG = `f="\\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}";',
135
- 'const READ_CONFIG = `f="${CONFIG_FILE}";',
136
- ],
137
- [
138
- "hooks/paper-skills-nudge.hook.mjs",
139
- "nudge/manifest read goes relative again",
140
- "the same defect in the SIBLING, and the failure there is the OPPOSITE one: a PostToolUse " +
141
- "hook does not deny, it returns `nothing()` — i.e. it just quietly stops firing. Silence " +
142
- "is exactly what a nudge's success state looks like, so a dead hook is indistinguishable " +
143
- "from a working one except by this mutation",
144
- 'const READ_CONFIG = `f="\\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}";',
145
- 'const READ_CONFIG = `f="${CONFIG_FILE}";',
146
- ],
147
- [
148
- GUARD,
149
- "guard/versions/ carve-out stops exempting",
150
- "remove the exemption for frozen snapshots — an archival record under `versions/` is denied " +
151
- "again, and there is nothing left to restore the paper's source from: the gate locks down " +
152
- "an immutable artifact while leaving the live `paper.tex` it exists to protect open",
153
- "const isPaperSource = (p) => !isFrozenSnapshot(p) && hasSourceExtension(p);",
154
- "const isPaperSource = (p) => hasSourceExtension(p);",
155
- ],
156
- [
157
- GUARD,
158
- "guard/versions/ carve-out becomes WIDER than it should",
159
- "declare anything a snapshot — the carve-out swallows the live `paper.tex`, and the guard " +
160
- "stops being a guard while still looking installed. Paired with the previous one: one " +
161
- "proves the exemption EXISTS, the other proves it does not overreach into what it " +
162
- "protects",
163
- "const isFrozenSnapshot = (p) => /(^|\\/)versions\\/[^/]+$/.test(p);",
164
- "const isFrozenSnapshot = () => true;",
165
- ],
166
- [
167
- GUARD,
168
- "guard/the read-vs-write conjunct dropped",
169
- "remove `runsMutator` — `cat <root>/x/paper.tex` touches a paper and names a paper source, so " +
170
- "a plain READ is denied (this happened, and it cost real work)",
171
- " runsMutator(e.command) &&\n (e.command.writesTo(PAPER_SOURCES)",
172
- " (e.command.writesTo(PAPER_SOURCES)",
173
- ],
174
- // ── the nudge: it must LAND, and only for a paper ───────────────────────────
175
- [
176
- NUDGE,
177
- "nudge/fires on everything",
178
- "drop the path test — the checklist lands on every Edit anywhere, which is how an advisory " +
179
- "hook gets muted and then stays muted when it matters",
180
- " return e.path.under([root]) && isPaperSource(e.path.raw)\n ? notice(CHECKLIST)\n : nothing();",
181
- " return notice(CHECKLIST);",
182
- ],
183
- [
184
- NUDGE,
185
- "nudge/every file under the root is a paper",
186
- "drop the source-shape test — a README or a note under the papers root gets the pre-submit " +
187
- "checklist",
188
- " return e.path.under([root]) && isPaperSource(e.path.raw)\n ? notice(CHECKLIST)\n : nothing();",
189
- " return e.path.under([root])\n ? notice(CHECKLIST)\n : nothing();",
190
- ],
191
- [
192
- NUDGE,
193
- "nudge carrier/null read as absence",
194
- "`??` again, in this hook's own copy of the carrier — the nudge starts firing about the " +
195
- "default tree the consumer never named",
196
- ' const root = declared === undefined ? DEFAULT_PAPERS_ROOT : declared;\n if (typeof root !== "string" || root.length === 0) return null;',
197
- ' const root = declared ?? DEFAULT_PAPERS_ROOT;\n if (typeof root !== "string" || root.length === 0) return null;',
198
- ],
199
- // ── the gates hook: anchors, escaping, and the name that reaches a shell ────
200
- [
201
- GATES,
202
- "gates/the root is not regex-escaped",
203
- "interpolate the declared root raw — a `.` in it becomes a wildcard, so the hook fires about " +
204
- "a sibling tree whose name merely resembles the declared one",
205
- '`(?:^|/)${root.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}/([A-Za-z0-9._-]+)/',
206
- "`(?:^|/)${root}/([A-Za-z0-9._-]+)/",
207
- ],
208
- [
209
- GATES,
210
- "gates/the `(?:^|/)` boundary removed",
211
- "anchor with a bare `^` — the live harness sends ABSOLUTE paths, so the hook would be dead in " +
212
- "production and green in any test that builds relative ones",
213
- " `(?:^|/)${root.replace(",
214
- " `^${root.replace(",
215
- ],
216
- [
217
- GATES,
218
- "gates/the directory charclass widened",
219
- "accept any non-slash directory name — a name carrying `;` or a space reaches a `run()` " +
220
- "command string, and `run()` goes through a shell",
221
- "/([A-Za-z0-9._-]+)/(?:[^/]*",
222
- "/([^/]+)/(?:[^/]*",
223
- ],
224
- [
225
- GATES,
226
- "gates carrier/null read as absence",
227
- "`??` in the third copy of the carrier — the hook surfaces a status file from the default tree",
228
- ' const root = declared === undefined ? DEFAULT_PAPERS_ROOT : declared;\n if (typeof root !== "string" || root.length === 0) return null;',
229
- ' const root = declared ?? DEFAULT_PAPERS_ROOT;\n if (typeof root !== "string" || root.length === 0) return null;',
230
- ],
231
- // ── the tool ───────────────────────────────────────────────────────────────
232
- [
233
- SH,
234
- "sh/the papers root hard-coded again",
235
- "put the literal back — the tool serves exactly one consumer's layout, which is the thing the " +
236
- "extraction exists to undo",
237
- 'papers_root="$(read_key papersDir papers)"',
238
- 'papers_root="papers"',
239
- ],
240
- [
241
- SH,
242
- "sh/the defence-in-depth name check removed",
243
- "trust the hook's validation — the file stops being safe to call directly, and it is a " +
244
- "documented entry point",
245
- ' *[!A-Za-z0-9._-]* | "" | . | ..) exit 0 ;;',
246
- ' "") exit 0 ;;',
247
- ],
248
- [
249
- SH,
250
- "sh/answers to being called as a hook",
251
- "drop the usage branch — the file silently does nothing when misused instead of saying it is " +
252
- "a tool, which is how it was misregistered as a hook before",
253
- ' echo "paper-status-gates.sh: a tool, not a hook. Use --surface <paper-dir>." >&2',
254
- " : >&2",
255
- ],
256
- ];
257
-
258
- const runHarness = () => {
259
- try {
260
- execFileSync("npx", ["vigiles", "test", HARNESS], {
261
- encoding: "utf8",
262
- stdio: ["ignore", "pipe", "pipe"],
263
- });
264
- return { failed: false, out: "" };
265
- } catch (e) {
266
- return { failed: true, out: (e.stdout ?? "") + (e.stderr ?? "") };
267
- }
268
- };
269
-
270
- // 🔴 A BASELINE RUN BEFORE ANY MUTATION. A red harness makes EVERY mutation look "killed", and
271
- // the battery would print a confident green verdict about a defence that is not there.
272
- {
273
- const base = runHarness();
274
- if (base.failed) {
275
- console.log(
276
- "❌ THE HARNESS IS RED BEFORE ANY MUTATION — the battery cannot tell a killed mutation " +
277
- "from that:\n" +
278
- base.out.slice(-1500),
279
- );
280
- process.exit(1);
281
- }
282
- }
283
-
284
- let ok = 0;
285
- let bad = 0;
286
- const rows = [];
287
- for (const [file, label, what, from, to] of M) {
288
- const pristine = readFileSync(file, "utf8");
289
- const hits = pristine.split(from).length - 1;
290
- if (hits !== 1) {
291
- console.log(
292
- `❌ ${label}: TARGET ${hits === 0 ? "NOT FOUND" : `NOT UNIQUE (${hits})`} in ${file} — ${from.slice(0, 70)}`,
293
- );
294
- bad++;
295
- continue;
296
- }
297
- writeFileSync(file, pristine.replace(from, to));
298
- const on = readFileSync(file, "utf8");
299
- if (!on.includes(to) || on.includes(from)) {
300
- console.log(
301
- `❌ ${label}: THE MUTATION DID NOT LAND (checked by re-reading ${file})`,
302
- );
303
- bad++;
304
- writeFileSync(file, pristine);
305
- continue;
306
- }
307
- const { failed, out } = runHarness();
308
- writeFileSync(file, pristine);
309
- if (readFileSync(file, "utf8") !== pristine) {
310
- console.log(`❌ ${label}: THE ROLLBACK FAILED on ${file}`);
311
- bad++;
312
- continue;
313
- }
314
- // The verdict carries the assertion text: "killed" without saying by what is half an answer.
315
- const at = (out.match(/hooks\.harness\.mjs:(\d+)/) ?? [])[1];
316
- const msg =
317
- (out.match(/AssertionError[^:]*: ([^\n]+)/) ?? [])[1] ??
318
- (out.match(/((?:Error|ENOENT|SyntaxError)[^\n]*)/) ?? [])[1] ??
319
- "";
320
- const where = (at ? `harness:${at} · ` : "") + msg;
321
- if (failed) {
322
- ok++;
323
- rows.push([label, what, where.trim().slice(0, 130)]);
324
- } else {
325
- console.log(
326
- `🔴 ${label}: THE HARNESS IS GREEN UNDER THE MUTATION — a finding about the TEST, not a ` +
327
- `conclusion about the defence`,
328
- );
329
- bad++;
330
- }
331
- }
332
- console.log("\n| property | mutation | what the harness died on |");
333
- console.log("|---|---|---|");
334
- for (const [a, b, c] of rows)
335
- console.log(`| \`${a}\` | ${b} | ${c.replace(/\|/g, "\\|")} |`);
336
- console.log(`\n${ok} mutation(s) killed, ${bad} problem(s)`);
337
- process.exit(bad ? 1 : 0);
@@ -1,109 +0,0 @@
1
- /**
2
- * Battery for `observeAgentCli`. Five mutations, five different assertions.
3
- *
4
- * 🔴 The first one is the issue #7 defect itself, restored verbatim: bring back
5
- * `stdio: "ignore"`. It is the whole point of this battery. Before the observation was pulled
6
- * out into its own module, there was NOWHERE to place this mutation: the spawn was baked into
7
- * the harness, which brings up the real CLI, and "ask the binary to print nothing" is not
8
- * something you can do. A green battery against a baked-in spawn would have been a finding
9
- * about the test, not about the defense.
10
- */
11
- import { fileURLToPath } from "node:url";
12
- import { dirname, join, resolve } from "node:path";
13
- import { runMutations } from "./mutation-driver.mjs";
14
-
15
- const HERE = dirname(fileURLToPath(import.meta.url));
16
- const ROOT = resolve(HERE, "..");
17
- const MOD = join(HERE, "agent-cli-version.mjs");
18
- const HARNESS = join(HERE, "agent-cli-version.harness.mjs");
19
-
20
- process.exit(
21
- runMutations({
22
- root: ROOT,
23
- runner: "node",
24
- cases: [
25
- {
26
- name: '🔴 revert to `stdio: "ignore"` — the issue #7 defect verbatim',
27
- harness: HARNESS,
28
- // ⚠️ The assertion this trips is NOT the one I was aiming for, and this was corrected
29
- // BY RUNNING IT, not from memory. The double hands back stdout regardless of the
30
- // options, so the observation succeeds, and the first thing to go red is the
31
- // `encoding` assertion. The `stdio` assertion itself is hit by the next case.
32
- expect: "without an encoding the output is a Buffer nobody reads",
33
- disables:
34
- "the observation itself — the version is printed into the void again, and the tier characterizes nothing",
35
- edits: [
36
- [
37
- MOD,
38
- 'spawnSync(program, ["--version"], { encoding: "utf8" })',
39
- 'spawnSync(program, ["--version"], { stdio: "ignore" })',
40
- ],
41
- ],
42
- },
43
- {
44
- name: '`stdio: "ignore"` added ALONGSIDE encoding — output still goes nowhere',
45
- harness: HARNESS,
46
- expect: "is the discarded-output defect itself",
47
- disables:
48
- "the second call-shape assertion: `encoding` is present, but the stream is still killed",
49
- edits: [
50
- [
51
- MOD,
52
- 'spawnSync(program, ["--version"], { encoding: "utf8" })',
53
- 'spawnSync(program, ["--version"], { encoding: "utf8", stdio: "ignore" })',
54
- ],
55
- ],
56
- },
57
- {
58
- name: "empty stdout at exit code 0 stops being a failure",
59
- harness: HARNESS,
60
- expect: "must fail loudly, not be reported as a version",
61
- disables:
62
- "rule 4 — `exit 0` with empty output reads as a clean measurement again",
63
- edits: [[MOD, 'if (version === "")', "if (false)"]],
64
- },
65
- {
66
- name: "drift is computed as a constant, not a comparison",
67
- harness: HARNESS,
68
- expect: "must be computed, not eyeballed",
69
- disables:
70
- "the comparison of observed against characterized — the divergence becomes invisible again",
71
- edits: [
72
- [
73
- MOD,
74
- "const drifted = version !== characterized;",
75
- "const drifted = false;",
76
- ],
77
- ],
78
- },
79
- {
80
- name: "only one of the two numbers survives into the message",
81
- harness: HARNESS,
82
- expect: "must name BOTH numbers",
83
- disables:
84
- "showing the divergence — the reader is back to having to remember the second number",
85
- edits: [
86
- [
87
- MOD,
88
- "`observed \\`${program}\\` ${version} — the assertions here were characterized against ` +\n `${characterized}.",
89
- "`observed \\`${program}\\` ${version} — characterized elsewhere.",
90
- ],
91
- ],
92
- },
93
- {
94
- name: "the version is not parsed out of the string, taken whole instead",
95
- harness: HARNESS,
96
- expect: "must be PARSED out of the line, not left raw",
97
- disables:
98
- "parsing the version string — `2.1.273 (Claude Code)` stops being comparable to `2.1.227`",
99
- edits: [
100
- [
101
- MOD,
102
- 'const version = VERSION_IN.exec(raw)?.[1] ?? "";',
103
- "const version = raw;",
104
- ],
105
- ],
106
- },
107
- ],
108
- }),
109
- );
@@ -1,227 +0,0 @@
1
- /**
2
- * `runMutations` — three harness outcomes, not two.
3
- *
4
- * ── WHY THIS FILE SHOWED UP LAST, NOT FIRST ────────────────────────────
5
- * The engine that renders a verdict for EVERY battery in the repository was, until 2026-09-15,
6
- * checked by nothing. Every one of its defenses (a harness path pointing at nothing · a no-op
7
- * mutation · retrying a non-kill · a baseline of already-red harnesses) was written AFTER it
8
- * caught a real defect, and none of them had a test. So the next defect of the same class
9
- * arrived in CI and was read as "two mutations survived".
10
- *
11
- * The third outcome — SKIP — is pinned down here. The fixtures are real: the skipping harness
12
- * calls `skip()` FROM vigiles rather than faking it with exit code 77 by hand, because the
13
- * latter would test my mental model of the protocol instead of the protocol itself.
14
- */
15
- import assert from "node:assert/strict";
16
- import {
17
- mkdtempSync,
18
- writeFileSync,
19
- readFileSync,
20
- rmSync,
21
- realpathSync,
22
- } from "node:fs";
23
- import { tmpdir } from "node:os";
24
- import { join } from "node:path";
25
- import { createRequire } from "node:module";
26
- import { pathToFileURL } from "node:url";
27
- import { recordCheck } from "vigiles";
28
- import { runMutations } from "./mutation-driver.mjs";
29
-
30
- // The fixture lives in a temp directory where `node` will not find `vigiles` by walking up
31
- // the tree. The specifier is resolved HERE, by node's own resolver, and substituted in
32
- // absolute — that way the skip is done by a real `skip()`, not an imitation via exit code 77.
33
- // An imitation would test my mental model of the protocol instead of the protocol itself.
34
- const VIGILES = pathToFileURL(
35
- createRequire(import.meta.url).resolve("vigiles"),
36
- ).href;
37
-
38
- const SUBJECT = "export const MARKER = 'keep-me';\n";
39
- const cases = [];
40
-
41
- /** Temp root: a fixture subject + the harness that judges it. */
42
- function fixture(harnessBody) {
43
- const root = realpathSync(mkdtempSync(join(tmpdir(), "mutdrv-")));
44
- const subject = join(root, "subject.mjs");
45
- const harness = join(root, "probe.harness.mjs");
46
- writeFileSync(subject, SUBJECT);
47
- writeFileSync(harness, harnessBody(subject));
48
- return { root, subject, harness };
49
- }
50
-
51
- const quiet = { log: console.log, error: console.error };
52
- /** The verdict is read from the exit code and output, so the driver's output is intercepted. */
53
- function silently(fn) {
54
- const lines = [];
55
- console.log = console.error = (...a) => lines.push(a.join(" "));
56
- try {
57
- return { code: fn(), out: lines.join("\n") };
58
- } finally {
59
- console.log = quiet.log;
60
- console.error = quiet.error;
61
- }
62
- }
63
-
64
- // ── 1. THE HARNESS SKIPS ⇒ the battery REFUSES, rather than declaring the mutation survived.
65
- // Observed failure (run 34966606186): CI had no `claude` binary, the harness skipped,
66
- // `vigiles test` returned 0, and the driver printed "🔴 SURVIVED" — a claim about the TEST
67
- // that was really a claim about the ENVIRONMENT.
68
- {
69
- const { root, subject, harness } = fixture(
70
- () =>
71
- `import { skip } from ${JSON.stringify(VIGILES)};\nskip("fixture: the capability this tier observes is absent");\n`,
72
- );
73
- try {
74
- const before = readFileSync(subject, "utf8");
75
- const { code, out } = silently(() =>
76
- runMutations({
77
- root,
78
- runner: "node",
79
- cases: [
80
- {
81
- name: "marker removed",
82
- harness,
83
- expect: "MARKER",
84
- disables: "fixture",
85
- edits: [[subject, "keep-me", "gone"]],
86
- },
87
- ],
88
- }),
89
- );
90
- assert.equal(code, 2, `a skip must REFUSE, not judge; got ${code}\n${out}`);
91
- assert.doesNotMatch(
92
- out,
93
- /SURVIVED/,
94
- "and not claim a survived mutation — that's a false diagnosis",
95
- );
96
- assert.match(
97
- out,
98
- /SKIP on clean source/,
99
- "the refusal must name the cause",
100
- );
101
- assert.match(
102
- out,
103
- /probe\.harness\.mjs/,
104
- "and name WHICH harness is skipping",
105
- );
106
- assert.equal(
107
- readFileSync(subject, "utf8"),
108
- before,
109
- "the refusal must happen BEFORE any file is edited — the message arrives with a clean tree",
110
- );
111
- cases.push(
112
- "harness skips → refusal before editing files, no verdict about the mutation",
113
- );
114
- } finally {
115
- rmSync(root, { recursive: true, force: true });
116
- }
117
- }
118
-
119
- // ── 2. THE HARNESS WORKS ⇒ the prior behavior is intact. Without this half, case 1 is
120
- // indistinguishable from an engine that just always refuses.
121
- {
122
- const { root, subject, harness } = fixture(
123
- (s) =>
124
- `import assert from "node:assert/strict";\nimport { readFileSync } from "node:fs";\nassert.match(readFileSync(${JSON.stringify(s)}, "utf8"), /keep-me/, "MARKER is gone");\n`,
125
- );
126
- try {
127
- const { code, out } = silently(() =>
128
- runMutations({
129
- root,
130
- runner: "node",
131
- cases: [
132
- {
133
- name: "marker removed",
134
- harness,
135
- expect: "MARKER is gone",
136
- disables: "fixture",
137
- edits: [[subject, "keep-me", "gone"]],
138
- },
139
- ],
140
- }),
141
- );
142
- assert.equal(
143
- code,
144
- 0,
145
- `a working harness must kill the mutation; got ${code}\n${out}`,
146
- );
147
- assert.match(
148
- out,
149
- /RED \(expected case\)/,
150
- "and kill it with ITS OWN message, not just any red",
151
- );
152
- assert.equal(
153
- readFileSync(subject, "utf8"),
154
- SUBJECT,
155
- "the source must be restored",
156
- );
157
- cases.push(
158
- "harness works → mutation killed by its own assertion, source restored",
159
- );
160
- } finally {
161
- rmSync(root, { recursive: true, force: true });
162
- }
163
- }
164
-
165
- // ── 3. THE PATCH NEVER LANDED ⇒ that is NOT a survived mutation. The third outcome, and until
166
- // 09-17 the engine dumped it into the second one: anything that was not `RED (expected` counted
167
- // as survived — including NOT FOUND, AMBIGUOUS and NO-OP, where the file was never edited at all.
168
- //
169
- // 🔴 The two verdicts send the reader in OPPOSITE directions: survived means STRENGTHEN THE
170
- // TEST, unusable means FIX THE MUTATION. Observed the same day: a `find` string went stale
171
- // after the rule was rewritten, the engine reported "1 mutation survived", and the next step
172
- // would have been rewriting a harness that was fine.
173
- {
174
- const { root, subject, harness } = fixture(
175
- (s) =>
176
- `import assert from "node:assert/strict";\nimport { readFileSync } from "node:fs";\nassert.match(readFileSync(${JSON.stringify(s)}, "utf8"), /keep-me/, "MARKER is gone");\n`,
177
- );
178
- try {
179
- const { code, out } = silently(() =>
180
- runMutations({
181
- root,
182
- runner: "node",
183
- cases: [
184
- {
185
- name: "stale target",
186
- harness,
187
- expect: "MARKER is gone",
188
- disables: "fixture",
189
- // The string is not in the file — exactly the case of a stale target.
190
- edits: [[subject, "no-such-string-anywhere", "x"]],
191
- },
192
- ],
193
- }),
194
- );
195
- assert.equal(
196
- code,
197
- 1,
198
- `an unusable mutation must REFUSE the run; got ${code}\n${out}`,
199
- );
200
- assert.match(
201
- out,
202
- /UNUSABLE/,
203
- "the verdict must be named unusable, not survived",
204
- );
205
- assert.match(
206
- out,
207
- /NOT FOUND/,
208
- "and name the CAUSE — what exactly was not found",
209
- );
210
- assert.doesNotMatch(
211
- out,
212
- /mutation\(s\) survived/,
213
- "and must NOT claim a survived mutation: that would send someone to fix a working test",
214
- );
215
- cases.push(
216
- 'patch never landed → UNUSABLE with a cause, not "mutation survived"',
217
- );
218
- } finally {
219
- rmSync(root, { recursive: true, force: true });
220
- }
221
- }
222
-
223
- // Report to the tool, or the surface counts as UNCOVERED despite a working test —
224
- // "0 checks" and "no test" look identical in the ledger.
225
- recordCheck(cases.length);
226
- console.log(`mutation-driver: ${cases.length} outcome(s) pinned down:`);
227
- for (const c of cases) console.log(` ok ${c}`);