Loading...
Loading...
Automated, project-wide code coverage and CRAP (Change Risk Anti-Patterns) score analysis for .NET projects with existing unit tests. Auto-detects solution structure, runs coverage collection via `dotnet test` (supports both Microsoft.Testing.Extensions.CodeCoverage and Coverlet), generates reports via ReportGenerator, calculates CRAP scores per method, and surfaces risk hotspots — complex code with low test coverage that is dangerous to modify. Use when the user wants project-wide coverage analysis with risk prioritization, coverage gap identification, CRAP score computation across an entire solution, or to diagnose why coverage is stuck or plateaued and identify what methods are blocking improvement. DO NOT USE FOR: targeted single-method CRAP analysis (use crap-score skill), writing tests, running tests without coverage collection, applying test filters, producing TRX reports, or troubleshooting test execution (use run-tests for all of these).
npx skill4agent add dotnet/skills coverage-analysiscrap-scoredotnet test| Input | Required | Default | Description |
|---|---|---|---|
| Project/solution path | No | Current directory | Path to the .NET solution or project |
| Line coverage threshold | No | 80% | Minimum acceptable line coverage |
| Branch coverage threshold | No | 70% | Minimum acceptable branch coverage |
| CRAP threshold | No | 30 | Maximum acceptable CRAP score before flagging |
| Top N hotspots | No | 10 | Number of risk hotspots to surface |
dotnetdotnet tool installTestResults/$root = "<user-provided-path-or-current-directory>"
# Prefer solution file; fall back to project file
$sln = Get-ChildItem -Path $root -Filter "*.sln" -Recurse -Depth 2 -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($sln) {
Write-Host "ENTRY_TYPE:Solution"; Write-Host "ENTRY:$($sln.FullName)"
} else {
$project = Get-ChildItem -Path $root -Filter "*.csproj" -Recurse -Depth 2 -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($project) {
Write-Host "ENTRY_TYPE:Project"; Write-Host "ENTRY:$($project.FullName)"
} else {
Write-Host "ENTRY_TYPE:NotFound"
}
}
# Test projects: search path first, then git root, then parent
$searchRoots = @($root)
$gitRoot = (git -C $root rev-parse --show-toplevel 2>$null)
if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) }
if ($gitRoot -and $gitRoot -ne $root) { $searchRoots += $gitRoot }
$parentPath = Split-Path $root -Parent
if ($parentPath -and $parentPath -ne $root -and $parentPath -ne $gitRoot) { $searchRoots += $parentPath }
$testProjects = @()
foreach ($sr in $searchRoots) {
# Primary: match by .csproj content (test framework references)
$testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch '([/\\]obj[/\\]|[/\\]bin[/\\])' } |
Where-Object { (Select-String -Path $_.FullName -Pattern 'Microsoft\.NET\.Test\.Sdk|xunit|nunit|MSTest\.TestAdapter|"MSTest"|MSTest\.TestFramework|TUnit' -Quiet) })
if ($testProjects.Count -gt 0) {
if ($sr -ne $root) { Write-Host "SEARCHED:$sr" }
break
}
}
# Fallback: match by file name convention
if ($testProjects.Count -eq 0) {
foreach ($sr in $searchRoots) {
$testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '(?i)(test|spec)' })
if ($testProjects.Count -gt 0) {
if ($sr -ne $root) { Write-Host "SEARCHED:$sr" }
break
}
}
}
Write-Host "TEST_PROJECTS:$($testProjects.Count)"
$testProjects | ForEach-Object { Write-Host "TEST_PROJECT:$($_.FullName)" }
# Resolve the test output root (where coverage-analysis artifacts will be written)
if ($testProjects.Count -eq 1) {
$testOutputRoot = $testProjects[0].DirectoryName
} else {
# Multiple test projects — find their deepest common parent directory
$dirs = $testProjects | ForEach-Object { $_.DirectoryName }
$common = $dirs[0]
foreach ($d in $dirs[1..($dirs.Count-1)]) {
$sep = [System.IO.Path]::DirectorySeparatorChar
while (-not $d.StartsWith("$common$sep", [System.StringComparison]::OrdinalIgnoreCase) -and $d -ne $common) {
$prevCommon = $common
$common = Split-Path $common -Parent
# Terminate if we can no longer move up (at filesystem root or no parent)
if ([string]::IsNullOrEmpty($common) -or $common -eq $prevCommon) {
$common = $null
break
}
}
}
if ([string]::IsNullOrEmpty($common)) {
# Fallback when no common parent directory exists (e.g., projects on different drives)
if ($gitRoot) {
$testOutputRoot = $gitRoot
} else {
$testOutputRoot = $root
}
} else {
$testOutputRoot = $common
}
}
Write-Host "TEST_OUTPUT_ROOT:$testOutputRoot"ENTRY_TYPE:NotFounddotnet test.csprojENTRY_TYPE:NotFoundNo .sln or test projects found under <path>. Provide the path to your .NET solution or project.TEST_PROJECTS:0No test projects found (expected projects with 'Test' or 'Spec' in the name). Ensure your solution has unit test projects before running coverage analysis.$coverageDir = Join-Path $testOutputRoot "TestResults" "coverage-analysis"
if (Test-Path $coverageDir) { Remove-Item $coverageDir -Recurse -Force }
New-Item -ItemType Directory -Path $coverageDir -Force | Out-Null
Write-Host "COVERAGE_DIR:$coverageDir"TestResults/$pattern = "**/TestResults/"
$gitRoot = (git -C $testOutputRoot rev-parse --show-toplevel 2>$null)
if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) }
if ($gitRoot) {
$gitignorePath = Join-Path $gitRoot ".gitignore"
$alreadyIgnored = $false
if (Test-Path $gitignorePath) {
$alreadyIgnored = (Select-String -Path $gitignorePath -Pattern '^\s*(\*\*/)?TestResults/?\s*$' -Quiet)
}
if ($alreadyIgnored) {
Write-Host "GITIGNORE_RECOMMENDATION:already-present"
} else {
Write-Host "GITIGNORE_RECOMMENDATION:$pattern"
}
} else {
Write-Host "GITIGNORE_RECOMMENDATION:$pattern"
}dotnet testdotnet testMicrosoft.Testing.Extensions.CodeCoveragecoverlet.collectordotnet test# Detect coverage provider per test project
$coverageProvider = "unknown" # will be set to "ms-codecoverage" or "coverlet"
$msCodeCovProjects = @()
$coverletProjects = @()
$neitherProjects = @()
foreach ($tp in $testProjects) {
$hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet
$hasCoverlet = Select-String -Path $tp.FullName -Pattern 'coverlet\.collector' -Quiet
if ($hasMsCodeCov) { $msCodeCovProjects += $tp }
elseif ($hasCoverlet) { $coverletProjects += $tp }
else { $neitherProjects += $tp }
}
# Determine the provider strategy
if ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -eq 0) {
$coverageProvider = "ms-codecoverage"
Write-Host "COVERAGE_PROVIDER:ms-codecoverage (ms:$($msCodeCovProjects.Count), none:$($neitherProjects.Count))"
} elseif ($coverletProjects.Count -gt 0 -and $msCodeCovProjects.Count -eq 0) {
$coverageProvider = "coverlet"
Write-Host "COVERAGE_PROVIDER:coverlet (coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))"
} elseif ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -gt 0) {
$coverageProvider = "mixed-project"
Write-Host "COVERAGE_PROVIDER:mixed-project (ms:$($msCodeCovProjects.Count), coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))"
} else {
$coverageProvider = "coverlet"
Write-Host "COVERAGE_PROVIDER:none-detected — defaulting to coverlet"
}if ($coverageProvider -eq "ms-codecoverage" -and $neitherProjects.Count -gt 0) {
Write-Host "ADDING_MS_CODECOVERAGE:$($neitherProjects.Count) project(s)"
foreach ($tp in $neitherProjects) {
dotnet add $tp.FullName package Microsoft.Testing.Extensions.CodeCoverage --no-restore
Write-Host " ADDED_MS_CODECOVERAGE:$($tp.FullName)"
}
foreach ($tp in $neitherProjects) {
dotnet restore $tp.FullName --quiet
}
}
if (($coverageProvider -eq "coverlet" -or $coverageProvider -eq "mixed-project") -and $neitherProjects.Count -gt 0) {
Write-Host "ADDING_COVERLET:$($neitherProjects.Count) project(s)"
foreach ($tp in $neitherProjects) {
dotnet add $tp.FullName package coverlet.collector --no-restore
Write-Host " ADDED:$($tp.FullName)"
}
foreach ($tp in $neitherProjects) {
dotnet restore $tp.FullName --quiet
}
}dotnet testms-codecoveragecoverlet.slnmixed-projectcoverlet.collector$rawDir = Join-Path "<COVERAGE_DIR>" "raw"
dotnet test "<ENTRY>" `
--collect:"XPlat Code Coverage" `
--results-directory $rawDir `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=trueMicrosoft.Testing.Extensions.CodeCoverage----coveragedotnet test$rawDir = Join-Path "<COVERAGE_DIR>" "raw"
# Detect SDK version for correct argument placement
$sdkVersion = (dotnet --version 2>$null)
$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 }
if ($major -ge 10) {
# .NET 10+: --coverage is a first-class dotnet test flag
dotnet test "<ENTRY>" `
--results-directory $rawDir `
--coverage `
--coverage-output-format cobertura `
--coverage-output $rawDir
} else {
# .NET 9: pass Microsoft.Testing.Platform arguments after the -- separator
dotnet test "<ENTRY>" `
--results-directory $rawDir `
-- --coverage --coverage-output-format cobertura --coverage-output $rawDir
}Microsoft.Testing.Extensions.CodeCoveragecoverlet.collector$rawDir = Join-Path "<COVERAGE_DIR>" "raw"
$sdkVersion = (dotnet --version 2>$null)
$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 }
foreach ($tp in $testProjects) {
$hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet
if ($hasMsCodeCov) {
if ($major -ge 10) {
dotnet test $tp.FullName --results-directory $rawDir --coverage --coverage-output-format cobertura --coverage-output $rawDir
} else {
dotnet test $tp.FullName --results-directory $rawDir -- --coverage --coverage-output-format cobertura --coverage-output $rawDir
}
} else {
dotnet test $tp.FullName `
--collect:"XPlat Code Coverage" `
--results-directory $rawDir `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=true
}
}$coberturaFiles = Get-ChildItem -Path (Join-Path "<COVERAGE_DIR>" "raw") -Filter "coverage.cobertura.xml" -Recurse
Write-Host "COBERTURA_COUNT:$($coberturaFiles.Count)"
$coberturaFiles | ForEach-Object { Write-Host "COBERTURA:$($_.FullName)" }
$vsCovFiles = Get-ChildItem -Path (Join-Path "<COVERAGE_DIR>" "raw") -Filter "*.coverage" -Recurse -ErrorAction SilentlyContinue
if ($vsCovFiles) { Write-Host "VS_BINARY_COVERAGE:$($vsCovFiles.Count)" }COBERTURA_COUNTVS_BINARY_COVERAGEdotnet test.coveragedotnet test$rgAvailable = $false
$rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue
if ($rgCommand) {
$rgAvailable = $true
Write-Host "RG_INSTALLED:already-present"
} else {
$rgToolPath = Join-Path "<COVERAGE_DIR>" ".tools"
dotnet tool install dotnet-reportgenerator-globaltool --tool-path $rgToolPath
if ($LASTEXITCODE -eq 0) {
$env:PATH = "$rgToolPath$([System.IO.Path]::PathSeparator)$env:PATH"
$rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue
if ($rgCommand) {
$rgAvailable = $true
Write-Host "RG_INSTALLED:true (tool-path: $rgToolPath)"
} else {
Write-Host "RG_INSTALLED:false"
Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available"
}
} else {
Write-Host "RG_INSTALLED:false"
Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available"
}
}
Write-Host "RG_AVAILABLE:$rgAvailable"RG_AVAILABLE:false$reportsDir = Join-Path "<COVERAGE_DIR>" "reports"
if ($rgAvailable) {
reportgenerator `
-reports:"<semicolon-separated COBERTURA paths>" `
-targetdir:$reportsDir `
-reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" `
-title:"Coverage Report" `
-tag:"coverage-analysis-skill"
Get-Content (Join-Path $reportsDir "Summary.txt") -ErrorAction SilentlyContinue
} else {
Write-Host "REPORTGENERATOR_SKIPPED:true"
}scripts/Compute-CrapScores.ps1CRAP(m) = comp² × (1 − cov)³ + compSKILL.mdscripts/Compute-CrapScores.ps1& "<skill-directory>/scripts/Compute-CrapScores.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CrapThreshold <crap_threshold> `
-TopN <top_n>TOTAL_METHODS:<n>FLAGGED_METHODS:<n>HOTSPOTS:<json>scripts/Extract-MethodCoverage.ps1& "<skill-directory>/scripts/Extract-MethodCoverage.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CoverageThreshold <line_threshold> `
-BranchThreshold <branch_threshold> `
-Filter below-thresholdTestResults/coverage-analysis/coverage-analysis.mdTestResults/coverage-analysis/coverage-analysis.mdcodestartreferences/output-format.mdreferences/guidelines.mdcoverage.cobertura.xmldotnet testTestResults/coverage-analysis/coverage-analysis.mdcomp² × (1 − cov)³ + compTestResults/coverage-analysis/reports/index.htmldotnet add package.coveragedotnet tool install