src/PSMutation.Runner.ps1

<#
.SYNOPSIS
    Execution engine for the PowerShell mutation runner - baseline, candidate
    selection, and per-mutant Pester runs. Operates entirely on SANDBOX paths
    (see PSMutation.Sandbox.ps1); tracked source is never touched.

.DESCRIPTION
    Depends on PSMutation.Operators.ps1. Each function is small and single-purpose so
    every unit stays under the complexity ceiling. Each mutant's covering tests run in a
    cancellable runspace under a wall-clock timeout (Invoke-PSBoundedPester): the loop-
    condition guard is a speed optimisation that avoids obviously-doomed condition
    mutants, but the timeout is the real safety net -- a mutated loop *body* can still
    make a guarded loop never terminate, and Stop() interrupts it so the run never hangs.
#>


function Invoke-PSMutationBaseline {
    <#
    .SYNOPSIS
        Run the suite once (green-gate) and capture per-file covered line numbers,
        so we only mutate lines a test actually exercises (Stryker's perTest idea).
    .OUTPUTS
        @{ Passed = <bool>; DurationSeconds = <double>; CoveredLines = @{ file = HashSet[int] } }
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string[]]$TestPath,
        [Parameter(Mandatory)] [string[]]$MutateFiles
    )

    $cfg = New-PesterConfiguration
    $cfg.Run.Path = $TestPath
    $cfg.Run.PassThru = $true
    $cfg.Output.Verbosity = 'None'
    $cfg.CodeCoverage.Enabled = $true
    $cfg.CodeCoverage.Path = $MutateFiles
    # Read coverage from the result object; steer the XML to temp so we don't
    # litter a coverage.xml in the working tree (Pester's default output path).
    $cfg.CodeCoverage.OutputPath = Join-Path ([System.IO.Path]::GetTempPath()) "psmut-coverage-$PID.xml"

    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $result = Invoke-Pester -Configuration $cfg
    $sw.Stop()

    $covered = @{}
    $result.CodeCoverage.CommandsExecuted | ForEach-Object {
        $f = [System.IO.Path]::GetFullPath($_.File)
        if (-not $covered.ContainsKey($f)) { $covered[$f] = [System.Collections.Generic.HashSet[int]]::new() }
        [void]$covered[$f].Add([int]$_.Line)
    }

    return @{
        Passed          = ($result.Result -eq 'Passed')
        DurationSeconds = $sw.Elapsed.TotalSeconds
        CoveredLines    = $covered
    }
}

function Test-PSMutantCovered {
    # True if a candidate's line was executed by the baseline run. Pure.
    [OutputType([bool])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Candidate, [Parameter(Mandatory)] [hashtable]$CoveredLines)
    $f = [System.IO.Path]::GetFullPath($Candidate.File)
    return $CoveredLines.ContainsKey($f) -and $CoveredLines[$f].Contains([int]$Candidate.Line)
}

function Select-PSMutationCandidate {
    # Enumerate candidates across the mutate files, keeping only covered ones (opt).
    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string[]]$MutateFiles,
        [Parameter(Mandatory)] [string[]]$Operators,
        [bool]$CoveredLinesOnly,
        $CoveredLines
    )
    $out = [System.Collections.Generic.List[object]]::new()
    foreach ($file in $MutateFiles) {
        Get-PSMutationCandidate -Path $file -Operators $Operators |
            Where-Object { -not $CoveredLinesOnly -or (Test-PSMutantCovered -Candidate $_ -CoveredLines $CoveredLines) } |
            ForEach-Object { $out.Add($_) }
    }
    return , $out.ToArray()
}

function Invoke-PSBoundedPester {
    <#
    .SYNOPSIS
        Run the covering tests in a CANCELLABLE runspace with a wall-clock timeout.
    .DESCRIPTION
        The loop-condition guard prevents a flipped *condition* from spinning, but a
        mutated loop *body* (e.g. `$i + 1` -> `$i - 1`) can still make a guarded loop
        never terminate. There is no way to know that statically, so each mutant runs
        under a hard timeout: a fresh PowerShell/runspace whose pipeline is Stop()'d
        when it overruns -- Stop() interrupts even a tight loop, so the run never hangs.
    .OUTPUTS
        The Pester result string ('Passed'/'Failed'/...), or 'TimedOut'.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string[]]$CoveringTests, [Parameter(Mandatory)] [int]$TimeoutSeconds)
    $code = 'param($tests) $c = New-PesterConfiguration; $c.Run.Path = $tests; $c.Run.PassThru = $true; $c.Output.Verbosity = "None"; (Invoke-Pester -Configuration $c).Result'
    $ps = [PowerShell]::Create()
    [void]$ps.AddScript($code).AddParameter('tests', $CoveringTests)
    $async = $ps.BeginInvoke()
    try {
        if ($async.AsyncWaitHandle.WaitOne([timespan]::FromSeconds($TimeoutSeconds))) {
            return [string]($ps.EndInvoke($async) | Select-Object -Last 1)
        }
        $ps.Stop()
        return 'TimedOut'
    }
    finally { $ps.Dispose() }
}

function Invoke-PSMutant {
    <#
    .SYNOPSIS
        Evaluate one mutant: splice it into its SANDBOX file, run the covering tests
        under a timeout, classify, and restore the sandbox file for the next mutant.
    .OUTPUTS
        'Killed' | 'Survived' -- Survived only if the suite still fully passes; any
        failure OR a timeout (a runaway mutant) counts as Killed.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Candidate,
        [Parameter(Mandatory)] [string]$MutatedContent,
        [Parameter(Mandatory)] [string[]]$CoveringTests,
        [Parameter(Mandatory)] [int]$TimeoutSeconds
    )
    $original = [System.IO.File]::ReadAllText($Candidate.File)
    try {
        [System.IO.File]::WriteAllText($Candidate.File, $MutatedContent)
        $outcome = Invoke-PSBoundedPester -CoveringTests $CoveringTests -TimeoutSeconds $TimeoutSeconds
        if ($outcome -eq 'Passed') { return 'Survived' } else { return 'Killed' }
    }
    finally {
        [System.IO.File]::WriteAllText($Candidate.File, $original)
    }
}

function Write-PSMutationProgress {
    # One per-mutant progress line.
    [CmdletBinding()]
    param([int]$Index, [int]$Total, $Result, [string]$DisplayFile)
    $survived = $Result.Status -eq 'Survived'
    $glyph = if ($survived) { '.' } else { 'x' }
    $col = if ($survived) { 'Yellow' } else { 'DarkGray' }
    Write-Host (" [{0}/{1}] {2} {3}:{4} {5}" -f $Index, $Total, $glyph, $DisplayFile, $Result.Line, $Result.Description) -ForegroundColor $col
}

function Invoke-PSMutationLoop {
    # Evaluate every candidate; return the result rows.
    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]]$Candidates,
        [Parameter(Mandatory)] [hashtable]$TestsByFile,
        [Parameter(Mandatory)] [string[]]$AllTests,
        [Parameter(Mandatory)] [int]$TimeoutSeconds,
        [string]$SandboxRoot,
        [switch]$Quiet
    )
    $results = [System.Collections.Generic.List[object]]::new()
    $n = 0
    foreach ($c in $Candidates) {
        $n++
        $content = [System.IO.File]::ReadAllText($c.File)
        $mutated = Set-PSMutationText -Content $content -Candidate $c
        $covering = if ($TestsByFile.ContainsKey($c.File)) { $TestsByFile[$c.File] } else { $AllTests }
        $status = Invoke-PSMutant -Candidate $c -MutatedContent $mutated -CoveringTests $covering -TimeoutSeconds $TimeoutSeconds
        $display = ConvertFrom-PSMutationSandboxPath -Path $c.File -SandboxRoot $SandboxRoot
        $row = [pscustomobject]@{
            Id = $c.Id; File = $display; Line = $c.Line
            Operator = $c.Operator; Description = $c.Description; Status = $status
        }
        $results.Add($row)
        if (-not $Quiet) { Write-PSMutationProgress -Index $n -Total $Candidates.Count -Result $row -DisplayFile (Split-Path $display -Leaf) }
    }
    return , $results.ToArray()
}