PSMutant

0.5.0

Mutation testing for PowerShell. Injects small faults (flip -eq to -ne, $true to $false, N to N+1, drop -not) into your scripts using the PowerShell AST and reports how many your Pester suite catches - the metric line coverage cannot give you. Runs mutants in a throwaway sandbox so your source is never modified. Requires Pester 5.2.0 or later AT RUN TIME, and delibera
Mutation testing for PowerShell. Injects small faults (flip -eq to -ne, $true to $false, N to N+1, drop -not) into your scripts using the PowerShell AST and reports how many your Pester suite catches - the metric line coverage cannot give you. Runs mutants in a throwaway sandbox so your source is never modified. Requires Pester 5.2.0 or later AT RUN TIME, and deliberately does not declare it as a RequiredModule: PSMutant runs under whichever Pester >= 5.2.0 you have loaded rather than importing one for you. Install Pester yourself if you do not already have it.
Show more

Minimum PowerShell version

7.0

Installation Options

Copy and Paste the following command to install this package using PowerShellGet More Info

Install-Module -Name PSMutant

Copy and Paste the following command to install this package using Microsoft.PowerShell.PSResourceGet More Info

Install-PSResource -Name PSMutant

You can deploy this package directly to Azure Automation. Note that deploying packages with dependencies will deploy all the dependencies to Azure Automation. Learn More

Manually download the .nupkg file to your system's default download location. Note that the file won't be unpacked, and won't include any dependencies. Learn More

Owners

Copyright

(c) Fortigi. MIT licensed.

Package Details

Author(s)

  • Fortigi

Tags

mutation-testing testing pester ast quality test-quality coverage

Functions

Invoke-PSMutation

Dependencies

This module has no dependencies.

Release Notes

**Evaluate mutants in parallel, with `"workers"`.** Wall-clock was `mutants x suite`,
strictly serial -- which is why mutation testing gets set off and walked away from rather than run
in an edit loop.

```jsonc
{ "workers": 0 }   // this machine: ProcessorCount - 1
{ "workers": 4 }   // exactly four
```

Each worker gets its own sandbox copy and its own Pester-loaded runspace; nothing is shared but the
read-only candidate list. Finished mutants are recorded in **candidate order**, not completion
order, so the answer does not depend on which worker finished first. Measured on a real repo:
**554 mutants, 266s serial against 100s at `workers: 8`**, reports identical row for row -- same
order, same verdicts, same killers. A test asserts it by running one fixture both ways.

**Opt-in, default 1.** Your suite runs N times concurrently, and nothing isolates a process-wide
resource. Two turned up in PSMutant's own suite: an environment variable a test WRITES (a runspace
does not get its own environment) and a temp file named after `$PID` (every worker shares one
process id). Both show as a VERDICT rather than an error -- a flipped value fails an assertion that
should have passed, which scores as a kill. Both are fixed, and this project's own gate now runs
at `workers: 3`: 1120 mutants, identical verdicts, 778s down to 306s.

**The per-mutant timeout is multiplied by `workers`.** The baseline is measured alone; N mutants
sharing a machine are slower for reasons unrelated to the fault in them, and an overrun scores as a
**kill** -- so a solo-sized budget turns contention into kills and the score goes **up**.

**An interrupted run can be CONTINUED, with `-ResumeFrom`.** Ctrl-C, a cancelled CI job or a killed
agent already left a partial report; now it seeds the next run instead of only saying how far the
last one got.

```powershell
Invoke-PSMutation -ConfigFile ./c.json -ResumeFrom ./reports/ps-mutation.json
```

Recorded mutants are carried over and only the ones never reached are evaluated. The result is a
**complete** run and carries a real score -- the difference from `-RecheckFrom`, whose set is
filtered and whose number would mean nothing. It cannot claim ONE run stood behind all of it, so
the report says `resumed` and `carriedOverUnverified`.

**It refuses rather than resuming when the carried-over verdicts might be stale**, on exactly the
terms `-MergeIntoBaseline` uses: the report must be a partial one, numbered against the same source
and operator set, and **no mapped test file may have shrunk or disappeared**. Adding a test cannot
revive a mutant the earlier run killed; editing or deleting one can, and a resume never re-looks.

**Configs pipe in, one independent run each.** There was no pipeline binding at all, so a monorepo
gating per package meant a `foreach` with the exit codes collected by hand.

```powershell
Get-ChildItem ./packages -Directory |
   Invoke-PSMutation -ConfigFile ./psmutant.config.json -Quiet | Where-Object ExitCode -ne 0
```

`-ConfigFile` binds by value and by property name; `-SourceRoot` by property name with `FullName`
aliased (`PSPath` deliberately not -- it is provider-qualified). Each config runs **as it arrives**
with its own sandbox, baseline and report. One result object each.

**`-SourceRoot` must be a directory, and says so at the source** -- piping FILES binds it from the
same object's `FullName`, which used to surface as a sandbox error naming a temp directory.

**Gate a pull request on what it changed, with `-ChangedFile`.**

```powershell
$changed = git diff --name-only origin/main...HEAD
exit (Invoke-PSMutation -ConfigFile ./c.json -ChangedFile $changed).ExitCode
```

`mutate` is intersected with what changed, so a run costs a fraction of a full one and answers the
question a reviewer has: are the lines this PR introduced tested well enough? A whole-repo score
cannot answer that, and a whole-repo score is what makes people turn the gate off.

**You compute the diff** -- no `-ChangedSince <ref>`, because every way of resolving a base goes
wrong in *your* environment. An **empty list is refused**: `git diff` against an unfetched ref
prints nothing and exits 0, which is a green gate over zero mutants. A list of files simply not in
`mutate` passes and says so, even under a break threshold.

The score is real but not the project's: the report goes to `<report>.changed.json`, `mode` is
`Changed`, and the schema **requires** `changedFiles` beside it. It cannot combine with
`-RecheckFrom`, `-UpdateBaseline` or `-MergeIntoBaseline` -- folding a scoped run's survivors into
a whole-project baseline would record "no survivors" for files it never looked at.

**Preview what a config would mutate, with `-ListOnly`.** Per file, per operator, and how many
candidates survive `coveredLinesOnly`, then it stops -- nothing evaluated, no report written.

It exists for the **vacuous 100%**: a file producing no candidates is still listed in `mutate`,
contributes 0 of 0, and in a blended score is invisible -- two files in a real repository were in
that state. It names those, and separately the files coverage emptied. `FilesWithNoCandidate` and
`FilesEmptiedByCoverage` travel on the result so a build can fail on either. `ExitCode` is always 0.

**A committed list of accepted survivors, so the gate is adoptable on code already red.** Point
`survivorBaseline` at a path; its presence enables the gate and `-UpdateBaseline` writes it.

```json
{ "mutate": ["src/a.ps1"], "survivorBaseline": ".psmutant-survivors.json" }
```

A survivor **not** in the list fails the run. So does a listed one that has been **fixed**, one
whose **file has left `mutate`**, and one that is **also** declared equivalent.

This is **debt, not equivalence**. `equivalents` means *this mutant cannot be killed* and carries a
written argument the gate checks; a baseline entry means *this mutant is not killed yet* and is
generated. Without the second, recording debt meant overstating it as equivalence, which corrupts
the one list whose entries are claims somebody made.

A set of mutants rather than a per-file score: a ratio's denominator moves with the source, so
against a file baselined at 90% three of four ordinary edits fail the ratchet. Entries are keyed by
file, function and change, so one survives a line moving. `-UpdateBaseline` writes **even on a
failing run**, which adoption needs.

**`-MergeIntoBaseline` folds a recheck's verdicts back into the report it came from**, instead of a
full run purely to refresh a baseline the rechecks already made stale. Each re-evaluated mutant
takes its new verdict, everything else keeps its status, and the report is **re-scored** -- new
verdicts under the old number is a self-contradictory document. It refuses when a mapped test file
**shrank** or disappeared, for the reason -ResumeFrom does.

**A recheck no longer pays for coverage instrumentation it cannot use.** It matches the mutants a
prior report listed on `(File, Id)`, and ids come from the *unfiltered* candidate set, so the
intersection is identical either way. Measured interleaved: the baseline is **13.2s without the
tracer against 16.4s with, +24%**.

**A run that stops running now stops, instead of looking like a slow one.** Every mutant was bounded
and the run was not -- observed, a run suspended overnight at 875 minutes of wall clock against 333
seconds of CPU. Two bounds, checked **between** mutants: a **stalled mutant**, and a **whole-run
budget** as backstop. `runTimeoutSeconds` overrides it, **0 disables it**, and both stop by throwing
so the partial report is still written.

**An interrupted run writes a partial report instead of nothing.** Ctrl-C, a cancelled CI job or a
killed agent used to discard everything. It is marked `"mode": "Partial"` with `evaluated` and
`planned`, and is **counts, never a score**: the loop evaluates in candidate order, so it has seen
whichever files sort earliest, not a sample of anything.

**The report and the console break the score down per file.** A blend is an average, so a strong
file carries a weak one -- observed on a real consumer at ~89% blended while files ranged from
39.6% to 100%.

```
 2 of 3 file(s) score below 85%:
     39.6%  src/weak.ps1  (19 killed / 48)
       78%  src/middling.ps1  (39 killed / 50)
```

`perFile` carries each file's score with its counts, weakest first. The console prints only files
below the good band, and nothing when one file was mutated or every file clears it.

**The report says which tests killed each mutant.** Every row carries `KilledBy`, **truncated** by
default -- a mutant's suite stops at the first failure. Truncated is not "exactly one": over 118
killed mutants the default still reported several killers for 20, so read `killersComplete`.
**`recordAllKillers: true`** records every killer at the cost of the early stop, and adds
**`testsWithoutKills`** -- absent by default, because under the early stop a test that would have
killed but was skipped looks exactly like one that cannot.

**`-Verbose` now tells you something.** A run traces its **resolutions**: the sandbox, the subtrees
copied, the files that resolved into the mutate set, the Pester found, and which covering suite each
file mapped to. `-Verbose` and `-Quiet` are **independent**. Per-mutant progress also goes through
`Write-Progress`, which no caller collecting output swallows.

**`schemaVersion` is now 2, and the report discloses more of what its score does not cover.**

- `filesWithNoCandidate` -- files in `mutate` no operator matched, which score a vacuous 100%.
 **Required** on a scored report, which is what the version bump is for.

`schemas/v2/report.schema.json` ships beside the module, and `schemas/v1/` still ships: an archived
report says `schemaVersion: 1` and only that schema can validate it. The rule for the number is
stated correctly now -- it moves when a field changes **meaning**, **disappears** or **becomes
required**, never when an optional one is added.

**Fixed: the `-ListOnly` result promised arrays and delivered `$null`** for
`FilesWithNoCandidate` and `FilesEmptiedByCoverage` on a clean run. The report was unaffected.

**A schema failure on a scored report names the field that is actually missing.** The full/recheck
split was keyed on the presence of `mode`, so a report missing one disclosure failed the `else` arm
while the validator reported the `if` arm's requirement -- naming `mode`, the one field whose
presence would make it a different kind of report. The discriminator is keyed on `mutationScore`.

FileList

Version History

Version Downloads Last updated
0.5.0 (current version) 10 9/1/2026
0.4.0 12 8/28/2026
0.3.2 23 8/23/2026
0.3.1 26 8/21/2026
0.2.2 9 8/18/2026
0.2.1 6 8/18/2026
0.2.0 10 8/17/2026
0.1.0 91 7/3/2026
Show more