src/PSMutation.Report.ps1
|
<# .SYNOPSIS Scoring, JSON report, and console summary for the PowerShell mutation runner. Split from the execution engine so each unit stays small and independently testable. #> function Get-PSMutationEquivalentKey { # The identity a config declares an equivalent mutant by. Line + description # rather than mutant id: a declaration should survive an unrelated edit # elsewhere in the file, which renumbers ids but does not move this mutant. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] $Result) return "$($Result.File):$($Result.Line):$($Result.Description)" } function Get-PSMutationDeclaredEquivalent { # Pure: normalise the config's `equivalents` object into key -> reason. A # declaration with a blank reason is dropped, so "equivalent" always comes with # a stated argument someone can disagree with. [OutputType([hashtable])] [CmdletBinding()] param($Equivalents) $map = @{} if ($null -eq $Equivalents) { return $map } foreach ($p in $Equivalents.PSObject.Properties) { if (-not [string]::IsNullOrWhiteSpace([string]$p.Value)) { $map[$p.Name] = [string]$p.Value } } return $map } function Get-PSMutationScore { # Pure: turn result rows into a score summary. No I/O. # # A mutant declared equivalent leaves the DENOMINATOR only while it actually # survives. If one is killed, the declaration was wrong, and rather than # quietly banking the kill it is surfaced as stale: a config that claims a # mutant cannot be caught, next to a test that caught it, is a false statement # about the code and the whole point of demanding a reason is that it can be # checked. Same for a declaration matching no mutant at all -- the code moved # and nobody revisited the claim. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Results, $Equivalents ) $declared = Get-PSMutationDeclaredEquivalent -Equivalents $Equivalents $stale = [System.Collections.Generic.List[string]]::new() $matched = [System.Collections.Generic.HashSet[string]]::new() $killed = 0; $survived = 0; $excluded = 0 foreach ($r in $Results) { $key = Get-PSMutationEquivalentKey -Result $r $isDeclared = $declared.ContainsKey($key) if ($isDeclared) { [void]$matched.Add($key) } if ($r.Status -eq 'Killed') { $killed++ if ($isDeclared) { $stale.Add("$key -- declared equivalent but the suite killed it") } } elseif ($isDeclared) { $excluded++ } else { $survived++ } } foreach ($k in $declared.Keys) { if (-not $matched.Contains($k)) { $stale.Add("$k -- declared equivalent but no such mutant exists") } } $total = $Results.Count - $excluded $score = if ($total -gt 0) { [math]::Round(100.0 * $killed / $total, 1) } else { 0 } return [pscustomobject]@{ Score = $score; Killed = $killed; Survived = $survived; Total = $total DeclaredEquivalent = $excluded; StaleEquivalents = $stale.ToArray() } } function Get-PSMutationExitCode { # Report-only unless thresholds.break is set and the score is below it. Pure. # # A stale equivalence declaration fails the run REGARDLESS of thresholds, and # regardless of report-only mode: it is not a quality shortfall to be graded on # a curve, it is a false statement in the config that is inflating the score. [OutputType([int])] [CmdletBinding()] param([Parameter(Mandatory)] $Summary, $Thresholds) # Filter before counting: @($null).Count is 1, not 0, so a summary that carries # no stale list at all would otherwise fail every run. if (@($Summary.StaleEquivalents | Where-Object { $_ }).Count -gt 0) { return 1 } if ($null -ne $Thresholds.break -and $Summary.Score -lt $Thresholds.break) { return 1 } return 0 } function Write-PSMutationReport { # Write the JSON report; return the summary. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Results, [Parameter(Mandatory)] [string]$ReportPath, $Thresholds, [hashtable]$SourceHashes, [string[]]$Operators, $Equivalents ) $summary = Get-PSMutationScore -Results $Results -Equivalents $Equivalents New-Item -ItemType Directory -Path (Split-Path $ReportPath -Parent) -Force | Out-Null [pscustomobject]@{ generatedFrom = 'PSMutant' mutationScore = $summary.Score total = $summary.Total; killed = $summary.Killed; survived = $summary.Survived # Reported so the headline score can always be reconciled against the raw # mutant count: total EXCLUDES declared equivalents, and a reader who cannot # see how many were excluded cannot tell a real 100% from a declared one. declaredEquivalent = $summary.DeclaredEquivalent staleEquivalents = @($summary.StaleEquivalents) thresholds = $Thresholds # Recorded so a later -RecheckFrom can prove the mutant numbering in this # report still refers to the same code. Mutant Ids come from AST walk order, # so they only mean anything for identical source and an identical operator # set; without these two fields a recheck could match the wrong mutants and # report a confident, wrong answer. operators = @($Operators | Sort-Object) sourceHashes = $SourceHashes survivors = @($Results | Where-Object Status -eq 'Survived') mutants = $Results } | ConvertTo-Json -Depth 6 | Set-Content $ReportPath return $summary } function Show-PSMutationSummary { # Human-readable summary + the list of survivors to go add assertions for. [CmdletBinding()] param( [Parameter(Mandatory)] $Summary, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Results, $Thresholds, [string]$ReportPath, $Equivalents ) $col = if ($Summary.Score -ge $Thresholds.high) { 'Green' } elseif ($Summary.Score -ge $Thresholds.low) { 'Yellow' } else { 'Red' } Write-Host "`n----------------------------------------------" -ForegroundColor DarkGray Write-Host (" Mutation score: {0}% ({1} killed / {2})" -f $Summary.Score, $Summary.Killed, $Summary.Total) -ForegroundColor $col # Printed next to the score, not buried in the report: a 100% built on a dozen # declared equivalents is a different claim from a 100% that killed everything. if ($Summary.DeclaredEquivalent -gt 0) { Write-Host (" {0} mutant(s) excluded as declared-equivalent (see config)" -f $Summary.DeclaredEquivalent) -ForegroundColor DarkGray } $stale = @($Summary.StaleEquivalents | Where-Object { $_ }) # @($null).Count is 1 if ($stale.Count -gt 0) { Write-Host " STALE equivalence declarations - the config is claiming something untrue:" -ForegroundColor Red $stale | ForEach-Object { Write-Host " $_" -ForegroundColor Red } } # A declared equivalent is not a survivor to go and fix: listing it here sends # the reader after a mutant the config already argued is unkillable, which is # how a good declaration gets "fixed" with a meaningless test. $declared = Get-PSMutationDeclaredEquivalent -Equivalents $Equivalents $open = @($Results | Where-Object { $_.Status -eq 'Survived' -and -not $declared.ContainsKey((Get-PSMutationEquivalentKey -Result $_)) }) if ($open.Count -gt 0) { Write-Host " Survivors (add assertions to kill these):" -ForegroundColor Yellow $open | ForEach-Object { Write-Host (" {0}:{1} {2}" -f $_.File, $_.Line, $_.Description) -ForegroundColor Yellow } } Write-Host " Report: $ReportPath" -ForegroundColor Gray } |