src/PSMutation.Config.ps1

<#
.SYNOPSIS
    Config resolution for the mutation run: the "what did the user ask for, and what
    do we do when they didn't say" decisions.

.DESCRIPTION
    A config object in, a value out: pure, no side effects, nothing to sandbox.

    Every documented default is resolved here, so this is the one place to read for
    "what happens if the config omits this" -- the operator set excepted, which resolves
    in PSMutation.Operators.ps1 beside the vocabulary it names.
#>


# Default subtrees copied into the sandbox when the config does not name any. A neutral
# module convention; a consuming repo overrides it with `sandboxSubtrees`.
#
# The sandbox takes -Subtrees as a mandatory parameter rather than defaulting: it is
# mechanism, and holds no opinion about a repo's layout.
$script:PSMutationDefaultSubtrees = @('src', 'tests')

# The config format has ONE definition: schemas/v1/config.schema.json, which also ships to
# consumers. The key names, the threshold sub-keys and the type of every value are read
# from it rather than restated here -- a second copy in PowerShell would be a second place
# to edit when a key is added, and the copy that was forgotten is the one that decides.
#
# Cached because it is read once per run and parsing it per call would be pointless work.
$script:PSMutationConfigSchema = $null

function Get-PSMutationConfigSchemaPath {
    # Where the shipped schema lives, relative to this file: src/ and schemas/ are siblings
    # in the repo and in the published package alike.
    #
    # The version is in the PATH, not in the URL's git ref. A `$schema` URL pointing at a
    # branch means the document a consumer validates against changes under them the day a
    # v2 lands; a versioned directory means v2 is a NEW file and every existing pointer
    # keeps resolving to the format it was written for. A v2 goes in schemas/v2/ beside
    # this one rather than replacing it.
    [OutputType([string])]
    [CmdletBinding()]
    param()
    return (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'schemas' -AdditionalChildPath 'v1', 'config.schema.json')
}

function Get-PSMutationConfigSchema {
    <#
    .SYNOPSIS
        The config schema, as raw text.

    .DESCRIPTION
        Throws a message naming the missing path rather than skipping validation. A
        validator that quietly does nothing when its schema is absent is the failure this
        project is organised around: every config would pass, including the ones that empty
        the per-mutant timeout and score every mutant as killed.

        The package smoke test asserts the schema ships, so this should only ever fire for
        a partially copied module.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param()
    if ($null -eq $script:PSMutationConfigSchema) {
        $path = Get-PSMutationConfigSchemaPath
        if (-not (Test-Path $path)) {
            throw "The PSMutant config schema is missing from this installation: $path. The module cannot validate a config without it."
        }
        $script:PSMutationConfigSchema = Get-Content $path -Raw
    }
    return $script:PSMutationConfigSchema
}

function Get-PSMutationConfigKey {
    # Every key the config understands, from the schema. A key the schema does not describe
    # resolves to $null and weakens the run in silence: `thresholds.brake` leaves the break
    # gate unable to fail, and `mutat` for `mutate` surfaces as a denied path inside the
    # sandbox, in a message mentioning neither the config nor the key.
    [OutputType([string[]])]
    [CmdletBinding()]
    param([string]$Section = 'config')
    $schema = Get-PSMutationConfigSchema | ConvertFrom-Json
    $node = if ($Section -eq 'thresholds') { $schema.properties.thresholds } else { $schema }
    return [string[]]@($node.properties.PSObject.Properties.Name)
}

function Get-PSMutationEditDistance {
    # Levenshtein distance between two strings.
    #
    # Exists only so a rejected key can say "did you mean 'break'?" rather than leaving the
    # reader to diff two lists by eye. The three typos this has to reach are a transposition
    # (brake/break, distance 2), a dropped letter (mutat/mutate, 1) and a wrong letter (1).
    [OutputType([int])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [AllowEmptyString()] [string]$From,
          [Parameter(Mandatory)] [AllowEmptyString()] [string]$To)
    $n = $From.Length
    $m = $To.Length
    # No empty-string guard, deliberately. The obvious `if ($n -eq 0) { return $m }` pair
    # reads like a necessary base case and is not one: with either length zero the loop
    # below simply does not run and the seeded row already holds the answer. Verified by
    # deleting both and re-running the table in Config.Tests.ps1 -- identical. They would
    # also have been two mutants nothing could ever kill.
    #
    # Two rows rather than a full matrix: only the previous row is ever read.
    $prev = [int[]](0..$m)
    # Sized from $prev rather than as ($m + 1). Same length, but written as a literal it is
    # a mutant nothing can kill: the row is a buffer, so an oversized one computes exactly
    # the same distances. Deriving the length removes the mutant instead of excusing it.
    $curr = [int[]]::new($prev.Length)
    for ($i = 1; $i -le $n; $i++) {
        $curr[0] = $i
        for ($j = 1; $j -le $m; $j++) {
            $cost = if ($From[$i - 1] -eq $To[$j - 1]) { 0 } else { 1 }
            $curr[$j] = [math]::Min([math]::Min($prev[$j] + 1, $curr[$j - 1] + 1), $prev[$j - 1] + $cost)
        }
        $prev = $curr.Clone()
    }
    return [int]$prev[$m]
}

function Get-PSMutationNearestName {
    # The valid name closest to a misspelling, or $null when nothing is close enough.
    #
    # Two edits is the cutoff because that is what a transposition costs; beyond it a
    # "did you mean" is a guess, and a wrong guess sends the reader off to change a key
    # that was never the problem.
    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string]$Name,
          [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Candidates)
    $best = $null
    $bestDistance = 3
    foreach ($c in $Candidates) {
        $d = Get-PSMutationEditDistance -From $Name.ToLowerInvariant() -To $c.ToLowerInvariant()
        if ($d -lt $bestDistance) {
            $bestDistance = $d
            $best = $c
        }
    }
    return $best
}

function Get-PSMutationUnknownKeyMessage {
    # The complaint about one unrecognised key, or $null when it is recognised.
    #
    # Returns the reason rather than throwing so the caller decides how to report, and so
    # the decision itself is a value a test can compare.
    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string]$Name,
          [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Known,
          [Parameter(Mandatory)] [string]$Where)
    # JSON has no comments; `_`-prefixed keys are how every config here explains itself.
    # `$schema` is exempt for a different reason: it points at the published config
    # schema, which is the format's definition. Rejecting the key would mean a config
    # cannot name the very schema it is written against.
    if ($Name.StartsWith('_') -or $Name -eq '$schema') { return $null }
    if ($Known -contains $Name) { return $null }
    $near = Get-PSMutationNearestName -Name $Name -Candidates $Known
    $hint = if ($near) { " Did you mean '$near'?" } else { '' }
    return "Unknown $Where key '$Name'.$hint Valid keys: $(($Known | Sort-Object) -join ', ')."
}

function Get-PSMutationNameFault {
    # The first complaint about an unrecognised NAME anywhere in a config, or $null.
    #
    # Keys, threshold sub-keys and operator names are one question asked three times, so
    # they answer in one place: a caller that checked two of the three would leave a whole
    # class of typo silently accepted, which is the failure this checking exists to stop.
    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Cfg)
    foreach ($prop in $Cfg.PSObject.Properties) {
        $why = Get-PSMutationUnknownKeyMessage -Name $prop.Name -Known (Get-PSMutationConfigKey) -Where 'config'
        if ($why) { return $why }
    }
    foreach ($prop in $Cfg.thresholds.PSObject.Properties) {
        $why = Get-PSMutationUnknownKeyMessage -Name $prop.Name -Known (Get-PSMutationConfigKey -Section 'thresholds') -Where 'thresholds'
        if ($why) { return $why }
    }
    foreach ($op in @($Cfg.operators)) {
        if ($null -eq $op) { continue }
        $why = Get-PSMutationUnknownKeyMessage -Name $op -Known (Get-PSMutationKnownOperator) -Where 'operators'
        if ($why) { return $why }
    }
    return $null
}

function Get-PSMutationConfigTypeFault {
    <#
    .SYNOPSIS
        Why a config does not match the schema, or $null when it does.

    .DESCRIPTION
        The schema decides. Both of PowerShell's coercions fail OPEN -- a string where a
        number belongs makes the timeout arithmetic yield nothing, and any non-empty string
        is $true -- so an unchecked type does not error, it produces a confident wrong
        answer. The timeout is the worst of them: an expiry is scored as a KILL, so the run
        reports a number it never measured.

        The config is re-serialised because Test-Json validates TEXT while the caller holds
        an object. -Depth 10 against a format that nests three deep, so nothing is quietly
        truncated into validity.

    .PARAMETER Cfg
        The parsed config.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Cfg)
    $json = $Cfg | ConvertTo-Json -Depth 10
    $ok = Test-Json -Json $json -Schema (Get-PSMutationConfigSchema) -ErrorAction SilentlyContinue -ErrorVariable schemaErrors
    if ($ok) { return $null }
    # EVERY violation, not just the first. Test-Json writes one error per violation and the
    # first is not reliably the one the reader caused, so reporting only that sends them to
    # a line that is fine.
    $detail = @($schemaErrors | ForEach-Object {
            $_.Exception.Message -replace '^The JSON is not valid with the schema: ', ''
        }) -join '; '
    return "The config does not match the PSMutant config schema: $detail"

}

function Assert-PSMutationConfig {
    # Refuse a config that asks for something this module does not understand.
    #
    # An error rather than a warning, deliberately: a warning in a CI log is
    # indistinguishable from silence, and every failure here makes the gate WEAKER while
    # the run stays green -- the exact class of silent wrong answer this tool exists to
    # find in other people's code.
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Cfg)
    # The ORDER is the message quality. Each check below can also be reached by the schema,
    # which is deliberate -- the schema is what consumers validate against -- but the schema
    # answers in JSON-pointer terms, and these three answer in terms of what to go and do.
    #
    # 1. A misspelled key, reported as a misspelling with the nearest valid name. The schema
    # would call it "property not allowed", which does not say `break` when you wrote
    # `brake`.
    $why = Get-PSMutationNameFault -Cfg $Cfg
    if ($why) { throw $why }

    # 2. Missing or empty `mutate` / `tests`, reported as what the key is FOR. The schema's
    # "required properties are not present" is true and teaches nothing.
    #
    # Before the type check, because these are about the value being absent or empty
    # rather than the wrong kind. An object in `mutate` still reaches the schema and is
    # named as a type error, which is the better answer for that case.
    if (@($Cfg.mutate).Where({ $_ }).Count -eq 0) {
        throw "Config must set 'mutate' to a non-empty list of files to mutate."
    }
    # .Where({ $_ }) rather than a bare .Count: with no `tests` key at all, .PSObject
    # yields $null and @($null) is an array of ONE, so a plain count reads a missing map
    # as a populated one.
    if (@($Cfg.tests.PSObject.Properties).Where({ $_ }).Count -eq 0) {
        throw "Config must set 'tests' to a map of mutate file -> the test file(s) covering it."
    }

    # 3. Everything about shape and type, decided by the schema alone.
    $why = Get-PSMutationConfigTypeFault -Cfg $Cfg
    if ($why) { throw $why }
}

function Get-PSMutationCoveredLinesOnly {
    # Whether to mutate only the lines the baseline actually executed.
    #
    # Defaults to TRUE, which is what the README promises and what every example sets.
    #
    # Resolved rather than cast at the call site, because `[bool]$null` is $false: an
    # omitted key would silently mean "mutate uncovered lines too", and every mutant on an
    # uncovered line is a guaranteed survivor. That reports a materially worse score than
    # the tool is designed to give, and measures coverage rather than test quality, which
    # is a separate gate.
    [OutputType([bool])]
    [CmdletBinding()]
    param($Cfg)
    if ($null -eq $Cfg.coveredLinesOnly) { return $true }
    return [bool]$Cfg.coveredLinesOnly
}

function Get-PSMutationSandboxPlan {
    # Translate the config's source-relative mutate/tests into sandbox absolute paths.
    #
    # Pure string work, and the last piece of config resolution that was still sitting
    # in the orchestrator rather than here.
    [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 Get-PSMutationSubtree {
    # Which source subtrees get copied into the sandbox. A consuming repo overrides
    # this to match its own layout; unset means the module's own convention.
    [OutputType([string[]])]
    [CmdletBinding()]
    param($Cfg)
    if ($Cfg.sandboxSubtrees) { return [string[]]@($Cfg.sandboxSubtrees) }
    return [string[]]$script:PSMutationDefaultSubtrees
}

function Get-PSMutationScoreBand {
    # The bands the console summary colours the score by: at or above High is green, at or
    # above Low is yellow, below Low is red.
    #
    # Resolved here, with defaults, because in PowerShell any number `-ge $null` is $true.
    # Compare a raw config value and a config with no thresholds -- or the entirely
    # reasonable `{"thresholds":{"break":80}}` -- makes the first branch always win and
    # prints EVERY score green, `Mutation score: 0% (0 killed / 42)` included, in a run
    # that exits 0. The smallest possible instance of the failure this tool exists to
    # prevent, in front of the person least able to spot it: a new adopter whose thresholds
    # are not tuned yet.
    #
    # Tested with `$null -ne`, not for truthiness like the resolvers above. A band of 0 is a
    # meaningful setting -- "never colour this red" -- and 0 is falsy, so a truthiness test
    # would silently substitute the default for it.
    [OutputType([hashtable])]
    [CmdletBinding()]
    param($Cfg)
    $high = if ($null -ne $Cfg.thresholds.high) { $Cfg.thresholds.high } else { 85 }
    $low = if ($null -ne $Cfg.thresholds.low) { $Cfg.thresholds.low } else { 70 }
    return @{ High = [double]$high; Low = [double]$low }
}

function Get-PSMutationTimeout {
    # Per-mutant timeout in whole seconds.
    #
    # A mutant should never take much longer than the baseline suite, so the budget
    # is max(floor, baseline x factor). The floor matters for fast suites: a baseline
    # of 0.2s would otherwise give a 0-second budget and kill every mutant on time
    # rather than on behaviour, scoring 100% against tests that never ran. The factor
    # matters for slow ones. A mutant that runs past this is cut off and counted
    # Killed -- which is the right answer for a non-terminating loop.
    [OutputType([int])]
    [CmdletBinding()]
    param($Cfg, [Parameter(Mandatory)] [double]$BaselineSeconds)
    $factor = if ($Cfg.timeoutFactor) { $Cfg.timeoutFactor } else { 4 }
    $floor = if ($Cfg.timeoutFloorSeconds) { $Cfg.timeoutFloorSeconds } else { 15 }
    $budget = [int][math]::Max($floor, $BaselineSeconds * $factor)

    # Refuse a budget the unmutated suite could not itself meet. Below that line every
    # mutant expires on the clock rather than on behaviour, and an expiry is scored as a
    # KILL -- so the run comes back 100% over tests that never finished. That is the
    # failure the floor above exists to prevent, and nothing was bounding the result.
    #
    # An error rather than a clamp. Clamping would run to completion under a budget the
    # config did not ask for and cannot be seen in the report; the two configs that reach
    # here are a floor and factor that are both tiny, and neither is a thing anyone means.
    $least = [math]::Max(1, $BaselineSeconds)
    if ($budget -lt $least) {
        throw ("Per-mutant timeout resolves to ${budget}s, which is below the " +
            "$([math]::Round($BaselineSeconds, 1))s the unmutated suite took. Every mutant would " +
            "expire on the clock rather than on behaviour, and an expiry is scored as a kill -- " +
            "the run would report a perfect score over tests that never finished. Raise " +
            "'timeoutFloorSeconds' (currently $floor) or 'timeoutFactor' (currently $factor).")
    }
    return $budget
}