src/PSMutation.Recheck.ps1
|
<# .SYNOPSIS Re-evaluating only the mutants that survived a previous run. .DESCRIPTION The write-tests-kill-survivors loop is the slow part of using this tool: every iteration re-runs the whole mutant set, and the mutants you already killed cost exactly as much as the ones you are working on. A recheck runs ONLY the mutants a previous report recorded as survivors. This is a development-loop convenience, and it is deliberately not a measurement: * It cannot produce a mutation score. Killing 6 of 10 previous survivors is not "60%" of anything -- the denominator is a filtered set. * It is sound only for test changes that are purely ADDITIVE. Editing or deleting an existing test can bring a previously-killed mutant back to life, and a recheck never looks at those, so it would report success while the real score fell. Run the full set before trusting a number or moving a threshold. Both facts are enforced rather than documented-and-hoped: the recheck writes to its own report path so it can never overwrite the full baseline, its console summary reports counts instead of a score, and thresholds are not applied. MATCHING. A mutant is identified by (File, Id). Id comes from the order the AST walk emits candidates, so it is stable for a given file content and operator set -- and meaningless if either changed. Rather than silently matching the wrong mutants, a recheck refuses to run when the recorded source hash or operator set no longer matches. That is why the report carries both. #> function Get-PSMutationSourceHash { # SHA256 of a file's bytes, lowercase hex. Used to prove the source a report # was produced from is byte-identical to the source in front of us now. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Path) $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.IO.File]::ReadAllBytes($Path) return [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Get-PSMutationSourceHashMap { # displayPath -> hash, for every file in the mutate set. [OutputType([hashtable])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$MutateFiles, [Parameter(Mandatory)] [string]$SandboxRoot ) $map = @{} foreach ($f in $MutateFiles) { $map[(ConvertFrom-PSMutationSandboxPath -Path $f -SandboxRoot $SandboxRoot)] = Get-PSMutationSourceHash -Path $f } return $map } function Test-PSMutationRecheckCompatible { # Pure. Compare a prior report against the current source hashes + operator set # and return the reasons a recheck would be unsound (empty = compatible). [OutputType([string[]], [object[]])] [CmdletBinding()] param( [Parameter(Mandatory)] $Report, [Parameter(Mandatory)] [hashtable]$SourceHashes, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Operators ) $reasons = [System.Collections.Generic.List[string]]::new() if (-not $Report.PSObject.Properties.Name.Contains('sourceHashes') -or $null -eq $Report.sourceHashes) { $reasons.Add('the report predates source-hash recording, so the mutants it lists cannot be matched to the current source -- run the full set once to regenerate it') return , $reasons.ToArray() } $priorOps = @($Report.operators) if (($priorOps -join ',') -ne (($Operators | Sort-Object) -join ',')) { $reasons.Add("the operator set changed ('$($priorOps -join ', ')' -> '$(($Operators | Sort-Object) -join ', ')'), which renumbers every mutant") } foreach ($name in ($SourceHashes.Keys | Sort-Object)) { $prior = $Report.sourceHashes.$name if (-not $prior) { $reasons.Add("$name is not in the report (it was added to the mutate set since)") } elseif ($prior -ne $SourceHashes[$name]) { $reasons.Add("$name changed since the report was written") } } return , $reasons.ToArray() } function Select-PSMutationRecheckCandidate { # Pure. Keep only the candidates a prior report recorded as Survived, matched on # (File, Id). Candidates are sandbox-absolute; report files are display paths. [OutputType([object[]])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Candidates, [Parameter(Mandatory)] $Report, [Parameter(Mandatory)] [string]$SandboxRoot ) # A set, not a hashtable-with-dummy-values: the value stored against each key was # never read (ContainsKey ignores it), so it was noise that looked like data. $wanted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($s in @($Report.survivors)) { [void]$wanted.Add("$($s.File)|$($s.Id)") } $out = [System.Collections.Generic.List[object]]::new() foreach ($c in $Candidates) { $key = "$(ConvertFrom-PSMutationSandboxPath -Path $c.File -SandboxRoot $SandboxRoot)|$($c.Id)" if ($wanted.Contains($key)) { $out.Add($c) } } return , $out.ToArray() } function Get-PSMutationRecheckReportPath { # Sibling of the full report, never the same file -- a partial run must not be # able to overwrite the baseline it was derived from. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$ReportPath) $dir = Split-Path $ReportPath -Parent $name = [System.IO.Path]::GetFileNameWithoutExtension($ReportPath) $ext = [System.IO.Path]::GetExtension($ReportPath) return (Join-Path $dir "$name.recheck$ext") } function Write-PSMutationRecheckReport { # Write the recheck JSON. Deliberately carries NO mutationScore field: the set # is filtered, so any percentage computed over it would be read as a file score. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Results, [Parameter(Mandatory)] [string]$ReportPath, [Parameter(Mandatory)] [int]$PriorSurvivorCount, [string]$SourceReportPath ) $killed = @($Results | Where-Object Status -eq 'Killed').Count New-Item -ItemType Directory -Path (Split-Path $ReportPath -Parent) -Force | Out-Null [pscustomobject]@{ generatedFrom = 'PSMutant' mode = 'Recheck' note = 'Partial run over a previous report''s survivors. Not a mutation score; a full run is required before trusting a number, because edited or deleted tests can revive mutants this run never evaluated.' recheckedFrom = $SourceReportPath priorSurvivors = $PriorSurvivorCount rechecked = $Results.Count nowKilled = $killed stillSurviving = @($Results | Where-Object Status -eq 'Survived') mutants = $Results } | ConvertTo-Json -Depth 6 | Set-Content $ReportPath return [pscustomobject]@{ Mode = 'Recheck'; PriorSurvivors = $PriorSurvivorCount Rechecked = $Results.Count; NowKilled = $killed StillSurviving = $Results.Count - $killed } } function Show-PSMutationRecheckSummary { # Counts, not a score, and the caveat every time -- a recheck that reads like a # measurement is exactly how a filtered number ends up quoted as a real one. [CmdletBinding()] param( [Parameter(Mandatory)] $Summary, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Results, [string]$ReportPath ) $col = if ($Summary.StillSurviving -eq 0) { 'Green' } else { 'Yellow' } Write-Host "`n----------------------------------------------" -ForegroundColor DarkGray Write-Host (" Recheck: {0} of {1} previous survivor(s) now killed" -f $Summary.NowKilled, $Summary.Rechecked) -ForegroundColor $col if ($Summary.StillSurviving -gt 0) { Write-Host " Still surviving:" -ForegroundColor Yellow $Results | Where-Object Status -eq 'Survived' | ForEach-Object { Write-Host (" {0}:{1} {2}" -f $_.File, $_.Line, $_.Description) -ForegroundColor Yellow } } Write-Host " Not a mutation score - this run skipped every mutant that was already killed." -ForegroundColor DarkGray Write-Host " Run the full set before trusting a number: edited tests can revive mutants this run never saw." -ForegroundColor DarkGray Write-Host " Report: $ReportPath" -ForegroundColor Gray } |