src/Invoke-PSMutation.ps1
|
# Public entry point for PSMutant: wiring, and nothing else. function Invoke-PSMutation { <# .SYNOPSIS Run mutation testing over a set of PowerShell files and score how many injected faults ("mutants") the Pester suite catches ("kills"). .DESCRIPTION All work happens in a throwaway temp sandbox: the source subtrees are copied out, mutants are spliced into the COPY, and the tests run from the copy - so tracked source is never modified, even if the run is killed mid-way. Returns a summary object; report-only unless the config sets thresholds.break. .PARAMETER ConfigFile Path to a JSON config (see about_PSMutant / the README): mutate, tests, operators, coveredLinesOnly, thresholds, reportPath, sandboxSubtrees. .PARAMETER SourceRoot Root of the code under test; config paths are relative to it. Defaults to the current directory. .PARAMETER RecheckFrom Path to a report from a previous run -- full or from an earlier recheck. Evaluates ONLY the mutants that report recorded as survivors, minus any declared equivalent, which is the fast inner loop while you are writing assertions. Chaining is the point: recheck a recheck and each round evaluates only what the previous one left alive, so the loop shortens as you close in rather than restarting from the full set every time. Rounds overwrite one <report>.recheck.json; the full report is never written by a recheck. This is not a measurement and does not produce a score: the set is filtered, so no percentage over it means anything, thresholds are not applied, and the result is written to a separate <report>.recheck.json so the full baseline cannot be overwritten by a partial run. It is also only sound for test changes that purely ADD assertions. Editing or deleting an existing test can revive a mutant that was killed before, and a recheck never evaluates those -- so finish with a full run before trusting a number or moving a threshold. .PARAMETER Quiet Suppress the console output: the banner, the per-mutant progress lines and the closing summary. The JSON report is still written and the result object is still returned, so nothing is lost -- only the narration. Worth using in CI, where a build log gains nothing from a line per mutant. Worth leaving OFF interactively, where those lines are the only sign of progress during a run that can take minutes, and survivors appear in yellow as they are found rather than all at the end. .OUTPUTS [pscustomobject] @{ Score; Killed; Survived; Total; ExitCode }, or for -RecheckFrom, @{ Mode; PriorSurvivors; Rechecked; NowKilled; StillSurviving }. .EXAMPLE Invoke-PSMutation -ConfigFile ./psmutant.config.json A full run. Prints a coloured score, lists the survivors to go and kill, and writes the JSON report named by the config's reportPath. .EXAMPLE $r = Invoke-PSMutation -ConfigFile ./psmutant.config.json -Quiet if ($r.ExitCode -ne 0) { throw "Mutation score $($r.Score)% is below the threshold" } A CI gate. -Quiet drops the per-mutant progress lines, which are worth watching interactively and are noise in a build log. ExitCode is 0 unless thresholds.break is set and unmet, so a config without it is report-only. .EXAMPLE Invoke-PSMutation -ConfigFile ./c.json -RecheckFrom ./reports/ps-mutation.json Re-run ONLY the mutants the previous report recorded as survivors -- the fast inner loop while you are writing assertions to kill them. Declared equivalents are skipped, since the config already argues no test can kill those. .EXAMPLE Invoke-PSMutation -ConfigFile ./c.json -RecheckFrom ./reports/ps-mutation.json Invoke-PSMutation -ConfigFile ./c.json -RecheckFrom ./reports/ps-mutation.recheck.json A recheck report seeds the next recheck, so the loop NARROWS: five survivors, kill two, and the second round evaluates three rather than five again. Each round overwrites the same *.recheck.json; the full report is never touched. .EXAMPLE Invoke-PSMutation -ConfigFile ./c.json -SourceRoot ../other-repo Mutate a different repository. Every path in the config is relative to -SourceRoot, which defaults to the current directory. .LINK https://github.com/Fortigi/PSMutant #> [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ConfigFile, [string]$SourceRoot = (Get-Location).Path, [string]$RecheckFrom, [switch]$Quiet ) # Started before anything else so `totalSeconds` covers what a user actually waits for, # sandbox setup and baseline included, rather than only the mutation loop. $runClock = [System.Diagnostics.Stopwatch]::StartNew() $root = (Resolve-Path $SourceRoot).Path $cfg = Get-Content $ConfigFile -Raw | ConvertFrom-Json Assert-PSMutationConfig -Cfg $cfg Assert-PSMutationPester Clear-PSMutationStaleSandbox $subtrees = Get-PSMutationSubtree -Cfg $cfg $sandbox = New-PSMutationSandbox -RepoRoot $root -Subtrees $subtrees try { $t = Get-PSMutationSandboxPlan -Cfg $cfg -SourceRoot $root -SandboxRoot $sandbox Write-PSMutationOutput -Quiet:$Quiet -Lines (New-PSMutationLine -Role 'Banner' ` -Text "`nPSMutant - PowerShell mutation testing (sandboxed)`n Running baseline suite...") $baseline = Invoke-PSMutationBaseline -TestPath $t.AllTests -MutateFiles $t.Mutate Assert-PSMutationBaselineGreen -Baseline $baseline $timeout = Get-PSMutationTimeout -Cfg $cfg -BaselineSeconds $baseline.DurationSeconds Write-PSMutationOutput -Quiet:$Quiet -Lines (New-PSMutationLine -Role 'Good' ` -Text (" Baseline green in {0:N1}s (per-mutant timeout {1}s)" -f $baseline.DurationSeconds, $timeout)) $ops = Get-PSMutationOperatorList -Cfg $cfg $cands = Select-PSMutationCandidate -MutateFiles $t.Mutate -Operators $ops -CoveredLinesOnly (Get-PSMutationCoveredLinesOnly -Cfg $cfg) -CoveredLines $baseline.CoveredLines $hashes = Get-PSMutationSourceHashMap -MutateFiles $t.Mutate -SandboxRoot $sandbox $reportPath = Join-Path $root $cfg.reportPath # Gathered here, in the wiring, because the two impure inputs -- the clock and the # loaded module -- are what would make New-PSMutationProvenance untestable. It stays # pure and is handed values. $provenance = { New-PSMutationProvenance -ModuleVersion (Get-Module PSMutant).Version ` -BaselineSeconds $baseline.DurationSeconds -PerMutantTimeoutSeconds $timeout ` -TotalSeconds $runClock.Elapsed.TotalSeconds } if ($RecheckFrom) { return Invoke-PSMutationRecheckRun -RecheckFrom $RecheckFrom -Candidates $cands -Plan $t ` -SourceHashes $hashes -Operators $ops -TimeoutSeconds $timeout -SandboxRoot $sandbox ` -ReportPath $reportPath -Equivalents $cfg.equivalents -Provenance $provenance -Quiet:$Quiet } Write-PSMutationOutput -Quiet:$Quiet -Lines (New-PSMutationLine -Role 'Detail' ` -Text " Mutants to evaluate: $($cands.Count)`n") $results = Invoke-PSMutationLoop -Candidates $cands -TestsByFile $t.TestsByFile -AllTests $t.AllTests -TimeoutSeconds $timeout -SandboxRoot $sandbox -Quiet:$Quiet # Invoked here, not above: the elapsed time has to be read AFTER the loop, or # totalSeconds records how long the run took to start rather than to finish. $summary = Write-PSMutationReport -Results $results -ReportPath $reportPath -Thresholds $cfg.thresholds ` -SourceHashes $hashes -Operators $ops -Equivalents $cfg.equivalents -Provenance (& $provenance) $band = Get-PSMutationScoreBand -Cfg $cfg Write-PSMutationOutput -Quiet:$Quiet -Lines (Get-PSMutationSummaryLine -Summary $summary -Results $results ` -High $band.High -Low $band.Low -ReportPath $reportPath -Equivalents $cfg.equivalents) $exit = Get-PSMutationExitCode -Summary $summary -Thresholds $cfg.thresholds return ConvertTo-PSMutationRunResult -Summary $summary -ExitCode $exit } finally { Remove-PSMutationSandbox -SandboxRoot $sandbox } } |