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 for candidates and PSMutation.Pester.ps1 for the
    child runspace's import contract. 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: 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.

    Mutants are dispatched across a pool of WORKERS, each with its own sandbox copy and its own
    Pester-loaded runspace. A serial run is a pool of one through the same scheduler rather than
    a separate path, and finished mutants are retired in candidate order, so the report does not
    depend on which worker happened to finish first.
#>


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] };
           FailedTest = <string[]> }
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string[]]$TestPath,
        # AllowEmptyCollection: a -ChangedFile run over a docs-only pull request has nothing to
        # mutate and still needs its green gate. A mandatory [string[]] refuses an empty array
        # outright, which turned that ordinary case into a binding failure.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$MutateFiles,
        # Where Pester's coverage XML goes. Mandatory rather than defaulted to temp: the sandbox is
        # the one directory this run owns and disposes of, and a default would put the file back in
        # shared temp for any caller who forgot.
        [Parameter(Mandatory)] [string]$SandboxRoot,
        # Instrument for coverage. OFF by default, because the green gate is what every run needs
        # and the covered lines are what only some do -- see Test-PSMutationCoverageNeeded. A
        # baseline without it returns an empty CoveredLines, which is the true answer: nothing was
        # measured. It must not then be handed to a filter, and one decision drives both.
        [switch]$Coverage
    )

    $cfg = New-PesterConfiguration
    $cfg.Run.Path = $TestPath
    $cfg.Run.PassThru = $true
    $cfg.Output.Verbosity = 'None'
    $cfg.CodeCoverage.Enabled = [bool]$Coverage
    $cfg.CodeCoverage.Path = $MutateFiles
    # Coverage is read from the RESULT OBJECT below; this file is never opened. The path exists
    # only because Pester writes one somewhere, and its default is a coverage.xml in the working
    # tree -- which is the thing to avoid.
    #
    # It goes in the SANDBOX, not temp. In temp it was "psmut-coverage-$PID.xml", which nothing
    # ever deleted: the startup sweep matches directories named psmut-sandbox-*, so it could not
    # match this file by construction, and they accumulated for the life of the machine -- 67 of
    # them on the box this was found on. The sandbox is already removed in the run's finally, so
    # putting it there makes the cleanup the one that already exists rather than a second one to
    # keep in step.
    #
    # It also stops a predictable write into world-writable temp, which is the same shape as the
    # sandbox path that was fixed in #95 -- lesser, because the content is coverage data rather
    # than source, but there is no reason to keep one after removing the other.
    $cfg.CodeCoverage.OutputPath = Join-Path $SandboxRoot 'coverage.xml'

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

    # A foreach STATEMENT, not a pipeline, and that is the whole of a bug this uncovered. With the
    # tracer off Pester reports no CommandsExecuted, and `$null | ForEach-Object` runs its body
    # ONCE with $_ = $null -- so GetFullPath was handed an empty string and the run died inside
    # the baseline. Wrapping in @( ) does not help: @($null) has one element, which is $null.
    # `foreach ($x in $null)` iterates zero times, which is the answer this wants. Measured all
    # three; the numbers are 1, 1 and 0.
    #
    # It was unreachable while coverage was unconditional, which is why it sat here until the
    # tracer became optional.
    #
    # Reached through Get-PSMutationCoveredLine, which reads the MISSED commands as well as the
    # executed ones: a line is covered by the innermost command spanning it, and only the two lists
    # together say which command that is and whether it ran.
    $covered = Get-PSMutationCoveredLine -Executed $result.CodeCoverage.CommandsExecuted `
        -Missed $result.CodeCoverage.CommandsMissed

    # The reason each failure gave, decided ONCE. The names the guard prints and the count it reads
    # to decide whether to add a hint are two views of this list; computed apart they could disagree
    # about what "no reason" means, and the hint would fire beside a name that carried one.
    # A foreach STATEMENT, because Failed is null on a green run and a pipeline over it would run
    # once with $null and report a phantom failure.
    $failures = [System.Collections.Generic.List[object]]::new()
    foreach ($t in $result.Failed) {
        $failures.Add([pscustomobject]@{ Name = [string]$t.ExpandedPath; Why = (Get-PSMutationFailedTestReason -Test $t) })
    }

    return @{
        Passed          = ($result.Result -eq 'Passed')
        # Read by the guard only when EVERY failure is unexplained: then the names alone point at
        # nothing, and the reader needs to know where else to look.
        Unexplained     = @($failures | Where-Object { -not $_.Why }).Count
        DurationSeconds = $sw.Elapsed.TotalSeconds
        CoveredLines    = $covered
        # Carried so the guard can NAME what broke, and say why. A red baseline is reported by
        # a gate that runs -Quiet, where the whole run prints one line -- so "not green", or
        # even a bare test name, sends the reader to reproduce a failure that by definition is
        # not happening on the machine they are standing on.
        #
        # NO dangling separator when there is nothing to say: "Some.Test -- ." reads as a reason
        # that was lost rather than one that never existed. What counts as a reason is decided in
        # Get-PSMutationFailedTestReason.
        FailedTest      = @(foreach ($f in $failures) { $f.Why ? "$($f.Name) -- $($f.Why)" : $f.Name })
    }
}

function Get-PSMutationFailedTestReason {
    <#
    .SYNOPSIS
        The one line a failed Pester test gives as its reason, or '' when it gives none.
    .DESCRIPTION
        ErrorRecord is a COLLECTION, not one record -- a test can fail for more than one reason, and
        Pester hands back a List. Reading `.Exception.Message` off the list itself works only by
        member enumeration, which yields the inner value for a ONE-element list and NOTHING for a
        longer one, so the reason came back '' exactly when a test had several errors. The FIRST
        record is the reason: later ones are usually cascade from it.

        Of that record, the first NON-EMPTY line. A Pester message is an expectation, then the
        actual, then a stack; the second line would report "at <ScriptBlock>", which is a fact
        about nothing, and a leading blank line would report ''. Split on \r?\n rather than on a bare
        newline: a CRLF message otherwise keeps a trailing carriage return, which prints as a stray
        line break in the middle of the gate's one line of output.

        A test can carry no record at all: when a BeforeAll dies under ErrorActionPreference = Stop,
        Pester marks every test in the block Failed and attaches the error to the CONTAINER. The
        container's record is deliberately NOT reached for -- it holds Pester's own break/continue
        guard text, which says nothing about the consumer's failure. '' is the honest answer.

        Walked with foreach rather than indexed: the empty case is precisely the one this has to
        answer, and indexing an empty collection is an error wherever StrictMode is on.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Test)
    foreach ($record in @($Test.ErrorRecord)) {
        foreach ($line in ([string]$record.Exception.Message -split "\r?\n")) {
            if (-not [string]::IsNullOrWhiteSpace($line)) { return $line.Trim() }
        }
        return ''
    }
    return ''
}

function Assert-PSMutationBaselineGreen {
    # Refuse to mutate against a failing suite. Every mutant would "die" for the
    # reason the suite was already red, producing a perfect score that means nothing
    # -- the single most misleading result this tool could hand back.
    #
    # Lives beside Invoke-PSMutationBaseline, whose output it is the only reader of.
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Baseline)
    if ($Baseline.Passed) { return }
    # Named, and capped at ten. A wholly broken suite would otherwise paste hundreds of names
    # into one exception and bury the first, which is the one most likely to explain the rest.
    $named = @($Baseline.FailedTest)
    $detail = ''
    if ($named.Count -gt 0) {
        $shown = @($named | Select-Object -First 10)
        $more = ''
        if ($named.Count -gt $shown.Count) { $more = " (and $($named.Count - $shown.Count) more)" }
        $detail = " Failed: $($shown -join '; ')$more."
    }
    # Every failure without a reason is a different situation from a failing assertion, and the
    # names alone send the reader to the tests. Two causes produce it, and neither is in the tests'
    # own lines: a BeforeAll that died, whose error Pester attaches to the FILE, and a damaged
    # Pester install, which fails even a test with no assertion in it. Both have been seen.
    if ($named.Count -gt 0 -and $Baseline.Unexplained -eq $named.Count) {
        $detail += ' None of them carried an error. A BeforeAll that failed attaches its error to the test file rather than to the tests, so run that file alone to see it. If even a test with no assertions fails this way, the Pester installation itself may be damaged: reinstall it with Install-Module Pester -RequiredVersion <the version in use> -Force.'
    }
    throw "Baseline suite is not green - fix the tests before mutating.$detail"
}

function Get-PSMutationCoveredLine {
    <#
    .SYNOPSIS
        Per-file covered line numbers, from the commands Pester saw run and the ones it saw not run. Pure.
    .DESCRIPTION
        A command is attributed to the line it STARTS on. Taken alone that leaves every later line
        of a statement written over several lines uncovered although the statement ran -- the
        continuation of a long condition, an argument list, a message built from several strings --
        so `coveredLinesOnly` dropped every mutant on those lines from the score in silence, while
        the coverage gate, which counts commands, reported 100% over the same code.

        So a line no command starts on is covered when the INNERMOST command spanning it ran. Not
        "any command spanning it": a pipeline whose script block never executed still spans that
        block's lines, and reading those as covered would hand mutants nothing ran to the loop,
        to come back as survivors no test could have killed. The innermost spanning command is the
        one whose verdict belongs to the line, which is why the MISSED commands are read too: they
        are what stops a hit outer command speaking for an inner one that did not run.

        A line a command starts on keeps its old answer -- covered when any command starting there
        ran. A line no command spans at all, such as a `param()` default, stays uncovered: nothing
        Pester instruments can say whether it executed.
    .PARAMETER Executed
        Pester's CommandsExecuted. Null when the tracer was off, and then nothing is covered.
    .PARAMETER Missed
        Pester's CommandsMissed.
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param(
        [AllowNull()] [AllowEmptyCollection()] [object[]]$Executed,
        [AllowNull()] [AllowEmptyCollection()] [object[]]$Missed
    )
    # foreach STATEMENTS throughout: a null list iterates zero times, where a pipeline would run
    # once with $null and hand GetFullPath an empty string.
    $byFile = @{}
    foreach ($pass in @(@{ Hit = $true; Commands = $Executed }, @{ Hit = $false; Commands = $Missed })) {
        foreach ($c in $pass.Commands) {
            $f = [System.IO.Path]::GetFullPath($c.File)
            if (-not $byFile.ContainsKey($f)) { $byFile[$f] = [System.Collections.Generic.List[object]]::new() }
            $byFile[$f].Add([pscustomobject]@{
                    StartLine = [int]$c.StartLine; StartColumn = [int]$c.StartColumn
                    EndLine = [int]$c.EndLine; Hit = $pass.Hit
                })
        }
    }
    $covered = @{}
    foreach ($f in $byFile.Keys) { $covered[$f] = Get-PSMutationFileCoveredLine -Command $byFile[$f].ToArray() }
    return $covered
}

function Get-PSMutationFileCoveredLine {
    <#
    .SYNOPSIS
        The covered lines of ONE file, from its commands. Pure.
    .DESCRIPTION
        One pass over the lines with a stack of the commands still open. Extents from one parse
        are nested or disjoint, so after the commands that ended above a line are popped, the top
        of the stack is the innermost command spanning it -- no per-line search over every
        command, which on a large file is the difference between linear and quadratic.

        A command that ended earlier on the same line as a later sibling can sit beneath it on the
        stack. It never decides a line: it ended on the line it was pushed on, so it is popped
        before any later line is judged.
    #>

    # Both types, as for every comma-wrapped return here: `, $set` is statically an Object[]
    # wrapper that PowerShell unrolls on the way out, and the caller receives the set.
    [OutputType([System.Collections.Generic.HashSet[int]], [object[]])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [object[]]$Command)
    $covered = [System.Collections.Generic.HashSet[int]]::new()
    # Sorted, because Pester hands executed and missed back as two lists and neither order is a
    # promise. The stack only works over commands in document order.
    $sorted = @($Command | Sort-Object StartLine, StartColumn)
    $last = ($sorted | Measure-Object -Property EndLine -Maximum).Maximum
    $open = [System.Collections.Generic.Stack[object]]::new()
    $next = 0
    for ($line = $sorted[0].StartLine; $line -le $last; $line++) {
        while ($open.Count -gt 0 -and $open.Peek().EndLine -lt $line) { [void]$open.Pop() }
        # Every command starting here: a start line is covered when ANY of them ran, which is the
        # rule this function inherits unchanged.
        while ($next -lt $sorted.Count -and $sorted[$next].StartLine -eq $line) {
            if ($sorted[$next].Hit) { [void]$covered.Add($line) }
            $open.Push($sorted[$next])
            $next++
        }
        # Every other line: the innermost command spanning it decides. On a start line this is
        # the command just pushed, which the loop above has already answered for.
        if ($open.Count -gt 0 -and $open.Peek().Hit) { [void]$covered.Add($line) }
    }
    return , $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 Get-PSMutationRunBaseline {
    <#
    .SYNOPSIS
        The baseline this run will size its budget from -- measured, or the stand-in a preview uses.
    .DESCRIPTION
        A run must measure: it needs a green suite to mutate against and a duration to derive the
        per-mutant timeout from. A preview with no coverage filter to satisfy needs neither, and a
        suite run would buy it nothing.

        The stand-in is a FUNCTION rather than two lines inside an else, because its zeros are
        otherwise unobservable: nothing in a preview reads a duration, so every mutant of them
        survives a test that can only assert the preview did not throw. Returned from a named
        function, the contract -- no time taken, no coverage measured -- is something a test can
        state.

        It carries no Passed flag. A green verdict is the one thing this did not establish, and
        fabricating one here is exactly the "a number nobody measured, reported as one" failure
        the module exists to catch.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Plan,
        [Parameter(Mandatory)] [string]$SandboxRoot,
        [switch]$Measure,
        [switch]$Coverage,
        [switch]$Quiet
    )
    if (-not $Measure) { return [pscustomobject]@{ DurationSeconds = 0.0; CoveredLines = @{} } }
    Write-PSMutationOutput -Quiet:$Quiet -Lines (New-PSMutationLine -Role 'Banner' `
            -Text "`nPSMutant - PowerShell mutation testing (sandboxed)`n Running baseline suite...")
    $baseline = Invoke-PSMutationBaseline -TestPath $Plan.AllTests -MutateFiles $Plan.Mutate `
        -SandboxRoot $SandboxRoot -Coverage:$Coverage
    Assert-PSMutationBaselineGreen -Baseline $baseline
    return $baseline
}

function ConvertTo-PSMutationDisplayPerFile {
    <#
    .SYNOPSIS
        The per-file tally with its paths made repo-relative, for anything a human reads.
    .DESCRIPTION
        Select-PSMutationCandidate works in the SANDBOX, so every File it reports is an absolute
        path under a temp directory whose name changes on every run. That is the right value to
        mutate and the wrong one to print or to persist: it cannot be matched against a checkout,
        it differs between two runs of the same commit, and it names a directory that is deleted
        before the reader sees it. It reached the report's `filesWithNoMutants` and the summary's
        uncovered caveat that way.

        Converted ONCE, here, rather than at each of the places that display it -- the candidates
        themselves keep their sandbox paths, because that is where the loop reads them from.
    #>

    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$PerFile,
        [Parameter(Mandatory)] [string]$SandboxRoot
    )
    return , @($PerFile | ForEach-Object {
            [pscustomobject]@{
                File       = (ConvertFrom-PSMutationSandboxPath -Path $_.File -SandboxRoot $SandboxRoot)
                Produced   = $_.Produced
                Kept       = $_.Kept
                ByOperator = $_.ByOperator
            }
        })
}

function Get-PSMutationCandidateByOperator {
    # One file's candidate counts, split by the operator that produced them.
    #
    # BOTH numbers per operator, because they answer different questions and only the pair
    # locates the fault. `Produced` says which operators matched the file at all -- zero across
    # the board means the file has nothing this module knows how to mutate, and no amount of
    # test-writing changes that. `Kept` says how many survived the coverage filter -- produced
    # but not kept means the mapped suite does not reach those lines, which IS a test problem.
    # Reported as one number, a file scoring a vacuous 100% looks the same either way.
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Produced,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Kept
    )
    $byOp = [ordered]@{}
    foreach ($c in $Produced) {
        if (-not $byOp.Contains($c.Operator)) { $byOp[$c.Operator] = @{ Produced = 0; Kept = 0 } }
        $byOp[$c.Operator].Produced++
    }
    # No guard on the key: Kept is a subset of Produced by construction, so a missing key here
    # would mean the caller passed two unrelated sets, which is a fault to surface rather than
    # to absorb into a zero.
    foreach ($c in $Kept) { $byOp[$c.Operator].Kept++ }
    return $byOp
}

function Select-PSMutationCandidate {
    # Enumerate candidates across the mutate files, keeping only covered ones (opt), and
    # report what that removed.
    #
    # Returns BOTH, rather than only the survivors, because the coverage filter can empty a
    # whole mutate file and the score then answers for a smaller set than the config asked
    # for -- upward, and silently. It fires the moment a file joins `mutate` before its tests
    # exist, or a refactor stops a suite exercising a module. The per-file tally is the only
    # place the pre-filter count still exists; recomputing it later would mean parsing every
    # file a second time.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        # AllowEmptyCollection, like the baseline's: a -ChangedFile run over a docs-only pull
        # request has no file to enumerate, and that is an ordinary outcome rather than a
        # binding failure. It yields no candidates, which is the true answer.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$MutateFiles,
        [Parameter(Mandatory)] [string[]]$Operators,
        [bool]$CoveredLinesOnly,
        $CoveredLines
    )
    $out = [System.Collections.Generic.List[object]]::new()
    $perFile = [System.Collections.Generic.List[object]]::new()
    foreach ($file in $MutateFiles) {
        $produced = @(Get-PSMutationCandidate -Path $file -Operators $Operators)
        $kept = @($produced | Where-Object { -not $CoveredLinesOnly -or (Test-PSMutantCovered -Candidate $_ -CoveredLines $CoveredLines) })
        foreach ($c in $kept) { $out.Add($c) }
        $perFile.Add([pscustomobject]@{ File = $file; Produced = $produced.Count; Kept = $kept.Count
                ByOperator = (Get-PSMutationCandidateByOperator -Produced $produced -Kept $kept)
            })
    }
    return [pscustomobject]@{ Candidates = $out.ToArray(); PerFile = $perFile.ToArray() }
}

# The outcomes this module understands from a covering-test run. Pester's run-level result
# supplies 'Passed' and 'Failed'; 'TimedOut' is minted here by Stop-PSMutationJob. Anything
# outside this set is refused rather than scored -- see Get-PSMutationVerdict.
$script:PSMutationKnownOutcomes = @('Passed', 'Failed', 'TimedOut')

function Start-PSMutantEvaluation {
    <#
    .SYNOPSIS
        Splice one mutant into ITS WORKER'S sandbox file and set the covering tests running,
        without waiting for them.
    .DESCRIPTION
        Half of what used to be one synchronous function, and the split is what makes parallel
        evaluation possible: the caller can have one of these in flight per worker and collect
        whichever finishes first.

        There is exactly ONE execution path. A serial run is a pool of one worker through this
        same pair, rather than a second, simpler route -- two paths would be two places for the
        verdict to be decided, and they would disagree in whichever case nobody tests.

        The ORDER of the last two statements is load-bearing. The shell is obtained BEFORE the
        file is written, so a Pester import that fails leaves the sandbox exactly as it was
        found. The old shape wrote first and leaned on a `finally` to undo it; this needs no
        undo at all.
    .OUTPUTS
        The in-flight job: the worker it belongs to, its handle, and everything needed to
        collect it and put the file back.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Writes only inside the throwaway sandbox; tracked source is never touched.')]
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        # The mutant addressed in the WORKER'S sandbox -- this is the file that gets written.
        [Parameter(Mandatory)] $Candidate,
        # The same mutant addressed in the PRIMARY sandbox, which is what the report says. Carried
        # because a row must not name a per-worker temp directory: which worker ran a mutant is
        # scheduling, and scheduling must not be visible in the answer.
        [Parameter(Mandatory)] $Source,
        [Parameter(Mandatory)] [int]$Index,
        [Parameter(Mandatory)] [string]$MutatedContent,
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$OriginalContent,
        [Parameter(Mandatory)] [string[]]$CoveringTests,
        [Parameter(Mandatory)] [int]$WorkerId,
        [switch]$RecordAllKillers
    )
    $ps = Get-PSMutationWarmShell -WorkerId $WorkerId
    $ps.Commands.Clear()
    [void]$ps.AddScript((Get-PSMutationWarmPesterScript).ToString()).AddParameter('tests', $CoveringTests).
        AddParameter('recordAllKillers', [bool]$RecordAllKillers)
    [System.IO.File]::WriteAllText($Candidate.File, $MutatedContent)
    # The clock starts at BeginInvoke rather than when the candidate was queued, so a mutant's
    # measured seconds are what it spent running and not what it spent waiting for a worker.
    # The per-mutant budget is a statement about the child, and a queue time folded into it would
    # turn a busy pool into a run full of timeouts.
    return [pscustomobject]@{
        WorkerId = $WorkerId; Shell = $ps; Async = $ps.BeginInvoke()
        Candidate = $Candidate; Source = $Source; Index = $Index
        OriginalContent = $OriginalContent
        Clock = [System.Diagnostics.Stopwatch]::StartNew()
    }
}

function Stop-PSMutationJob {
    # Cut off a mutant that outlived its budget, and discard the runspace it was using.
    #
    # Stop() leaves a runspace unusable, so it is discarded rather than handed to the next mutant.
    # A timeout is rare; paying a cold start after one is the cheap half of the trade -- and it is
    # now one WORKER'S cold start rather than the pool's.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Stops an in-process pipeline; there is no system state to confirm.')]
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Job)
    $Job.Shell.Stop()
    Close-PSMutationWarmRunspace -WorkerId $Job.WorkerId
    # No killers on a timeout, and an EMPTY list rather than none: the caller reads .Killers
    # unconditionally, and a missing property would make "nothing killed it" and "we never
    # looked" the same answer at the one point where they differ most.
    return [pscustomobject]@{ Result = 'TimedOut'; Killers = @() }
}

function Receive-PSMutationJob {
    # The finished child's verdict.
    #
    # A child that returned no verdict proved nothing about the mutant. Handing that back would
    # classify it Killed -- anything but 'Passed' is a kill -- so a broken child reads as a
    # perfect score. That is exactly how the Pester version collision stayed invisible for so
    # long. Fail the run instead of scoring it.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Job)
    $result = $Job.Shell.EndInvoke($Job.Async) | Select-Object -Last 1
    $outcome = [string]$result.Result
    if (-not $outcome) {
        $why = Get-PSMutationRunspaceError -Runspace $Job.Shell
        Close-PSMutationWarmRunspace -WorkerId $Job.WorkerId
        throw "The covering tests produced no result: $why"
    }
    return [pscustomobject]@{ Result = $outcome; Killers = @($result.Killers) }
}

function Get-PSMutationVerdict {
    <#
    .SYNOPSIS
        What one covering-test outcome says about the mutant. Pure.
    .OUTPUTS
        'Killed' | 'Survived' | 'TimedOut' -- Survived only if the suite still fully
        passes. A timeout scores WITH the kills, because a mutant that hangs the suite is a
        fault, but it is reported apart from them: "the suite proved this fault is caught"
        and "the suite hung and we assumed so" are different claims and only the first is
        evidence. Folded together, a suite that is merely too slow inflates the score.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Run)
    $outcome = $Run.Result
    # The verdict and the killers travel together from here, so a caller cannot record one
    # without the other. Survived and TimedOut carry an empty list rather than none, because
    # "nothing killed it" and "we did not look" must not be the same value.
    if ($outcome -eq 'Passed') { return [pscustomobject]@{ Status = 'Survived'; Killers = @() } }
    if ($outcome -eq 'TimedOut') { return [pscustomobject]@{ Status = 'TimedOut'; Killers = @() } }
    # A CLOSED vocabulary. Everything above is a value this module understands; anything
    # else is an outcome nobody modelled, and the fall-through below scores it Killed --
    # toward the flattering answer, silently, with no test failing.
    #
    # The collapse is correct for every shipping Pester, whose run-level result is
    # two-valued. The risk is a WIDENED vocabulary rather than a renamed one: a rename
    # fails loudly at the baseline, which compares against the literal 'Passed', but a
    # third state that coexists with it leaves the baseline green and scores every mutant
    # returning it as killed. That is a perfect score over tests that proved nothing --
    # the same shape as the Pester-collision bug, reached through a door its fix left open.
    if ($outcome -notin $script:PSMutationKnownOutcomes) {
        throw ("The covering tests returned an outcome this version of PSMutant does not " +
            "model: '$outcome'. Known outcomes are $($script:PSMutationKnownOutcomes -join ', '). " +
            "Scoring it would guess, and the guess flatters the score.")
    }
    return [pscustomobject]@{ Status = 'Killed'; Killers = @($Run.Killers) }
}

function Complete-PSMutantEvaluation {
    <#
    .SYNOPSIS
        Collect one dispatched mutant's verdict and put its sandbox file back.
    .DESCRIPTION
        -Expired is the SCHEDULER'S decision, not this function's, because only the scheduler
        knows what else is in flight: it waits on every handle at once and hands back whichever
        it found finished or past its budget. Asking here would mean a second clock reading and
        a second answer to a question already settled.
    .OUTPUTS
        @{ Status; Killers } -- see Get-PSMutationVerdict.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Writes only inside the throwaway sandbox; tracked source is never touched.')]
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Job, [switch]$Expired)
    try {
        return Get-PSMutationVerdict -Run ($Expired ? (Stop-PSMutationJob -Job $Job) : (Receive-PSMutationJob -Job $Job))
    }
    finally {
        # Still restored per mutant, and deliberately. The next mutant on this worker writes the
        # whole file anyway, so this write is redundant between two mutants of the SAME file in
        # the SAME worker -- but it is what makes the pair self-contained: a mutant that throws,
        # or a run killed here, leaves the sandbox as it found it.
        [System.IO.File]::WriteAllText($Job.Candidate.File, $Job.OriginalContent)
    }
}

function Get-PSMutationProgressLine {
    # One per-mutant progress line. Pure, and emitted as the loop goes rather than
    # collected: a run of several hundred mutants takes minutes, and a progress report
    # delivered at the end is not a progress report.
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param([int]$Index, [int]$Total, $Result, [string]$DisplayFile)
    $survived = $Result.Status -eq 'Survived'
    $glyph = $survived ? '.' : 'x'
    $role = $survived ? 'Warn' : 'Muted'
    return New-PSMutationLine -Role $role -Data $Result `
        -Text (" [{0}/{1}] {2} {3}:{4} {5}" -f $Index, $Total, $glyph, $DisplayFile, $Result.Line, $Result.Description)
}

function Get-PSMutationStalledFault {
    <#
    .SYNOPSIS
        The fault, if any, when one mutant's wall clock says the run stopped running.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [double]$MutantSeconds,
        [Parameter(Mandatory)] [int]$TimeoutSeconds,
        [Parameter(Mandatory)] [int]$Index,
        [Parameter(Mandatory)] [int]$Total
    )
    # The precise version of the run bound, and the one that fires within a single mutant instead
    # of at the end of a budget nobody wants to wait out. Every mutant is already bounded: the
    # child is given TimeoutSeconds and its handle is waited on for exactly that. So a mutant
    # whose WALL CLOCK is far past its own budget did not run slowly -- the mechanism that was
    # supposed to stop it did not fire.
    #
    # That is what an overnight hang looks like from inside: 875 minutes elapsed against 333
    # seconds of CPU, because the machine slept mid-run and the handle never came back. Comparing
    # the two numbers names the cause; a total-run deadline only reports the symptom, hours later.
    #
    # x4 and a +30s floor, so an ordinary overrun cannot trip it. A timed-out mutant already
    # costs its full budget plus the cost of discarding and rebuilding the runspace, and a loaded
    # machine can stretch that; four times the budget is not something a working run reaches.
    $limit = [math]::Max(($TimeoutSeconds * 4), ($TimeoutSeconds + 30))
    if ($MutantSeconds -le $limit) { return $null }
    return ("Mutant $Index of $Total took $([int]$MutantSeconds)s against a per-mutant budget of " +
        "${TimeoutSeconds}s. The bound on the child did not fire, which is what a suspended or " +
        'wedged run looks like rather than a slow one -- a machine that slept mid-run leaves the ' +
        'handle it was waiting on never signalling. Stopping here so the partial report says how ' +
        'far the run got.')
}

function Get-PSMutationOverBudgetFault {
    <#
    .SYNOPSIS
        The fault, if any, when the whole run has outlived its wall-clock budget.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [double]$ElapsedSeconds,
        [Parameter(Mandatory)] [int]$DeadlineSeconds,
        [Parameter(Mandatory)] [int]$Index,
        [Parameter(Mandatory)] [int]$Total
    )
    # A pure decision rather than an inline comparison on a stopwatch, so its BOUNDARY can be
    # tested. Written inline it could not: elapsed wall-clock never lands exactly on the budget,
    # so nothing could tell -gt from -ge and self-mutation said so.
    if ($DeadlineSeconds -le 0) { return $null }
    if ($ElapsedSeconds -le $DeadlineSeconds) { return $null }
    return ("This run passed its wall-clock budget of ${DeadlineSeconds}s after $Index of " +
        "$Total mutant(s). Every mutant is bounded and the run was not, so a suspended or wedged " +
        'run used to sit there indefinitely and look exactly like a slow one. Raise ' +
        'runTimeoutSeconds, or set it to 0 if something else already kills wedged runs.')
}

function Get-PSMutationCoveringSuite {
    <#
    .SYNOPSIS
        The test files that cover one mutate file: its own mapping, or the whole suite.
    #>

    # BOTH types, and the second is the price of the comma-wrap: `, $x` is statically an
    # Object[] wrapper that PowerShell unrolls on return, so PSUseOutputTypeCorrectly
    # contradicts a bare [string[]]. Declaring only [object[]] would satisfy the analyzer
    # and stop documenting what a caller actually receives.
    [OutputType([string[]], [object[]])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$File,
        [Parameter(Mandatory)] [hashtable]$TestsByFile,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$AllTests
    )
    # ONE place, because the loop and the verbose trace both need the answer and a second copy of
    # a fallback is a second thing to get wrong -- the trace's copy survived its own mutant, since
    # nothing asserted what it printed.
    # Comma-wrapped for the reason the report's two sibling collectors are: an empty result
    # would unroll to $null, and this feeds a -TestPath that would then bind nothing.
    $suite = [string[]]@($TestsByFile.ContainsKey($File) ? $TestsByFile[$File] : $AllTests)
    return , $suite
}

function New-PSMutationResultRow {
    <#
    .SYNOPSIS
        One mutant's result as the report publishes it. Pure.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Pure projection: returns an object, changes no state.')]
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Candidate,
        [Parameter(Mandatory)] $Verdict,
        [Parameter(Mandatory)] [string]$DisplayFile
    )
    return [pscustomobject]@{
        # Function is carried so an equivalence declaration can address this mutant by
        # the function it lives in rather than by a line number, which moves whenever
        # anything above the mutant is edited and takes the declaration stale with it.
        #
        # This row is the report's published shape. Adding a field widens what every
        # consumer may depend on, so a test asserts the exact list: widening should
        # cost a deliberate edit, not happen as a side effect of an internal rename.
        #
        # Which WORKER ran the mutant is deliberately absent. It is scheduling, it differs
        # between two runs that must be identical, and a field that changes with the machine
        # is one a consumer would eventually diff.
        Id = $Candidate.Id; Function = $Candidate.Function; File = $DisplayFile; Line = $Candidate.Line
        Operator = $Candidate.Operator; Description = $Candidate.Description; Status = $Verdict.Status
        # The tests that noticed. TRUNCATED under the default configuration and complete under
        # -RecordAllKillers, which is stated at run level rather than inferred from the length
        # of this list.
        #
        # Truncated is not the same as "exactly one", measured: over 118 killed mutants the
        # default still reported more than one killer for 20 of them, because several tests can
        # be marked failed before Pester's early stop takes hold. So the length here says
        # nothing about how many tests really kill a mutant -- the same run with every killer
        # recorded found 85. Read killersComplete, never the count.
        KilledBy = @($Verdict.Killers)
    }
}

function Get-PSMutationFreeWorker {
    # The lowest-numbered worker with nothing in flight, or -1 when every worker is busy. Pure.
    #
    # LOWEST rather than any, so a serial run and a parallel one dispatch the same way for as
    # long as the pool is idle, and so the choice is a fact about the schedule rather than about
    # whichever enumeration order a hashtable felt like today.
    [OutputType([int])]
    [CmdletBinding()]
    # [AllowNull()] beside the mandatory, and it is not decoration. An IDLE POOL is an array whose
    # every element is $null, and PowerShell's mandatory check unwraps a single-element collection
    # before testing it: a one-worker pool with nothing in flight is `@($null)`, which binds as
    # null and is refused. So the serial case -- the only one every existing test exercises --
    # failed at the first dispatch while every parallel one bound fine.
    param([Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [object[]]$InFlight)
    for ($i = 0; $i -lt $InFlight.Count; $i++) {
        if ($null -eq $InFlight[$i]) { return $i }
    }
    return -1
}

function Get-PSMutationJobState {
    <#
    .SYNOPSIS
        'Complete', 'Expired' or 'Running' for one in-flight mutant. Pure.
    .DESCRIPTION
        Completion is asked FIRST. A child that finished a hair before its budget ran out has
        a verdict, and reading the clock first would throw that verdict away and score the
        mutant Killed on a timeout it did not have -- toward the flattering answer, which is
        the direction this module refuses to guess in.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [bool]$Completed,
        [Parameter(Mandatory)] [double]$ElapsedSeconds,
        [Parameter(Mandatory)] [int]$TimeoutSeconds
    )
    if ($Completed) { return 'Complete' }
    if ($ElapsedSeconds -ge $TimeoutSeconds) { return 'Expired' }
    return 'Running'
}

function Get-PSMutationWaitBudget {
    <#
    .SYNOPSIS
        How long the scheduler may block before it MUST look again, in MILLISECONDS: the least
        budget any in-flight mutant has left. Pure.
    .DESCRIPTION
        The least, not the average and not a fixed poll interval, because a mutant is cut off
        on its OWN budget and one that expires while the scheduler is asleep on somebody else's
        clock has overrun by however long the nap was. A fixed interval would answer the same
        question by waking up constantly and would still be wrong at the boundary.

        Floored at 1ms rather than 0: a zero timeout makes WaitAny a non-blocking poll, so a
        mutant already past its budget would spin the scheduler at full speed for as long as it
        takes the sweep to reach it.
    #>

    [OutputType([int])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [double[]]$ElapsedSeconds,
        [Parameter(Mandatory)] [int]$TimeoutSeconds
    )
    # The OLDEST in-flight mutant has the least budget left, so this asks for a maximum rather
    # than tracking a minimum in a loop -- and that is not tidiness. `if ($left -lt $least)` and
    # `-le` produce identical output for every input, because assigning an equal value changes
    # nothing: an equivalent mutant that would have to be argued for in the config rather than
    # killed by a test. Asking for a maximum has no boundary to get wrong.
    #
    # The 0.0 is what makes an empty list answer the whole budget rather than $null, and it is
    # observable: raise it and a scheduler with nothing in flight waits for less than it should.
    $oldest = (@($ElapsedSeconds) + @(0.0) | Measure-Object -Maximum).Maximum
    return [int][math]::Max(1, [math]::Ceiling(($TimeoutSeconds - $oldest) * 1000))
}

function Wait-PSMutationWorker {
    <#
    .SYNOPSIS
        Block until an in-flight mutant finishes or the earliest per-mutant budget runs out.
    .DESCRIPTION
        There is ALWAYS something in flight when this is called, and that is an invariant of
        the loop rather than a hope. Every candidate is in exactly one of four states -- not
        yet dispatched, in flight, parked, retired -- and the loop only reaches here with at
        least one not retired. The next one to retire is not parked, because retirement drains
        every parked mutant whose turn has come; so it is either in flight, or not yet
        dispatched -- and in the second case dispatch stopped because every worker was busy.
        Both leave something in flight.

        It is not guarded, deliberately. A guard for a state the loop cannot reach is a branch
        no test can distinguish from its own absence, and it would quietly turn a future
        scheduling bug into a spin instead of an exception.
    #>

    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Schedule, [Parameter(Mandatory)] $Context)
    $jobs = @($Schedule.InFlight | Where-Object { $null -ne $_ })
    $budget = Get-PSMutationWaitBudget -TimeoutSeconds $Context.TimeoutSeconds `
        -ElapsedSeconds @($jobs | ForEach-Object { $_.Clock.Elapsed.TotalSeconds })
    [void][System.Threading.WaitHandle]::WaitAny(
        [System.Threading.WaitHandle[]]@($jobs | ForEach-Object { $_.Async.AsyncWaitHandle }), $budget)
}

function Start-PSMutationWorkerJob {
    # Dispatch the next candidate to one idle worker, addressed inside THAT worker's sandbox copy.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Writes only inside the throwaway sandbox; tracked source is never touched.')]
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Schedule,
        [Parameter(Mandatory)] $Context,
        [Parameter(Mandatory)] [int]$WorkerId
    )
    $c = $Context.Candidates[$Schedule.Next]
    # One read per FILE, not per mutant. A file contributes many candidates -- 125 for the largest
    # in this repo's sibling -- and every one of them re-read the same unchanged bytes to splice
    # against. Keyed on the PRIMARY path, so every worker shares one read: the copies are byte
    # identical by construction, and reading each worker's own copy would be the same bytes off
    # disk once per worker to produce strings that are equal.
    if (-not $Context.Originals.ContainsKey($c.File)) {
        $Context.Originals[$c.File] = [System.IO.File]::ReadAllText($c.File)
    }
    $content = $Context.Originals[$c.File]
    $root = $Context.Roots[$WorkerId]
    # A COPY, because the candidate list is shared across workers and re-rooting in place would
    # point every later worker at whichever sandbox happened to run it last.
    $exec = $c.PSObject.Copy()
    $exec.File = Get-PSMutationWorkerPath -Path $c.File -SandboxRoot $Context.SandboxRoot -WorkerRoot $root
    $covering = Get-PSMutationCoveringSuite -File $c.File -TestsByFile $Context.TestsByFile -AllTests $Context.AllTests
    # The TESTS are re-rooted too, and that is the half that is easy to forget. A worker running
    # the primary sandbox's test files would dot-source the primary sandbox's source, so every
    # mutant would run against unmutated code and survive -- a score of zero, arrived at silently.
    $tests = [string[]]@($covering | ForEach-Object {
            Get-PSMutationWorkerPath -Path $_ -SandboxRoot $Context.SandboxRoot -WorkerRoot $root
        })
    return Start-PSMutantEvaluation -Candidate $exec -Source $c -Index $Schedule.Next `
        -MutatedContent (Set-PSMutationText -Content $content -Candidate $c) -OriginalContent $content `
        -CoveringTests $tests -WorkerId $WorkerId -RecordAllKillers:$Context.RecordAllKillers
}

function Start-PSMutationDispatch {
    # Hand the next candidate to every idle worker, until one of the two runs out.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Writes only inside the throwaway sandbox; tracked source is never touched.')]
    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Schedule, [Parameter(Mandatory)] $Context)
    while ($Schedule.Next -lt $Context.Total) {
        $w = Get-PSMutationFreeWorker -InFlight $Schedule.InFlight
        if ($w -lt 0) { break }
        $Schedule.InFlight[$w] = Start-PSMutationWorkerJob -Schedule $Schedule -Context $Context -WorkerId $w
        $Schedule.Next++
    }
}

function Get-PSMutationFinishedMutant {
    <#
    .SYNOPSIS
        One finished mutant, collected: its published row and the seconds it took.
    .DESCRIPTION
        The seconds are carried BESIDE the row rather than in it. The row's field list is the
        report's contract, and a wall clock is a fact about this run on this machine rather than
        about the mutant -- putting it in the row would make two identical runs produce different
        reports.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Job,
        [Parameter(Mandatory)] [string]$State,
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$SandboxRoot
    )
    $verdict = Complete-PSMutantEvaluation -Job $Job -Expired:($State -eq 'Expired')
    return [pscustomobject]@{
        Row = New-PSMutationResultRow -Candidate $Job.Source -Verdict $verdict `
            -DisplayFile (ConvertFrom-PSMutationSandboxPath -Path $Job.Source.File -SandboxRoot $SandboxRoot)
        Seconds = $Job.Clock.Elapsed.TotalSeconds
    }
}

function Complete-PSMutationSweep {
    # Collect every worker whose mutant has finished or run out of budget, and free its slot.
    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Schedule, [Parameter(Mandatory)] $Context)
    # Over the JOBS, not over slot indices, and the job says which slot it came from. Written as a
    # `for` over `0..Count-1` the bound is a boundary nothing can observe: reading one slot past
    # the end yields $null, which the idle-slot guard skips, so `-lt` and `-le` produce identical
    # output -- an equivalent mutant that would have to be argued for rather than killed.
    foreach ($job in @($Schedule.InFlight | Where-Object { $null -ne $_ })) {
        $state = Get-PSMutationJobState -Completed $job.Async.IsCompleted `
            -ElapsedSeconds $job.Clock.Elapsed.TotalSeconds -TimeoutSeconds $Context.TimeoutSeconds
        if ($state -eq 'Running') { continue }
        # Freed BEFORE collecting, so a collection that throws cannot leave a slot pointing at a
        # job nobody will ever wait on again.
        $Schedule.InFlight[$job.WorkerId] = $null
        $Schedule.Parked[$job.Index] = Get-PSMutationFinishedMutant -Job $job -State $state -SandboxRoot $Context.SandboxRoot
    }
}

function Get-PSMutationLoopFault {
    <#
    .SYNOPSIS
        The fault, if any, that should stop the run after one mutant is retired.
    .DESCRIPTION
        Sequenced in ONE place because the two are different questions and the order is the
        answer's quality: a mutant whose own clock says its bound never fired names the cause,
        and a run past its total budget names only the symptom.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [double]$MutantSeconds,
        [Parameter(Mandatory)] [double]$ElapsedSeconds,
        [Parameter(Mandatory)] [int]$TimeoutSeconds,
        [Parameter(Mandatory)] [int]$DeadlineSeconds,
        [Parameter(Mandatory)] [int]$Index,
        [Parameter(Mandatory)] [int]$Total
    )
    $stalled = Get-PSMutationStalledFault -MutantSeconds $MutantSeconds -TimeoutSeconds $TimeoutSeconds `
        -Index $Index -Total $Total
    if ($stalled) { return $stalled }
    return Get-PSMutationOverBudgetFault -ElapsedSeconds $ElapsedSeconds -DeadlineSeconds $DeadlineSeconds `
        -Index $Index -Total $Total
}

function Complete-PSMutationRetirement {
    <#
    .SYNOPSIS
        Record every finished mutant whose turn has come, IN CANDIDATE ORDER.
    .DESCRIPTION
        Workers finish out of order -- a killed mutant stops at the first failing test and a
        survivor runs the whole suite, which is a 6x spread measured on this repo's sibling --
        and a report whose row order depended on that would differ between two runs of the same
        config. So a finished mutant is parked under its candidate index and only recorded once
        every mutant before it has been.

        That is one mechanism answering three requirements at once. The report is deterministic,
        the progress line stays a monotonic [n/total] rather than jumping about, and the partial
        report an interrupted run writes is a genuine PREFIX of the full one instead of whichever
        mutants happened to land first. Nothing waits on it: at most one worker-count's worth of
        mutants can be finished and unrecorded, and they are still finished.
    #>

    [OutputType([void])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Schedule, [Parameter(Mandatory)] $Context)
    while ($Schedule.Parked.ContainsKey($Schedule.Retired)) {
        $done = $Schedule.Parked[$Schedule.Retired]
        $Schedule.Parked.Remove($Schedule.Retired)
        $Schedule.Retired++
        $Context.Sink.Add($done.Row)
        Write-PSMutationOutput -Quiet:$Context.Quiet -Lines (Get-PSMutationProgressLine -Index $Schedule.Retired `
                -Total $Context.Total -Result $done.Row -DisplayFile (Split-Path $done.Row.File -Leaf))
        # Not passed -Quiet: a non-interactive host renders no progress at all, so there is
        # nothing for -Quiet to silence, and a local run keeps the one signal that tells a hung
        # run from a slow one even when the log is off.
        Write-PSMutationProgress -Index $Schedule.Retired -Total $Context.Total -Activity 'Evaluating mutants'
        # Checked AFTER the row is in the sink. A run stopped here has already recorded
        # everything it finished, so the partial report written on the way out says how far it
        # got -- which is the difference between a diagnosable stop and the zero-byte report an
        # overnight hang leaves.
        $fault = Get-PSMutationLoopFault -MutantSeconds $done.Seconds -TimeoutSeconds $Context.TimeoutSeconds `
            -ElapsedSeconds $Context.RunClock.Elapsed.TotalSeconds -DeadlineSeconds $Context.DeadlineSeconds `
            -Index $Schedule.Retired -Total $Context.Total
        if ($fault) { throw $fault }
    }
}

function Invoke-PSMutationLoop {
    <#
    .SYNOPSIS
        Evaluate every candidate across one or more workers; return the result rows.
    .DESCRIPTION
        A serial run is a pool of ONE worker through this same scheduler, not a separate simpler
        route. Two paths would be two places where a verdict is decided, and they would disagree
        in whichever case nobody tests -- so `workers` changes how many mutants are in flight and
        nothing else. The report is identical either way, which tests/EndToEnd.Tests.ps1 asserts
        by running the same fixture both ways and comparing.

        Each worker owns its own sandbox copy and its own Pester-loaded runspace, so nothing is
        shared but the read-only candidate list. That is what the isolation rests on: two workers
        writing the same file would splice one mutant over another and score both against the
        wrong source.
    #>

    [OutputType([object[]])]
    [CmdletBinding()]
    param(
        # Empty is a legitimate input, not an error: a mutate file may contribute no
        # covered candidates, and a recheck run whose previous survivors are all dead
        # has nothing left to evaluate. Both should report zero, not throw.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Candidates,
        [Parameter(Mandatory)] [hashtable]$TestsByFile,
        [Parameter(Mandatory)] [string[]]$AllTests,
        [Parameter(Mandatory)] [int]$TimeoutSeconds,
        # The caller's accumulator, so an INTERRUPTED run still has its rows.
        #
        # A run is long -- long enough that losing one to Ctrl-C or a cancelled CI job is an
        # ordinary event rather than an exceptional one -- and every row lived in a local list
        # that died with the loop. Handing the list in means the caller still holds whatever was
        # evaluated after the loop stops, however it stopped.
        #
        # MANDATORY rather than optional-with-a-fallback. An internal list used when none was
        # supplied would be a branch whose two arms produce identical output, which no test could
        # tell from its own absence -- the same reason -UnitTable is mandatory in the sibling.
        # AllowEmptyCollection because it is ALWAYS empty here: a mandatory parameter refuses an
        # empty collection, so without this the loop threw on every single call.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]]$Sink,
        [string]$SandboxRoot,
        # The EXTRA sandboxes, one per additional worker; worker 0 mutates the primary sandbox.
        # The worker count is derived from this rather than given beside it, so there is no way
        # to ask for four workers and hand over two sandboxes.
        [AllowEmptyCollection()] [string[]]$WorkerSandbox = @(),
        [switch]$Quiet,
        [switch]$RecordAllKillers,
        # Wall-clock budget for the whole loop. Zero disables it. Checked as a mutant is RETIRED,
        # never inside one, so the mutant it stops after has already had its file restored. Other
        # workers may still be mid-flight with a file spliced -- their sandboxes are removed by
        # the run's own finally, which is the same guarantee that covers a Ctrl-C.
        [int]$DeadlineSeconds = 0
    )
    $roots = [string[]]@(@($SandboxRoot) + @($WorkerSandbox))
    $context = [pscustomobject]@{
        Candidates = $Candidates; Total = $Candidates.Count; Roots = $roots; SandboxRoot = $SandboxRoot
        TestsByFile = $TestsByFile; AllTests = $AllTests; TimeoutSeconds = $TimeoutSeconds
        RecordAllKillers = [bool]$RecordAllKillers; Quiet = [bool]$Quiet
        DeadlineSeconds = $DeadlineSeconds; Originals = @{}; Sink = $Sink
        RunClock = [System.Diagnostics.Stopwatch]::StartNew()
    }
    # Mutable, and a pscustomobject rather than a hashtable so the four fields are named at every
    # use. The counters have to live here rather than in locals: dispatch, sweep and retirement
    # each move them, and PowerShell would give each function its own copy of a local.
    $schedule = [pscustomobject]@{
        InFlight = [object[]]::new($roots.Count); Parked = @{}; Next = 0; Retired = 0
    }
    while ($schedule.Retired -lt $context.Total) {
        Start-PSMutationDispatch -Schedule $schedule -Context $context
        Wait-PSMutationWorker -Schedule $schedule -Context $context
        Complete-PSMutationSweep -Schedule $schedule -Context $context
        Complete-PSMutationRetirement -Schedule $schedule -Context $context
    }
    return , $Sink.ToArray()
}