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.
- package/LICENSE +21 -0
- package/README.md +376 -4
- package/binding/oxmutate.darwin-arm64.node +0 -0
- package/binding/oxmutate.darwin-x64.node +0 -0
- package/binding/oxmutate.linux-arm64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-musl.node +0 -0
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/bin.js +5 -0
- package/dist/checker.js +122 -0
- package/dist/child.js +125 -0
- package/dist/cli.js +165 -0
- package/dist/command-runner.js +83 -0
- package/dist/config.d.ts +141 -0
- package/dist/config.js +172 -0
- package/dist/coordinator.d.ts +83 -0
- package/dist/coordinator.js +653 -0
- package/dist/files.js +62 -0
- package/dist/frozen.d.ts +19 -0
- package/dist/frozen.js +47 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/lanes.d.ts +38 -0
- package/dist/lanes.js +92 -0
- package/dist/native.d.ts +36 -0
- package/dist/native.js +40 -0
- package/dist/platform.js +27 -0
- package/dist/progress.js +40 -0
- package/dist/reports.d.ts +20 -0
- package/dist/reports.js +225 -0
- package/dist/resolve.js +43 -0
- package/dist/runner.d.ts +49 -0
- package/dist/runner.js +270 -0
- package/dist/runtime.js +24 -0
- package/dist/scheduler.js +170 -0
- package/dist/spec-oracle.d.ts +69 -0
- package/dist/spec-oracle.js +68 -0
- package/dist/spec.d.ts +21 -0
- package/dist/spec.js +75 -0
- package/dist/vite-plugin.js +18 -0
- package/dist/vite.d.ts +16 -0
- package/dist/vite.js +3 -0
- package/package.json +81 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/bin.js
ADDED
package/dist/checker.js
ADDED
|
@@ -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
|