>
crap-score skill insteaddotnet test with coverage collection directly| 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 |
dotnet on PATH)dotnet add package coverlet.collector (or Microsoft.Testing.Extensions.CodeCoverage) when a test project has no coverage provider yet. Skip when the user supplies an existing Cobertura XML.dotnet tool install (ReportGenerator). Core CRAP/coverage analysis works from Cobertura XML alone — ReportGenerator only adds HTML/CSV reports as an optional post-summary extra.MANDATORY: deliver the final assistant response with the CRAP/risk-hotspot summary BEFORE any optional work. As soon asCompute-CrapScores.ps1andExtract-MethodCoverage.ps1return data, your next assistant response must contain the user-facing analysis (CRAP table, blocking methods, recommendations). Do not run ReportGenerator (Phase 5), do not install global tools, and do not start any heavy parallel work before that response is delivered. The user is judged on the final assistant message, not on side-effect files.If a phase fails, times out, or budget is running low, skip remaining optional work and immediately return a partial summary containing: (1) what was found in the Cobertura XML, (2) any CRAP/risk-hotspot data already extracted, (3) which methods are blocking coverage, and (4) failures encountered.
TestResults/), skip Phase 2 entirely (no test execution) and skip Phase 5 by default (no ReportGenerator install or HTML report) — go directly from Phase 3 (analysis scripts) to Phase 4 (user-facing summary). Only run Phase 5 if the user explicitly asks for HTML/CSV reports. The Risk Hotspots table and CRAP scores are mandatory in every output — they are the skill's core value-add over raw coverage numbers.dotnet tool install for ReportGenerator can crash the session before Phase 4 completes.$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 0) {
if ($gitRoot) {
$testOutputRoot = $gitRoot
} else {
$testOutputRoot = $root
}
} elseif ($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:NotFound and test projects were found → use the test projects directly as entry points (run dotnet test on each test .csproj).ENTRY_TYPE:NotFound and no test projects found → stop: No .sln or test projects found under <path>. Provide the path to your .NET solution or project.TEST_PROJECTS:0 and EXISTING_COBERTURA_COUNT > 0 (Step 2b) → continue with existing Cobertura XML analysis (no dotnet test run).TEST_PROJECTS:0 and EXISTING_COBERTURA_COUNT == 0 → stop: No test projects found (expected projects with 'Test' or 'Spec' in the name), and no existing Cobertura XML was provided. Add a test project or provide a Cobertura file path.$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/coverage-analysis/ subdirectory (skill-owned outputs). It must never delete user-supplied Cobertura files — those live one level up at TestResults/coverage.cobertura.xml (or wherever the user pointed). If the user provided a path that is TestResults/coverage-analysis/..., copy the file aside before this step recreates the directory.# 1. Honor a user-supplied path first (highest priority)
$coberturaFiles = @()
if ($userSuppliedCoberturaPath -and (Test-Path $userSuppliedCoberturaPath)) {
$coberturaFiles = @(Get-Item $userSuppliedCoberturaPath)
}
# 2. Otherwise scan TestResults/ at the repo/test root for any *.cobertura.xml
if ($coberturaFiles.Count -eq 0) {
$searchPaths = @(
(Join-Path $testOutputRoot "TestResults"),
(Join-Path $root "TestResults")
) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($sp in $searchPaths) {
$found = @(Get-ChildItem -Path $sp -Filter "*.cobertura.xml" -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch '[/\\]coverage-analysis[/\\]raw[/\\]' })
if ($found.Count -gt 0) { $coberturaFiles = $found; break }
}
}
Write-Host "EXISTING_COBERTURA_COUNT:$($coberturaFiles.Count)"
$coberturaFiles | ForEach-Object { Write-Host "EXISTING_COBERTURA:$($_.FullName)" }
EXISTING_COBERTURA_COUNT > 0 → skip Phase 2 entirely and pass these paths to the Phase 3 scripts.EXISTING_COBERTURA_COUNT == 0 → run Phase 2 to generate fresh coverage; the file paths to feed Phase 3 will be discovered from <COVERAGE_DIR>/raw/ after dotnet test.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 test with coverage collectionMicrosoft.Testing.Extensions.CodeCoverage (Microsoft's built-in provider, common on .NET 9+) or
coverlet.collector (open-source, the default in xUnit templates). The provider determines which
dotnet test arguments to use — both produce Cobertura XML.# 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 test per entry point for the selected strategy:ms-codecoverage or coverlet mode: run a single command for the solution entry (or one per test project if no .sln was found).mixed-project mode: run one command per test project, using that project's existing provider to avoid dual-provider conflicts.coverlet.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=true
Microsoft.Testing.Extensions.CodeCoverage):-- separator. In .NET 10+, --coverage is a top-level dotnet test flag.$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.CodeCoverage + coverlet.collector in the same solution):$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_COUNT is 0:VS_BINARY_COVERAGE > 0: warn the user — "Found .coverage files (VS binary format) but no Cobertura XML. These were likely produced by Visual Studio's built-in collector, which outputs a binary format by default. This skill needs Cobertura XML. Re-running with the detected provider configured for Cobertura output." Then re-run the appropriate dotnet test command above (Coverlet or Microsoft CodeCoverage) with Cobertura format..coverage files either: stop and report — "Coverage files not generated. Ensure dotnet test completed successfully and check the build output for errors."scripts/Compute-CrapScores.ps1 (co-located with this SKILL.md). It reads all Cobertura XML files, applies CRAP(m) = comp² × (1 − cov)³ + comp per method, and returns the top-N hotspots as JSON.SKILL.md file (the skill loader provides this context), then resolve scripts/Compute-CrapScores.ps1 relative to it. If the script path cannot be determined, calculate CRAP scores inline using the formula below.& "<skill-directory>/scripts/Compute-CrapScores.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CrapThreshold <crap_threshold> `
-TopN <top_n>
OVERALL_LINE_COVERAGE:<n>, OVERALL_BRANCH_COVERAGE:<n> (aggregated project-wide rates across all provided Cobertura files), TOTAL_METHODS:<n>, FLAGGED_METHODS:<n>, HOTSPOTS:<json> (top-N sorted by CrapScore descending). The OVERALL_* values are exactly what the Phase 4 summary needs for the "Line Coverage" / "Branch Coverage" rows — no separate XML parsing tool call is required.scripts/Extract-MethodCoverage.ps1 to get per-method coverage data for the Coverage Gaps table:& "<skill-directory>/scripts/Extract-MethodCoverage.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CoverageThreshold <line_threshold> `
-BranchThreshold <branch_threshold> `
-Filter below-threshold
OVERALL_LINE_COVERAGE: / OVERALL_BRANCH_COVERAGE: lines emitted by Compute-CrapScores.ps1 (no extra Cobertura parsing required)Compute-CrapScores.ps1 HOTSPOTS: output (CRAP scores, complexity, coverage)references/output-format.md verbatim for fixed headings, table structures, symbols, and emoji. Use references/guidelines.md for prioritization rules and style.## 📁 Reports section's HTML/Text/CSV/GitHub-markdown rows as Not generated (optional — request HTML reports to enable). Only the coverage-analysis.md and raw Cobertura paths are guaranteed to exist.TestResults/coverage-analysis/coverage-analysis.md before delivering the response (use the editor's create/edit tool — do not shell out). If the file write fails, still deliver the summary and note the file-write failure explicitly.$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, leave the existing user-facing summary as the final output, and note that HTML reports were skipped.$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"
}
coverage.cobertura.xml file was generated after dotnet test (or already exists when the user supplied one)TestResults/coverage-analysis/coverage-analysis.md was written and contains datacomp² × (1 − cov)³ + comp — a method with 100% coverage should have CRAP = complexityTestResults/coverage-analysis/reports/index.html exists; otherwise the report file should mark HTML/Text/CSV rows as Not generateddotnet add package fails (offline/proxy), coverage collection silently produces nothing. Check for .coverage binary files as a fallback indicator.dotnet tool install can crash the session or exhaust budget, leaving the user with no analysis even though the CRAP scores were already computed.dotnet tool install fails (no internet) during Phase 5, leave the existing Phase 4 summary as the final output and note that HTML reports were skipped. Do not retry or block on the install.