oxmutate 0.0.0 → 1.0.0

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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +376 -4
  3. package/binding/oxmutate.darwin-arm64.node +0 -0
  4. package/binding/oxmutate.darwin-x64.node +0 -0
  5. package/binding/oxmutate.linux-arm64-gnu.node +0 -0
  6. package/binding/oxmutate.linux-x64-gnu.node +0 -0
  7. package/binding/oxmutate.linux-x64-musl.node +0 -0
  8. package/binding/oxmutate.win32-x64-msvc.node +0 -0
  9. package/dist/bin.js +5 -0
  10. package/dist/checker.js +122 -0
  11. package/dist/child.js +125 -0
  12. package/dist/cli.js +165 -0
  13. package/dist/command-runner.js +83 -0
  14. package/dist/config.d.ts +141 -0
  15. package/dist/config.js +172 -0
  16. package/dist/coordinator.d.ts +83 -0
  17. package/dist/coordinator.js +653 -0
  18. package/dist/files.js +62 -0
  19. package/dist/frozen.d.ts +19 -0
  20. package/dist/frozen.js +47 -0
  21. package/dist/index.d.ts +8 -0
  22. package/dist/index.js +9 -0
  23. package/dist/lanes.d.ts +38 -0
  24. package/dist/lanes.js +92 -0
  25. package/dist/native.d.ts +36 -0
  26. package/dist/native.js +40 -0
  27. package/dist/platform.js +27 -0
  28. package/dist/progress.js +40 -0
  29. package/dist/reports.d.ts +20 -0
  30. package/dist/reports.js +225 -0
  31. package/dist/resolve.js +43 -0
  32. package/dist/runner.d.ts +49 -0
  33. package/dist/runner.js +270 -0
  34. package/dist/runtime.js +24 -0
  35. package/dist/scheduler.js +170 -0
  36. package/dist/spec-oracle.d.ts +69 -0
  37. package/dist/spec-oracle.js +68 -0
  38. package/dist/spec.d.ts +21 -0
  39. package/dist/spec.js +75 -0
  40. package/dist/vite-plugin.js +18 -0
  41. package/dist/vite.d.ts +16 -0
  42. package/dist/vite.js +3 -0
  43. package/package.json +81 -6
  44. package/index.js +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ofri Peretz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,7 +1,379 @@
1
- # oxmutate
1
+ <p align="center">
2
+ <a href="https://github.com/ofri-peretz/burgee/tree/main/packages/oxmutate" target="blank">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ofri-peretz/burgee/main/brand-assets/oxmutate-lockup.svg" />
5
+ <img src="https://raw.githubusercontent.com/ofri-peretz/burgee/main/brand-assets/oxmutate-lockup-light.svg" alt="oxmutate" width="360" />
6
+ </picture>
7
+ </a>
8
+ </p>
2
9
 
3
- Mutation testing for JavaScript and TypeScript, built on the [oxc](https://oxc.rs) engine.
10
+ <p align="center">
11
+ The test of the tests: mutation testing for JavaScript and TypeScript, on the oxc engine, inside your own Vitest.
12
+ </p>
4
13
 
5
- **In development.** This version only reserves the name; it has no functionality yet.
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/oxmutate"><img src="https://img.shields.io/npm/v/oxmutate?style=flat-square&color=0a6b47" alt="oxmutate on npm: the latest version" /></a>
16
+ <a href="https://www.npmjs.com/package/oxmutate"><img src="https://img.shields.io/npm/dm/oxmutate?style=flat-square" alt="oxmutate downloads per month on npm" /></a>
17
+ <a href="https://github.com/ofri-peretz/burgee/actions/workflows/quality.yml?query=branch%3Amain"><img src="https://img.shields.io/github/actions/workflow/status/ofri-peretz/burgee/quality.yml?branch=main&style=flat-square&label=Quality%20Gate" alt="Quality Gate: the CI status of main" /></a>
18
+ <a href="https://app.codecov.io/gh/ofri-peretz/burgee/components"><img src="https://img.shields.io/codecov/c/github/ofri-peretz/burgee/main?component=oxmutate&style=flat-square" alt="oxmutate line coverage: its Codecov component" /></a>
19
+ <a href="https://scorecard.dev/viewer/?uri=github.com/ofri-peretz/burgee"><img src="https://img.shields.io/ossf-scorecard/github.com/ofri-peretz/burgee?style=flat-square&label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard for the repository" /></a>
20
+ <a href="https://www.npmjs.com/package/oxmutate?activeTab=code"><img src="https://img.shields.io/npm/unpacked-size/oxmutate?style=flat-square" alt="Unpacked size of the latest oxmutate release on npm" /></a>
21
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/dependencies-2%20in%20family%2C%200%20outside-0a6b47?style=flat-square" alt="Two dependencies, all in the burgee family (bellpull, burgee), none outside it" /></a>
22
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/types-included-blue?style=flat-square" alt="TypeScript types included for every entry point" /></a>
23
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/Node.js-20.19%2B%20%7C%2022.13%2B-green?style=flat-square" alt="Node.js 20.19+ or 22.13+" /></a>
24
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=flat-square" alt="License: MIT" /></a>
25
+ <a href="https://www.npmjs.com/package/oxmutate#provenance"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.npmjs.org%2Foxmutate%2Flatest&query=%24.dist.attestations.provenance~&label=npm&style=flat-square&color=0a6b47" alt="npm provenance of the latest release, read live from its registry attestation" /></a>
26
+ </p>
6
27
 
7
- Community project, built on oxc, not affiliated with the oxc project.
28
+ <p align="center">
29
+ Docs: <a href="https://oxmutate.interlace.tools">https://oxmutate.interlace.tools</a><br />
30
+ Migrating from: <a href="https://oxmutate.interlace.tools/docs/coming-from/stryker">Stryker</a>
31
+ </p>
32
+
33
+ **oxmutate** is mutation testing for JavaScript and TypeScript, on the [oxc](https://oxc.rs)
34
+ engine. It changes your code one small step at a time (`a < b` becomes `a <= b`, a block becomes
35
+ empty, `true` becomes `false`), runs the tests that cover each change, and reports every change no
36
+ test notices: a **survivor**, a line your suite runs but does not check. Each survivor comes with
37
+ its file, its line, the change and a `fix:` line that says what to assert, as the same record for
38
+ a person, for `--json` and for a coding agent.
39
+
40
+ It reads your **Stryker** config as it stands and writes Stryker's report, so moving over is one
41
+ command: where you ran `npx stryker run`, run `npx oxmutate run`. A community project built on
42
+ oxc, not affiliated with the oxc project.
43
+
44
+ ## Why oxmutate
45
+
46
+ | | Stryker 10 | oxmutate 1.0 |
47
+ | :-- | :-- | :-- |
48
+ | Mutant set | 17 mutator families | the same 17, the same mutants (720 of 720 on es-toolkit) |
49
+ | Verdicts | — | the same as Stryker's on 99.9% of mutants ([measured](#compatibility)) |
50
+ | Instrumentation | Babel, file by file | Rust on oxc, every file in parallel: 720 mutants in 35 ms |
51
+ | Test runs | a Vitest run per mutant | each covering test re-run per mutant inside a warm Vitest worker |
52
+ | Config | `stryker.config.*` | your `stryker.config.*` unchanged, or a `mutate` block in `vite.config.ts` |
53
+ | A mutant that leaves state behind | judges the next mutants by it | canary; the context restarts clean |
54
+ | A slow machine | timeouts read as kills | a stop is re-run at 3x before it is a timeout |
55
+ | Static mutants | skipped with `ignoreStatic`, else the whole suite per mutant | scored, against the files that load them |
56
+ | Pull requests | your own script | `--since <ref>`, SARIF annotations, `--frozen-oracle` |
57
+ | A change that weakens its tests | not seen | `--frozen-oracle` exits 1 and names each mutant |
58
+ | Coding agents | read the JSON report | `survivors --json` (326 bytes a survivor against 4,094), `--mcp` |
59
+ | Formal methods | — | spec oracles: Quint or any ITF traces as a second verdict |
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ npm install oxmutate
65
+ pnpm add oxmutate
66
+ yarn add oxmutate
67
+ bun add oxmutate
68
+ ```
69
+
70
+ Install it as a dev dependency (`-D`) of the project whose tests it grades. The native core for
71
+ macOS (arm64, x64), Linux (glibc x64 and arm64, musl x64) and Windows (x64) is inside the package;
72
+ nothing runs at install time. Vitest 4.1 or later is an optional peer: oxmutate runs the project's
73
+ own Vitest, with its config, and never brings a second one.
74
+
75
+ ## Quick start
76
+
77
+ ```bash
78
+ npm install oxmutate
79
+ ```
80
+
81
+ ```js
82
+ import { writeFileSync } from 'node:fs';
83
+ import { loadOptions } from 'oxmutate';
84
+
85
+ // The Stryker config a project already has, written here so the program runs anywhere.
86
+ writeFileSync('stryker.config.json', JSON.stringify({
87
+ mutate: ['src/**/*.ts', '!src/**/*.test.ts'],
88
+ thresholds: { high: 90, low: 70, break: 80 },
89
+ coverageAnalysis: 'perTest',
90
+ }));
91
+
92
+ const options = await loadOptions(process.cwd());
93
+ console.log(options.mutate);
94
+ console.log(options.thresholds);
95
+ for (const { key, why } of options.unused) console.log(`${key}: ${why}`);
96
+ ```
97
+
98
+ ```text
99
+ [ 'src/**/*.ts', '!src/**/*.test.ts' ]
100
+ { high: 90, low: 70, break: 80 }
101
+ coverageAnalysis: always perTest
102
+ ```
103
+
104
+ That is the config `npx oxmutate run` reads in that directory: the same file Stryker reads, each
105
+ key oxmutate does not use listed with its reason, never dropped in silence. In a project with
106
+ Vitest installed, `npx oxmutate run` then scores every mutant and writes
107
+ `reports/mutation/mutation.json`.
108
+
109
+ ## One config file, the Vite+ way
110
+
111
+ A Vite or [Vite+](https://viteplus.dev) project keeps its toolchain in one `vite.config.ts`. oxmutate
112
+ reads its settings from a `mutate` block there, beside `test`:
113
+
114
+ ```ts
115
+ // vite.config.ts
116
+ /// <reference types="vitest/config" />
117
+ /// <reference types="oxmutate/vite" /> // adds `mutate` to the config's type
118
+ import { defineConfig } from 'vite'; // or 'vite-plus'
119
+
120
+ export default defineConfig({
121
+ test: { include: ['src/**/*.test.ts'] },
122
+ mutate: {
123
+ include: ['src/**/*.ts', '!src/**/*.test.ts'],
124
+ thresholds: { high: 90, low: 70, break: 80 },
125
+ reporters: ['clear-text', 'html', 'sarif'],
126
+ ignore: [{ call: 'console.*', reason: 'log text is not behaviour' }],
127
+ },
128
+ });
129
+ ```
130
+
131
+ The block is loaded through the project's own Vite, so a TypeScript config and its imports work as
132
+ Vite sees them. Its keys are Stryker's (`include` is the file list, Vitest's name for Stryker's
133
+ `mutate`). Where more than one source sets a key, the nearest wins: a flag, then `--config` or
134
+ `oxmutate.config.*`, then the `mutate` block of `vite.config.*` (or `vitest.config.*`), then
135
+ `stryker.config.*`.
136
+
137
+ ## In CI
138
+
139
+ The pull-request lane mutates only the lines a change touched, annotates the survivors in code
140
+ scanning, and fails when the change weakened its own tests:
141
+
142
+ ```yaml
143
+ # .github/workflows/mutation.yml
144
+ on: pull_request
145
+ permissions: { contents: read, security-events: write }
146
+ jobs:
147
+ mutation:
148
+ runs-on: ubuntu-latest
149
+ steps:
150
+ - uses: actions/checkout@v5
151
+ with: { fetch-depth: 0 }
152
+ - uses: actions/setup-node@v5
153
+ with: { node-version: 22 }
154
+ - run: npm ci
155
+ - run: npx oxmutate run --since origin/${{ github.base_ref }} --frozen-oracle origin/${{ github.base_ref }} --reporter sarif,html
156
+ - uses: github/codeql-action/upload-sarif@v3
157
+ if: always()
158
+ with: { sarif_file: reports/mutation/mutation.sarif }
159
+ ```
160
+
161
+ For the full run, `--shard i/n` splits the mutants across a matrix (a mutant's shard is a hash of
162
+ its stable id, so the shards partition the run exactly and do not move when unrelated code does),
163
+ and `--incremental` reuses every verdict whose source and covering tests did not change.
164
+
165
+ ## How it works
166
+
167
+ ```text
168
+ source ──► oxc (Rust, parallel) ──► every mutant compiled in, each behind a switch
169
+ │
170
+ dry run ──► which test reaches which mutant ─┤
171
+ ▼
172
+ warm Vitest servers (work-stealing) ──► per mutant: arm it, re-run its covering tests, bail at
173
+ the first failure, canary, disarm
174
+ │
175
+ isolated runs ──► static mutants, stragglers, survivor and timeout confirmation
176
+ │
177
+ reports ──► Stryker JSON · HTML · SARIF · survivors --json · exit code
178
+ ```
179
+
180
+ Every mutant is compiled into one instrumented copy of each file, served to Vitest from memory, so
181
+ the project is never copied or rewritten. A mutant is a switch, not a rebuild: arming one is
182
+ setting a value inside the worker that already has the tests loaded.
183
+
184
+ ## How it stays correct
185
+
186
+ | Guard | What it catches |
187
+ | :-- | :-- |
188
+ | The dry run must pass | A suite that fails with no mutant armed scores nothing (exit 3) |
189
+ | Re-parse | Every instrumented file is parsed again before it runs; one that does not parse stops the run (exit 3) instead of scoring a broken file |
190
+ | Canary | The unmutated test must still pass after mutants run in a context; if a mutant left a cache, a registry or a fixture behind, the context restarts and the suspect verdicts run again |
191
+ | Survivor confirmation | A survivor is re-run with the mutant armed across its covering tests in one file, as Stryker arms it, so a mutant that only fails across tests is killed |
192
+ | Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout |
193
+ | Static mutants | Code that runs at import is re-run with the mutant armed from process start, against the files that load it |
194
+ | Flaky tests | Tests that behave differently across two dry runs have their mutants re-run; a verdict that changes is reported `flaky` |
195
+ | No orphans | Every Vitest server exits when its run does, killed or not |
196
+
197
+ ## Migrating
198
+
199
+ The config stays where it is. The command changes:
200
+
201
+ ```diff
202
+ - npx stryker run
203
+ + npx oxmutate run
204
+ ```
205
+
206
+ ```diff
207
+ "scripts": {
208
+ - "test:mutation": "stryker run"
209
+ + "test:mutation": "oxmutate run"
210
+ }
211
+ ```
212
+
213
+ `// Stryker disable next-line <Mutator>: <reason>` comments are honoured as written, and every
214
+ Stryker option oxmutate reads keeps its name and meaning. Three differences are on purpose:
215
+
216
+ - Code inside TypeScript `as` casts and enums **is** mutated; Stryker 10.0.0 skips it.
217
+ `--stryker-compat` gives Stryker's mutant set.
218
+ - The default per-mutant slack is 500 ms, not 5,000 ms: a warm in-process run has no process
219
+ start-up to absorb. `timeoutMS` in your config wins.
220
+ - Static mutants are scored, not skipped.
221
+
222
+ [Coming from Stryker](https://oxmutate.interlace.tools/docs/coming-from/stryker) maps every config
223
+ key.
224
+
225
+ ## Compatibility
226
+
227
+ **Not graded.** No suite is graded through `compat-oracle` for oxmutate, so it has no
228
+ compatibility row. That oracle grades an import path by running the incumbent's own test suite
229
+ against it, and a mutation tester's output is not a passing test but a verdict per mutant. So the
230
+ comparison is verdict agreement: the same project, scored by Stryker 10.0.0 and by oxmutate, each
231
+ mutant's verdict compared against Stryker's own JSON report. Measured on 2026-10-27
232
+ ([`m1-results.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/intents/oxmutate/m1-results.md)):
233
+
234
+ | Subject | Mutants | Same verdict as Stryker |
235
+ | :-- | --: | --: |
236
+ | es-toolkit `src/array` | 720 | 99.58–100% ¹ |
237
+ | burgee/paratext | 1,332 | 99.92% |
238
+ | burgee/controlroom | 997 | 99.90% |
239
+
240
+ ¹ From an earlier build of the same day, before that document's fixes; it is re-measured on the
241
+ CI lane before it is quoted as a result.
242
+
243
+ Of the two disagreements left, one is a Stryker timeout on a loop that ends (its hit limit stops
244
+ it; the covering tests pass). The other is a mutant Stryker scored as a runtime error, so it has
245
+ no Stryker verdict to compare.
246
+
247
+ **Speed is not yet a published number.** On a shared laptop, paratext took 180.0 s under oxmutate
248
+ and 427.4 s under Stryker, at 0.375 and 0.620 CPU-seconds a mutant; on controlroom oxmutate used
249
+ less CPU (0.092 against 0.171 CPU-seconds a mutant) and was slower by the wall clock (107.2 s
250
+ against 94.3 s). The laptop's load moved a run by more than the difference, so the wall-clock
251
+ figures to publish will come from the `oxmutate-bench.yml` lane on a runner nothing else shares.
252
+
253
+ ## Benchmarks
254
+
255
+ Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks).
256
+
257
+ No suite is graded against this package yet, so there is no compatibility number to quote.
258
+
259
+ ## For agents
260
+
261
+ - **`oxmutate survivors --json`** prints one record per survivor: file, line, column, mutator,
262
+ replacement, how many tests cover it, whether it is flaky, and a `fix:` line. On paratext an
263
+ agent read 326 bytes per survivor from it, and 4,094 from Stryker's report for the same
264
+ survivor (`A5` in
265
+ [`m1-results.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/intents/oxmutate/m1-results.md)).
266
+ - **`--json`, `--schema` and an MCP server** come from one declaration on burgee:
267
+ `oxmutate --mcp` serves `run` and `survivors` as tools, and `oxmutate --schema` prints every
268
+ option, default and exit code as data.
269
+ - **Exit codes are the contract.** `0` ok; `1` it ran and the result failed (below `--break`, or
270
+ the change weakened its tests); `2` a usage error, so rewrite the command; `3` the config or
271
+ the suite did not load (a dry run that fails, an instrumented file that does not parse).
272
+ - **The frozen oracle is a reward-hacking check.** An agent asked to make a mutation gate pass can
273
+ weaken the tests instead of strengthening them; `--frozen-oracle <ref>` scores the same mutants
274
+ against the base's tests and fails the change that did.
275
+ - **The docs are machine-readable** at
276
+ [oxmutate.interlace.tools/llms.txt](https://oxmutate.interlace.tools/llms.txt) and
277
+ [llms-full.txt](https://oxmutate.interlace.tools/llms-full.txt).
278
+
279
+ ## Spec oracles
280
+
281
+ A test asserts what someone thought to check; a formal model states what the code must do. A spec
282
+ oracle replays a model's traces against every mutant, as a verdict beside the tests':
283
+
284
+ ```ts
285
+ // vite.config.ts, inside `mutate`
286
+ specOracles: [
287
+ {
288
+ name: 'save-restore',
289
+ backend: 'quint', // `quint run --mbt`; or 'itf' for a directory of traces any tool wrote
290
+ model: 'specs/save-restore.qnt',
291
+ replay: ['src/save-restore.replay.test.ts'],
292
+ files: ['src/cursor.ts'],
293
+ },
294
+ ],
295
+ ```
296
+
297
+ The replay test imports `loadTraces` from `oxmutate/spec`, performs each step's action on the real
298
+ code and asserts the model's state. The report then lists every **test-spec gap**: a mutant the
299
+ model's traces kill while the tests let it survive, each with the trace that caught it, which is a
300
+ test someone should write. [Spec oracles](https://oxmutate.interlace.tools/docs/spec-oracles) walks
301
+ through one.
302
+
303
+ ## Configuration
304
+
305
+ Every key, Stryker's and oxmutate's own, is on
306
+ [oxmutate.interlace.tools/docs/configuration](https://oxmutate.interlace.tools/docs/configuration).
307
+ Keys Stryker documents and oxmutate does not use are listed in the report with the reason. Beyond
308
+ Stryker's options: `ignore` (calls whose subtrees are not mutated), `specOracles`, `flakyReruns`,
309
+ `sarifReport`, `typeChecker` and `strykerCompat`.
310
+
311
+ ## API
312
+
313
+ | Import | What it gives you |
314
+ | :-- | :-- |
315
+ | `oxmutate` | `run()` and `loadOptions()`, the run's result types and `MutateConfig`, `strykerReport()`, `sarifReport()` and `textSummary()`, and the CI-lane helpers `changedLines()`, `parseShard()` and `shardOf()` |
316
+ | `oxmutate/vite` | types only: the `mutate` block on Vite's `UserConfig` |
317
+ | `oxmutate/spec` | `loadTraces()`, `readTrace()` and `decode()`: what a spec-oracle replay test imports |
318
+ | `oxmutate/runner` | the Vitest runner oxmutate starts inside each Vitest worker; Vitest loads it, a program does not |
319
+
320
+ The `oxmutate` bin is that API, declared once on burgee.
321
+
322
+ ## Requirements
323
+
324
+ Node 20.19 or later on the 20 line, or 22.13 or later. Vitest 4.1 or later in the project, for the
325
+ default runner. macOS (arm64, x64), Linux (glibc x64 and arm64, musl x64) or Windows (x64).
326
+
327
+ ## Where it sits
328
+
329
+ It hosts no plugin key of its own.
330
+
331
+ Nothing in this family builds on it yet, and it builds on `bellpull` and `burgee`.
332
+
333
+ ## The family
334
+
335
+ Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each
336
+ owns one job. Six of them — `bellpull`, `closeout`, `linegauge`, `paratext`, `roundel`, `seniority`
337
+ — depend on nothing; the others depend only on packages in this table, and every dependency points
338
+ one way, down the [layers](https://burgee.interlace.tools/docs/concepts/family).
339
+
340
+ No package declares a dependency from outside the family; `react` and `react-reconciler` are
341
+ optional peers of `controlroom`, which npm does not install; `vitest` is an optional peer of
342
+ `oxmutate`, which npm does not install.
343
+ [`package-shape-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts)
344
+ holds that for every manifest, and
345
+ [`independence-install-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/independence-install-lock.test.ts)
346
+ installs each package alone and finds nothing but the family packages it declares.
347
+
348
+ Releases are published by one workflow through npm trusted publishing: no npm token is used, and
349
+ each release carries SLSA provenance, which `npm view <package> dist.attestations` shows.
350
+ [`trusted-publishing-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/trusted-publishing-lock.test.ts)
351
+ keeps both true.
352
+
353
+ | Package | What it is | Migrates from |
354
+ | :-- | :-- | :-- |
355
+ | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander, yargs and meow |
356
+ | [roundel](https://roundel.interlace.tools/docs) | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors |
357
+ | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 |
358
+ | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | @inquirer/core |
359
+ | [linegauge](https://linegauge.interlace.tools/docs) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line |
360
+ | [paratext](https://paratext.interlace.tools/docs) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img |
361
+ | [seniority](https://seniority.interlace.tools/docs) | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc |
362
+ | [closeout](https://closeout.interlace.tools/docs) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime |
363
+ | [bellpull](https://bellpull.interlace.tools/docs) | Subprocesses, and which executable actually ran | cross-spawn and which |
364
+ | [controlroom](https://controlroom.interlace.tools/docs) | Keyboard-driven terminal screens, inline or full-screen | ink |
365
+ | **oxmutate** (this package) | Mutation testing on the oxc engine, inside your own Vitest | Stryker |
366
+
367
+ Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility)
368
+ and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on
369
+ [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages).
370
+
371
+ ## Contributing
372
+
373
+ Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read
374
+ [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as
375
+ [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue.
376
+
377
+ ## Licence
378
+
379
+ MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/LICENSE).
Binary file
package/dist/bin.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { run } from 'burgee';
3
+ import { program } from './cli.js';
4
+ await run(program);
5
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The type checker (Stryker's `checkers: ['typescript']`): a surviving mutant that does not
3
+ * type-check is a CompileError, not a survivor. A survivor is what someone writes a test for, and
4
+ * a test cannot kill a program the compiler refuses; Stryker's TypeScript checker exists for the
5
+ * same reason.
6
+ *
7
+ * It checks survivors only, where Stryker checks every mutant before running it. A killed mutant
8
+ * that would not have compiled changes nothing anyone acts on, and a full project type check per
9
+ * mutant is the costliest step there is. The difference: such a mutant is Killed here where Stryker
10
+ * would score it CompileError, which can move a score that enables the checker by its share.
11
+ *
12
+ * The project's own compiler, `tsgo` when installed and `tsc` otherwise, over its own tsconfig.
13
+ * Survivors are checked in batches with at most one mutant per file, and each error is laid at the
14
+ * mutant in the file it names. An error in a file no mutant of the batch touched (a type that
15
+ * flowed across files) sends that batch back one mutant at a time, so no verdict is a guess.
16
+ */
17
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { ambientRuntime, isWindows, run as spawn } from 'bellpull';
20
+ import { makeSandbox } from './command-runner.js';
21
+ const OUTPUT_TAIL = 1500;
22
+ /**
23
+ * `src/a.ts(3,7): error TS2322: …` — tsc's and tsgo's `--pretty false` shape. No anchors: the lazy
24
+ * first group already makes the leftmost match start the line, and the greedy last one ends it.
25
+ */
26
+ const DIAGNOSTIC = /(.+?)\((\d+),(\d+)\): error (TS\d+: .*)/;
27
+ /** The source with one mutant applied. Columns are UTF-16 and 1-based, as JavaScript counts. */
28
+ export function applyMutant(source, m) {
29
+ const starts = [0];
30
+ for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1))
31
+ starts.push(i + 1);
32
+ const at = (p) => (starts[p.line - 1] ?? 0) + p.column - 1;
33
+ return source.slice(0, at(m.start)) + m.replacement + source.slice(at(m.end));
34
+ }
35
+ /** Errors per file (repository-relative), from the compiler's output. */
36
+ export function parseDiagnostics(output, cwd) {
37
+ const errors = new Map();
38
+ for (const line of output.split('\n')) {
39
+ const hit = DIAGNOSTIC.exec(line.trim());
40
+ if (!hit)
41
+ continue;
42
+ // Stryker disable next-line StringLiteral: all four groups take part in every match, so the defaults are never used; they are there for the type
43
+ const [, name = '', lineNo = '', , message = ''] = hit;
44
+ const file = path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/');
45
+ if (!errors.has(file))
46
+ errors.set(file, `${message} (line ${lineNo})`);
47
+ }
48
+ return errors;
49
+ }
50
+ /** Batches of survivors with at most one per file, so each error names its mutant. */
51
+ export function batches(items) {
52
+ const out = [];
53
+ for (const m of items) {
54
+ const free = out.find((b) => !b.some((x) => x.file === m.file));
55
+ if (free)
56
+ free.push(m);
57
+ else
58
+ out.push([m]);
59
+ }
60
+ return out;
61
+ }
62
+ /** npm's .bin entry for tsgo: `tsgo.cmd` on Windows, where the extensionless file beside it is a shell script. */
63
+ export const tsgoBin = (windows) => (windows ? 'tsgo.cmd' : 'tsgo');
64
+ function compiler(o) {
65
+ if (o.command)
66
+ return o.command.trim().split(/\s+/);
67
+ const tsgo = path.join(o.root, 'node_modules', '.bin', tsgoBin(isWindows(ambientRuntime())));
68
+ return existsSync(tsgo) ? [tsgo] : ['npx', 'tsc'];
69
+ }
70
+ /** The undetected mutants that do not type-check, each with the compiler's first error in its file. */
71
+ export async function checkSurvivors(o, survivors) {
72
+ const failing = new Map();
73
+ if (survivors.length === 0)
74
+ return failing;
75
+ // Stryker disable next-line StringLiteral: compiler() never returns an empty list, so the default is never taken; it is there for the type
76
+ const [command = '', ...prefix] = compiler(o);
77
+ const args = [...prefix, '--noEmit', '--pretty', 'false', '-p', o.tsconfig];
78
+ const sandboxes = Array.from({ length: Math.max(1, Math.min(o.concurrency, survivors.length)) }, () => makeSandbox(o.root));
79
+ try {
80
+ const check = async (sandbox) => {
81
+ const r = await spawn(command, args, { cwd: sandbox, runtime: ambientRuntime() });
82
+ return { ok: r.ok, errors: parseDiagnostics(`${r.stdout}\n${r.stderr}`, sandbox), output: `${r.stdout}${r.stderr}` };
83
+ };
84
+ // Stryker disable next-line StringLiteral: there is always at least one sandbox, so the fallback is never used; it is there for the type
85
+ const base = await check(sandboxes[0] ?? '');
86
+ if (!base.ok)
87
+ throw new Error(`the project does not type-check with no mutant applied (\`${[command, ...args].join(' ')}\`): ${base.output.trim().slice(-OUTPUT_TAIL)}. fix: make the type check pass first, or drop checkers`);
88
+ const original = (file) => readFileSync(path.join(o.root, file), 'utf8');
89
+ const run = async (sandbox, batch) => {
90
+ for (const m of batch)
91
+ writeFileSync(path.join(sandbox, m.file), applyMutant(original(m.file), m));
92
+ const r = await check(sandbox);
93
+ for (const m of batch)
94
+ writeFileSync(path.join(sandbox, m.file), original(m.file));
95
+ if (r.ok)
96
+ return;
97
+ const touched = new Set(batch.map((m) => m.file));
98
+ if (batch.length > 1 && [...r.errors.keys()].some((f) => !touched.has(f))) {
99
+ for (const m of batch)
100
+ await run(sandbox, [m]); // an error that crossed files: one at a time
101
+ return;
102
+ }
103
+ for (const m of batch) {
104
+ const error = r.errors.get(m.file) ?? (batch.length === 1 ? [...r.errors.values()][0] : undefined);
105
+ if (error)
106
+ failing.set(m.id, error);
107
+ }
108
+ };
109
+ const queue = batches(survivors);
110
+ await Promise.all(sandboxes.map(async (sandbox) => {
111
+ for (let b = queue.shift(); b; b = queue.shift())
112
+ await run(sandbox, b);
113
+ }));
114
+ return failing;
115
+ }
116
+ finally {
117
+ // No `force`: each sandbox was made above and nothing else removes it.
118
+ for (const s of sandboxes)
119
+ rmSync(s, { recursive: true });
120
+ }
121
+ }
122
+ //# sourceMappingURL=checker.js.map