Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,165 +1,80 @@
# Compute-CrapScores.ps1
#
# Reads a Cobertura XML coverage file and calculates CRAP scores per method.
# Uses Alberto Savoia's original CRAP formula:
# CRAP(m) = comp(m)^2 * (1 - cov(m))^3 + comp(m)
#
# Usage:
# .\Compute-CrapScores.ps1 -CoberturaPath <path1>,<path2>,... [-CrapThreshold <int>] [-TopN <int>]
#
# Outputs:
# - OVERALL_LINE_COVERAGE:<n.n> (aggregate line coverage across input files, as percent)
# - OVERALL_BRANCH_COVERAGE:<n.n> (aggregate branch coverage across input files, as percent)
# - TOTAL_METHODS:<n>
# - FLAGGED_METHODS:<n>
# - HOTSPOTS:<json> (top N by CRAP score)

<#
.SYNOPSIS
Compute CRAP = complexity^2 * (1 - line coverage)^3 + complexity.
.DESCRIPTION
Missing complexity or executable lines yield unknown, never complexity=1/coverage=0.
Legacy output retains OVERALL_*, TOTAL_METHODS, FLAGGED_METHODS and HOTSPOTS markers
only when all requested measurements are representable. -OutputFormat json-v1
emits one provenance-bearing JSON document. Diagnostics go to stderr.
Thresholds and hotspot ordering use raw scores; only displayed scores are rounded.
Exit codes: 0 complete, 2 invalid input, 3 incomplete/unrepresentable evidence.
#>
param(
[Parameter(Mandatory)][string[]]$CoberturaPath,
[int]$CrapThreshold = 30,
[int]$TopN = 10
[ValidateRange(0,[int]::MaxValue)][int]$CrapThreshold = 30,
[ValidateRange(1,[int]::MaxValue)][int]$TopN = 10,
[ValidateSet('legacy', 'json-v1')][string]$OutputFormat = 'legacy'
)

# Merge methods across all Cobertura files using a stable key (Class|Method|Signature|File).
# Line hits are accumulated so a line is counted as covered if any input coverage file covered it.
$methodMap = @{}
$overallLineRate = 0.0
$overallBranchRate = 0.0
$totalLinesCovered = 0
$totalLinesValid = 0
$totalBranchesCovered = 0
$totalBranchesValid = 0
$fallbackLineRates = [System.Collections.Generic.List[double]]::new()
$fallbackBranchRates = [System.Collections.Generic.List[double]]::new()

foreach ($filePath in $CoberturaPath) {
if (-not (Test-Path $filePath)) {
Write-Error "Cobertura file not found: $filePath"
exit 2
}

try {
[xml]$cobertura = Get-Content $filePath -Encoding UTF8 -ErrorAction Stop
} catch {
Write-Error "Failed to parse Cobertura XML: $filePath. $_"
exit 2
}

# Prefer aggregate numerator/denominator attributes when present.
if ($null -ne $cobertura.coverage.'lines-covered' -and $null -ne $cobertura.coverage.'lines-valid') {
$totalLinesCovered += [double]$cobertura.coverage.'lines-covered'
$totalLinesValid += [double]$cobertura.coverage.'lines-valid'
} elseif ($cobertura.coverage.'line-rate') {
$fallbackLineRates.Add([double]$cobertura.coverage.'line-rate')
}
if ($null -ne $cobertura.coverage.'branches-covered' -and $null -ne $cobertura.coverage.'branches-valid') {
$totalBranchesCovered += [double]$cobertura.coverage.'branches-covered'
$totalBranchesValid += [double]$cobertura.coverage.'branches-valid'
} elseif ($cobertura.coverage.'branch-rate') {
$fallbackBranchRates.Add([double]$cobertura.coverage.'branch-rate')
}

foreach ($package in $cobertura.coverage.packages.package) {
foreach ($class in $package.classes.class) {
$className = $class.name
$fileName = $class.filename

foreach ($method in $class.methods.method) {
$key = "$className|$($method.name)|$($method.signature)|$fileName"

# Cyclomatic complexity is stored as an XML attribute in Cobertura format
$complexity = if ($null -ne $method.complexity) { [int]$method.complexity } else { 1 }
if ($complexity -lt 1) { $complexity = 1 }

if (-not $methodMap.ContainsKey($key)) {
$methodMap[$key] = @{
Class = $className
Method = $method.name
Signature = $method.signature
File = $fileName
Complexity = $complexity
LineHits = @{}
}
}

# Accumulate hit counts per line number across files
foreach ($line in $method.lines.line) {
$lineNo = $line.number
$hits = [int]$line.hits
if ($methodMap[$key].LineHits.ContainsKey($lineNo)) {
$methodMap[$key].LineHits[$lineNo] += $hits
} else {
$methodMap[$key].LineHits[$lineNo] = $hits
}
}
}
}
try {
. "$PSScriptRoot\CoverageData.ps1"
} catch {
$diagnostic = @{ code = 'DEPENDENCY_UNAVAILABLE'; severity = 'error'; message = $_.Exception.Message; path = "$PSScriptRoot\CoverageData.ps1" }
[Console]::Error.WriteLine("$($diagnostic.code): $($diagnostic.message)")
if ($OutputFormat -eq 'json-v1') {
@{
schema_version = '1'; tool = 'Compute-CrapScores'; tool_version = '1.0'; status = 'error'
scope = @{}; inputs = @(); diagnostics = @($diagnostic); data = @{}
} | ConvertTo-Json -Depth 5
}
exit 2
}

$results = [System.Collections.Generic.List[PSCustomObject]]::new()

foreach ($entry in $methodMap.Values) {
$totalLines = $entry.LineHits.Count
$coveredLines = ($entry.LineHits.Values | Where-Object { $_ -gt 0 } | Measure-Object).Count
$lineCoverage = if ($totalLines -gt 0) { $coveredLines / $totalLines } else { 0.0 }

$complexity = $entry.Complexity

# Alberto Savoia's CRAP formula: comp^2 * (1 - cov)^3 + comp
# The cubic exponent on (1-cov) sharply penalizes low coverage:
# at 0% coverage the risk multiplier is 1.0; at 50% it drops to 0.125.
# Higher scores = more complex AND less covered = riskier to change
$uncovered = 1.0 - $lineCoverage
$crapScore = [Math]::Round(($complexity * $complexity * [Math]::Pow($uncovered, 3)) + $complexity, 2)

$results.Add([PSCustomObject]@{
Class = $entry.Class
Method = $entry.Method
Signature = $entry.Signature
File = $entry.File
TotalLines = $totalLines
CoveredLines = $coveredLines
LineCoverage = [Math]::Round($lineCoverage * 100, 1)
Complexity = $complexity
CrapScore = $crapScore
})
$scope = @{ identity = 'reported-module-file-class-signature'; source_revision = $null; top = $TopN; crap_threshold = $CrapThreshold }
try {
$coverage = Get-CoverageData $CoberturaPath
} catch {
$diagnostics = @([pscustomobject]@{ code = 'INVALID_INPUT'; severity = 'error'; message = $_.Exception.Message })
Write-CoverageDiagnostics $diagnostics
if ($OutputFormat -eq 'json-v1') { Write-CoverageJson 'Compute-CrapScores' 'error' $scope @() $diagnostics @{} }
exit 2
}

$hotspots = $results | Sort-Object CrapScore -Descending | Select-Object -First $TopN
$flagged = $results | Where-Object { $_.CrapScore -gt $CrapThreshold }

if ($totalLinesValid -gt 0) {
$overallLineRate = $totalLinesCovered / $totalLinesValid
} else {
# Fallback approximation when Cobertura aggregate counters and per-file rates are unavailable.
# This uses merged method line totals and may under/over-estimate if Cobertura
# includes executable lines outside method nodes.
$mergedTotalLines = ($results | Measure-Object -Property TotalLines -Sum).Sum
$mergedCoveredLines = ($results | Measure-Object -Property CoveredLines -Sum).Sum
if ($mergedTotalLines -gt 0) {
$overallLineRate = [double]$mergedCoveredLines / [double]$mergedTotalLines
} elseif ($fallbackLineRates.Count -gt 0) {
$overallLineRate = ($fallbackLineRates | Measure-Object -Average).Average
} else {
$overallLineRate = 0.0
}
foreach ($method in $coverage.Methods) {
$score = if ($null -ne $method.Complexity -and $method.LineCoverageStatus -eq 'exact') {
[math]::Pow($method.Complexity, 2) * [math]::Pow(1 - $method.CoveredLines / [double]$method.TotalLines, 3) + $method.Complexity
} else { $null }
$method | Add-Member -NotePropertyName CrapScore -NotePropertyValue $score
}

if ($totalBranchesValid -gt 0) {
$overallBranchRate = $totalBranchesCovered / $totalBranchesValid
} elseif ($fallbackBranchRates.Count -gt 0) {
$overallBranchRate = ($fallbackBranchRates | Measure-Object -Average).Average
} else {
$overallBranchRate = 0.0
$ranked = @($coverage.Methods | Where-Object { $null -ne $_.CrapScore } |
Sort-Object -Property @{Expression='CrapScore';Descending=$true}, Class, Method, File, Signature)
$hotspots = @($ranked | Select-Object -First $TopN)
$unknown = @($coverage.Methods | Where-Object { $null -eq $_.CrapScore }).Count
$coverage.Summary.flagged_methods = @($ranked | Where-Object { $_.CrapScore -gt $CrapThreshold }).Count
$coverage.Summary.unknown_crap_methods = $unknown
foreach ($method in $ranked) {
$method.CrapScore = [math]::Round($method.CrapScore, 2)
}

Write-Host "OVERALL_LINE_COVERAGE:$([Math]::Round($overallLineRate * 100, 1))"
Write-Host "OVERALL_BRANCH_COVERAGE:$([Math]::Round($overallBranchRate * 100, 1))"
Write-Host "TOTAL_METHODS:$($results.Count)"
Write-Host "FLAGGED_METHODS:$($flagged.Count)"
if ($hotspots) {
Write-Output "HOTSPOTS:$(@($hotspots) | ConvertTo-Json -Compress)"
} else {
Write-Output "HOTSPOTS:[]"
foreach ($metric in @('line', 'branch')) {
if ($coverage.Summary["overall_${metric}_coverage_status"] -in @('unknown', 'bounded')) {
Add-CoverageDiagnostic $coverage.Diagnostics 'AGGREGATE_UNKNOWN' "Overall $metric coverage cannot be derived from overlapping report counters." ''
}
}
$partial = -not $coverage.Methods.Count -or $unknown -gt 0 -or
$coverage.Summary.overall_line_coverage_status -in @('unknown', 'bounded') -or
$coverage.Summary.overall_branch_coverage_status -in @('unknown', 'bounded')
Write-CoverageDiagnostics $coverage.Diagnostics
if ($OutputFormat -eq 'json-v1') {
Write-CoverageJson 'Compute-CrapScores' $(if ($partial) { 'partial' } else { 'complete' }) $scope $coverage.Inputs $coverage.Diagnostics @{
methods = $coverage.Methods; hotspots = $hotspots; summary = $coverage.Summary
}
} elseif (-not $partial) {
$line = if ($coverage.Summary.overall_line_coverage_status -eq 'not-applicable') { 0 } else { $coverage.Summary.overall_line_coverage }
$branch = if ($coverage.Summary.overall_branch_coverage_status -eq 'not-applicable') { 0 } else { $coverage.Summary.overall_branch_coverage }
Write-Output "OVERALL_LINE_COVERAGE:$line"
Write-Output "OVERALL_BRANCH_COVERAGE:$branch"
Write-Output "TOTAL_METHODS:$($coverage.Methods.Count)"
Write-Output "FLAGGED_METHODS:$($coverage.Summary.flagged_methods)"
$legacy = @($hotspots | Select-Object Class, Method, Signature, File, TotalLines, CoveredLines, LineCoverage, Complexity, CrapScore)
Write-Output "HOTSPOTS:$(ConvertTo-Json -InputObject $legacy -Compress)"
}
if ($partial) { exit 3 }
exit 0
Loading
Loading