src/Invoke-PSMutation.ps1

<#
.SYNOPSIS
    Public entry point for PSMutant - mutation testing for PowerShell.
#>


function Assert-PSMutationPester {
    [CmdletBinding()]
    param()
    if (-not (Get-Module Pester -ListAvailable | Where-Object Version -ge '5.0.0')) {
        throw 'Pester 5+ is required. Install-Module Pester -MinimumVersion 5.0.0 -Force -Scope CurrentUser'
    }
    Import-Module Pester -MinimumVersion 5.0.0
}

function Get-PSMutationSandboxPlan {
    # Translate the config's source-relative mutate/tests into sandbox absolute paths.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
        Justification = 'SourceRoot and SandboxRoot are used inside the $toSb closure, which the analyzer does not track.')]
    [OutputType([hashtable])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Cfg, [Parameter(Mandatory)] [string]$SourceRoot, [Parameter(Mandatory)] [string]$SandboxRoot)
    $toSb = { param($p) ConvertTo-PSMutationSandboxPath -Path (Join-Path $SourceRoot $p) -RepoRoot $SourceRoot -SandboxRoot $SandboxRoot }
    $byFile = @{}
    $all = [System.Collections.Generic.List[string]]::new()
    foreach ($prop in $Cfg.tests.PSObject.Properties) {
        $vals = @($prop.Value | ForEach-Object { & $toSb $_ })
        $byFile[(& $toSb $prop.Name)] = $vals
        $vals | ForEach-Object { $all.Add($_) }
    }
    return @{
        Mutate      = @($Cfg.mutate | ForEach-Object { & $toSb $_ })
        TestsByFile = $byFile
        AllTests    = $all.ToArray()
    }
}

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.

    .OUTPUTS
        [pscustomobject] @{ Score; Killed; Survived; Total; ExitCode }

    .EXAMPLE
        Invoke-PSMutation -ConfigFile ./psmutant.config.json
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$ConfigFile,
        [string]$SourceRoot = (Get-Location).Path,
        [switch]$Quiet
    )

    $root = (Resolve-Path $SourceRoot).Path
    $cfg = Get-Content $ConfigFile -Raw | ConvertFrom-Json
    Assert-PSMutationPester
    Clear-PSMutationStaleSandbox

    $subtrees = if ($cfg.sandboxSubtrees) { @($cfg.sandboxSubtrees) } else { $script:PSMutationSandboxSubtrees }
    $sandbox = New-PSMutationSandbox -RepoRoot $root -Subtrees $subtrees
    try {
        $t = Get-PSMutationSandboxPlan -Cfg $cfg -SourceRoot $root -SandboxRoot $sandbox

        if (-not $Quiet) { Write-Host "`nPSMutant - PowerShell mutation testing (sandboxed)`n Running baseline suite..." -ForegroundColor Cyan }
        $baseline = Invoke-PSMutationBaseline -TestPath $t.AllTests -MutateFiles $t.Mutate
        if (-not $baseline.Passed) { throw 'Baseline suite is not green - fix the tests before mutating.' }
        # Per-mutant timeout: a mutant should never take much longer than the baseline,
        # so cap at max(floor, baseline x factor). A runaway (non-terminating) mutant is
        # cut off here and counted as Killed instead of hanging the run.
        $factor = if ($cfg.timeoutFactor) { $cfg.timeoutFactor } else { 4 }
        $floor = if ($cfg.timeoutFloorSeconds) { $cfg.timeoutFloorSeconds } else { 15 }
        $timeout = [int][math]::Max($floor, $baseline.DurationSeconds * $factor)
        if (-not $Quiet) { Write-Host (" Baseline green in {0:N1}s (per-mutant timeout {1}s)" -f $baseline.DurationSeconds, $timeout) -ForegroundColor Green }

        $ops = if ($cfg.operators) { @($cfg.operators) } else { $script:PSMutationDefaultOperators }
        $cands = Select-PSMutationCandidate -MutateFiles $t.Mutate -Operators $ops -CoveredLinesOnly ([bool]$cfg.coveredLinesOnly) -CoveredLines $baseline.CoveredLines
        if (-not $Quiet) { Write-Host " Mutants to evaluate: $($cands.Count)`n" -ForegroundColor Gray }

        $results = Invoke-PSMutationLoop -Candidates $cands -TestsByFile $t.TestsByFile -AllTests $t.AllTests -TimeoutSeconds $timeout -SandboxRoot $sandbox -Quiet:$Quiet
        $reportPath = Join-Path $root $cfg.reportPath
        $summary = Write-PSMutationReport -Results $results -ReportPath $reportPath -Thresholds $cfg.thresholds
        if (-not $Quiet) { Show-PSMutationSummary -Summary $summary -Results $results -Thresholds $cfg.thresholds -ReportPath $reportPath }

        $exit = Get-PSMutationExitCode -Summary $summary -Thresholds $cfg.thresholds
        return [pscustomobject]@{
            Score = $summary.Score; Killed = $summary.Killed
            Survived = $summary.Survived; Total = $summary.Total; ExitCode = $exit
        }
    }
    finally {
        Remove-PSMutationSandbox -SandboxRoot $sandbox
    }
}