coverage-analysis
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCoverage Analysis
覆盖率分析
Purpose
用途
Raw coverage percentages answer "what code was executed?" — they don't answer what you actually need to know:
- What tests should I write next? — ranked by risk and impact
- Which uncovered code is risky vs. trivial? — CRAP scores separate the two
- Why has coverage plateaued? — identify the files blocking further gains
- Is this code safe to refactor? — complex + uncovered = dangerous to change
This skill bridges that gap: from a bare .NET solution to a prioritized risk hotspot list, with no manual tool configuration required.
原始覆盖率百分比只能回答“哪些代码被执行了?”——但无法解答你真正需要知道的问题:
- 接下来应该编写哪些测试?——按风险和影响程度排序
- 哪些未覆盖的代码存在风险,哪些是无关紧要的?——CRAP分数可区分二者
- 覆盖率为何停滞不前?——找出阻碍进一步提升的文件
- 这段代码是否可以安全重构?——复杂且未覆盖的代码修改风险极高
本工具填补了这一空白:从一个.NET解决方案到一份按优先级排序的风险热点列表,无需手动配置工具。
When to Use
适用场景
Use this skill when the user mentions test coverage, coverage gaps, code risk, CRAP scores, where to add tests, why coverage plateaued, or wants to know which code is safest to refactor — even if they don't explicitly say "coverage analysis".
当用户提及测试覆盖率、覆盖率缺口、代码风险、CRAP分数、需要知道在哪里添加测试、覆盖率停滞的原因,或想了解哪些代码最适合重构时,即使未明确提到“覆盖率分析”,也可使用本工具。
When Not to Use
不适用场景
- Targeted single-method CRAP analysis — use the skill instead
crap-score - Writing or generating tests — this skill identifies where tests are needed, not write them
- General test execution unrelated to coverage or CRAP analysis
- Coverage reporting without CRAP context — use with coverage collection directly
dotnet test
- 针对单个方法的CRAP分析——请改用工具
crap-score - 编写或生成测试——本工具仅识别需要测试的位置,不负责编写测试
- 与覆盖率或CRAP分析无关的常规测试执行
- 无CRAP上下文的覆盖率报告——直接使用搭配覆盖率收集功能即可
dotnet test
Inputs
输入参数
| 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 |
| 输入项 | 是否必填 | 默认值 | 描述 |
|---|---|---|---|
| 项目/解决方案路径 | 否 | 当前目录 | .NET解决方案或项目的路径 |
| 行覆盖率阈值 | 否 | 80% | 可接受的最低行覆盖率 |
| 分支覆盖率阈值 | 否 | 70% | 可接受的最低分支覆盖率 |
| CRAP阈值 | 否 | 30 | 触发标记的最高可接受CRAP分数 |
| 风险热点数量上限 | 否 | 10 | 展示的风险热点数量 |
Prerequisites
前置条件
- .NET SDK installed (on PATH)
dotnet - At least one test project referencing the production code (xUnit, NUnit, or MSTest)
- Internet access for (ReportGenerator) on first run, or ReportGenerator already installed globally
dotnet tool install
The skill auto-detects coverage provider state per test project and selects the least-invasive execution strategy:
- unified Microsoft CodeCoverage when all projects use it,
- unified Coverlet when no project uses Microsoft CodeCoverage,
- per-project provider execution when the solution is truly mixed.
No pre-existing runsettings files or manually installed tools required.
- 已安装.NET SDK(命令已加入系统PATH)
dotnet - 至少有一个引用生产代码的测试项目(支持xUnit、NUnit或MSTest)
- 首次运行时需联网以安装ReportGenerator(),或已全局安装ReportGenerator
dotnet tool install
本工具会自动检测每个测试项目的覆盖率工具状态,并选择侵入性最低的执行策略:
- 若所有项目均使用Microsoft CodeCoverage,则统一使用该工具
- 若没有项目使用Microsoft CodeCoverage,则统一使用Coverlet
- 若解决方案混合使用两种工具,则按项目分别使用对应工具执行
无需预先配置runsettings文件或手动安装工具。
Workflow
工作流程
If the user provides a path to existing Cobertura XML (or coverage data is already present in ), skip Steps 3–4 (test execution and provider detection) but still run Steps 5–6 (ReportGenerator and CRAP score computation). The Risk Hotspots table and CRAP scores are mandatory in every output — they are the skill's core value-add over raw coverage numbers.
TestResults/The workflow runs in four phases. Phases 2 and 3 each contain steps that can run in parallel to reduce total wall-clock time.
若用户提供了现有Cobertura XML文件路径(或目录中已存在覆盖率数据),则跳过步骤3-4(测试执行和工具检测),但仍需运行步骤5-6(ReportGenerator报告生成和CRAP分数计算)。风险热点表格和CRAP分数是每个输出的必填项——这是本工具相较于原始覆盖率数据的核心价值。
TestResults/工作流程分为四个阶段,阶段2和阶段3中的部分步骤可并行运行以缩短总耗时。
Phase 1 — Setup (sequential)
阶段1 — 初始化(顺序执行)
Step 1: Locate the solution or project
步骤1:定位解决方案或项目
Given the user's path (default: current directory), find the entry point:
powershell
$root = "<user-provided-path-or-current-directory>"根据用户提供的路径(默认:当前目录),找到入口点:
powershell
$root = "<user-provided-path-or-current-directory>"Prefer solution file; fall back to project file
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"
}
}
$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
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
}
}
$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
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)" }
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)
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"
- If `ENTRY_TYPE:NotFound` and test projects were found → use the test projects directly as entry points (run `dotnet test` on each test `.csproj`).
- If `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.`
- If `TEST_PROJECTS:0` → stop: `No test projects found (expected projects with 'Test' or 'Spec' in the name). Ensure your solution has unit test projects before running coverage analysis.`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:NotFound`但找到测试项目 → 直接将测试项目作为入口点(对每个测试`.csproj`运行`dotnet test`)
- 若`ENTRY_TYPE:NotFound`且未找到测试项目 → 终止流程:`未在<路径>下找到.sln文件或测试项目,请提供.NET解决方案或项目的路径。`
- 若`TEST_PROJECTS:0` → 终止流程:`未找到测试项目(预期名称包含'Test'或'Spec'的项目)。运行覆盖率分析前,请确保解决方案包含单元测试项目。`Step 2: Create the output directory
步骤2:创建输出目录
powershell
$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"powershell
$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"Step 2b: Recommend ignoring TestResults/
TestResults/步骤2b:建议忽略TestResults/
目录
TestResults/powershell
$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"
}powershell
$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"
}Phase 2 — Data collection (Steps 3 and 4 run in parallel)
阶段2 — 数据收集(步骤3和步骤4并行运行)
Steps 3 and 4 are independent — start both simultaneously. is the slowest step, and ReportGenerator setup doesn't need coverage files, so running them concurrently cuts wall time significantly.
dotnet test步骤3和步骤4相互独立——可同时启动。是耗时最长的步骤,而ReportGenerator的设置不需要覆盖率文件,因此并行运行可大幅缩短总耗时。
dotnet testStep 3: Detect coverage provider and run dotnet test
with coverage collection
dotnet test步骤3:检测覆盖率工具并运行带覆盖率收集的dotnet test
dotnet testBefore running tests, detect which coverage provider the test projects use. Projects may reference
(Microsoft's built-in provider, common on .NET 9+) or
(open-source, the default in xUnit templates). The provider determines which
arguments to use — both produce Cobertura XML.
Microsoft.Testing.Extensions.CodeCoveragecoverlet.collectordotnet testpowershell
undefined运行测试前,先检测测试项目使用的覆盖率工具。项目可能引用(微软内置工具,.NET 9+版本常用)或(开源工具,xUnit模板中的默认选项)。工具类型决定了的参数——两者都会生成Cobertura XML格式的数据。
Microsoft.Testing.Extensions.CodeCoveragecoverlet.collectordotnet testpowershell
undefinedDetect coverage provider per test project
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 }
}
$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
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 any discovered test projects have no provider, add one based on the selected strategy:
```powershell
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
}
}Log each addition to the console so the developer sees what changed. Document the additions in the final report (see Output Format).
Run one per entry point for the selected strategy:
dotnet test- In or
ms-codecoveragemode: run a single command for the solution entry (or one per test project if nocoverletwas found)..sln - In mode: run one command per test project, using that project's existing provider to avoid dual-provider conflicts.
mixed-project
Coverlet ():
coverlet.collectorpowershell
$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 CodeCoverage ():
Microsoft.Testing.Extensions.CodeCoverageThe command syntax depends on the .NET SDK version. In .NET 9, Microsoft.Testing.Platform arguments
must be passed after the separator. In .NET 10+, is a top-level flag.
----coveragedotnet testpowershell
$rawDir = Join-Path "<COVERAGE_DIR>" "raw"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"
}
若发现测试项目未使用任何覆盖率工具,则根据选定的策略添加对应的工具:
```powershell
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- 在或
ms-codecoverage模式下:为解决方案入口运行单个命令(若未找到coverlet文件,则为每个测试项目分别运行).sln - 在模式下:为每个测试项目分别运行命令,使用项目已有的工具以避免双工具冲突
mixed-project
Coverlet():
coverlet.collectorpowershell
$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 CodeCoverage():
Microsoft.Testing.Extensions.CodeCoverage命令语法取决于.NET SDK版本。在.NET 9中,Microsoft.Testing.Platform参数必须在分隔符之后传递。在.NET 10+中,是的顶层标志。
----coveragedotnet testpowershell
$rawDir = Join-Path "<COVERAGE_DIR>" "raw"Detect SDK version for correct argument placement
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>"
--coverage
--coverage-output $rawDir
} else {
# .NET 9: pass Microsoft.Testing.Platform arguments after the -- separator
dotnet test "<ENTRY>"
-- --coverage --coverage-output-format cobertura --coverage-output $rawDir
}
--results-directory $rawDir --coverage-output-format cobertura --results-directory $rawDir
**Mixed-project mode** (`Microsoft.Testing.Extensions.CodeCoverage` + `coverlet.collector` in the same solution):
```powershell
$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
}
}Exit code handling:
- 0 — all tests passed, coverage collected
- 1 — some tests failed (coverage still collected — proceed with a warning)
- Other — build failure; stop and report the error
After the run, locate coverage files:
powershell
$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)" }If is 0:
COBERTURA_COUNT- If > 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
VS_BINARY_COVERAGEcommand above (Coverlet or Microsoft CodeCoverage) with Cobertura format.dotnet test - If no files either: stop and report — "Coverage files not generated. Ensure
.coveragecompleted successfully and check the build output for errors."dotnet test
$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>"
--coverage
--coverage-output $rawDir
} else {
# .NET 9: pass Microsoft.Testing.Platform arguments after the -- separator
dotnet test "<ENTRY>"
-- --coverage --coverage-output-format cobertura --coverage-output $rawDir
}
--results-directory $rawDir --coverage-output-format cobertura --results-directory $rawDir
**混合项目模式**(解决方案中同时存在`Microsoft.Testing.Extensions.CodeCoverage`和`coverlet.collector`):
```powershell
$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
}
}退出代码处理:
- 0 — 所有测试通过,覆盖率数据已收集
- 1 — 部分测试失败(仍会收集通过测试的覆盖率数据——带警告继续执行)
- 其他值 — 构建失败;终止流程并报告错误
运行完成后,定位覆盖率文件:
powershell
$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)" }若为0:
COBERTURA_COUNT- 若> 0:向用户发出警告——"找到.coverage文件(VS二进制格式)但未找到Cobertura XML。这些文件可能是Visual Studio内置收集器生成的,默认输出二进制格式。本工具需要Cobertura XML格式数据。将使用检测到的工具重新运行并配置为输出Cobertura格式。" 然后重新运行上述对应的
VS_BINARY_COVERAGE命令(Coverlet或Microsoft CodeCoverage)并指定Cobertura格式。dotnet test - 若未找到任何文件:终止流程并报告——"未生成覆盖率文件。请确保
.coverage成功完成,并检查构建输出中的错误。"dotnet test
Step 4: Verify or install ReportGenerator (parallel with Step 3)
步骤4:验证或安装ReportGenerator(与步骤3并行)
powershell
$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"If installation fails (no internet), keep and continue with raw Cobertura XML parsing + script-based analysis in Step 6. Skip HTML/Text/CSV report generation in Step 5 and note this in the output.
RG_AVAILABLE:falsepowershell
$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"若安装失败(无网络),保持并继续执行步骤6中的原始Cobertura XML解析+脚本分析。跳过步骤5中的HTML/文本/CSV报告生成,并在输出中注明此情况。
RG_AVAILABLE:falsePhase 3 — Analysis (Steps 5 and 6 run in parallel)
阶段3 — 分析(步骤5和步骤6并行运行)
Once Phase 2 completes (coverage files available, ReportGenerator ready), start Steps 5 and 6 simultaneously — both read from the same Cobertura XML and produce independent outputs.
阶段2完成后(覆盖率文件可用,ReportGenerator准备就绪),同时启动步骤5和步骤6——两者均读取相同的Cobertura XML文件并生成独立输出。
Step 5: Generate reports with ReportGenerator (parallel with Step 6)
步骤5:使用ReportGenerator生成报告(与步骤6并行)
powershell
$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"
}powershell
$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"
}Step 6: Calculate CRAP scores using the bundled script (parallel with Step 5)
步骤6:使用内置脚本计算CRAP分数(与步骤5并行)
Run (co-located with this SKILL.md). It reads all Cobertura XML files, applies per method, and returns the top-N hotspots as JSON.
scripts/Compute-CrapScores.ps1CRAP(m) = comp² × (1 − cov)³ + compTo locate the script: find the directory containing this skill's file (the skill loader provides this context), then resolve relative to it. If the script path cannot be determined, calculate CRAP scores inline using the formula below.
SKILL.mdscripts/Compute-CrapScores.ps1powershell
& "<skill-directory>/scripts/Compute-CrapScores.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CrapThreshold <crap_threshold> `
-TopN <top_n>Script outputs: , , (top-N sorted by CrapScore descending).
TOTAL_METHODS:<n>FLAGGED_METHODS:<n>HOTSPOTS:<json>Also run to get per-method coverage data for the Coverage Gaps table:
scripts/Extract-MethodCoverage.ps1powershell
& "<skill-directory>/scripts/Extract-MethodCoverage.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CoverageThreshold <line_threshold> `
-BranchThreshold <branch_threshold> `
-Filter below-thresholdScript outputs: JSON array of methods below the coverage threshold, sorted by coverage ascending. Use this data to populate the Coverage Gaps by File table in the report.
运行(与本SKILL.md文件同目录)。该脚本读取所有Cobertura XML文件,按每个方法应用公式计算CRAP分数,并返回前N个热点的JSON数据。
scripts/Compute-CrapScores.ps1CRAP(m) = comp² × (1 − cov)³ + comp定位脚本:找到包含本工具SKILL.md文件的目录(工具加载器会提供此上下文),然后解析相对路径。若无法确定脚本路径,则使用以下公式在线计算CRAP分数。
scripts/Compute-CrapScores.ps1powershell
& "<skill-directory>/scripts/Compute-CrapScores.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CrapThreshold <crap_threshold> `
-TopN <top_n>脚本输出:、、(按CrapScore降序排列的前N个热点)。
TOTAL_METHODS:<n>FLAGGED_METHODS:<n>HOTSPOTS:<json>同时运行以获取覆盖率缺口表格所需的方法级覆盖率数据:
scripts/Extract-MethodCoverage.ps1powershell
& "<skill-directory>/scripts/Extract-MethodCoverage.ps1" `
-CoberturaPath @(<all COBERTURA file paths as array>) `
-CoverageThreshold <line_threshold> `
-BranchThreshold <branch_threshold> `
-Filter below-threshold脚本输出:低于覆盖率阈值的方法JSON数组,按覆盖率升序排列。使用此数据填充报告中的“按文件划分的覆盖率缺口”表格。
Phase 4 — Output (sequential)
阶段4 — 输出(顺序执行)
Step 7: Build the output report
步骤7:构建输出报告
Compose the analysis and save it to under the test project directory. Print the full report to the console.
TestResults/coverage-analysis/coverage-analysis.mdAfter saving the file, automatically open in the editor so the user can review it immediately.
TestResults/coverage-analysis/coverage-analysis.md- In editor-hosted environments (VS Code, Visual Studio, or other IDE hosts): open the file in the current host session/editor context after writing it.
- Do not launch a different app instance via hardcoded shell commands (for example ,
code, or platform-specific open commands) unless the host has no native open-file mechanism.start - In CLI or non-editor environments: print the absolute report path and clearly state that the file was generated.
Do not ask for confirmation before opening the report file.
Use verbatim for all fixed headings, table structures, symbols, and emoji in the generated report. Use for execution constraints, prioritization rules, and style.
references/output-format.mdreferences/guidelines.md整理分析结果并保存至测试项目目录下的。将完整报告打印至控制台。
TestResults/coverage-analysis/coverage-analysis.md保存文件后,自动在编辑器中打开以便用户立即查看。
TestResults/coverage-analysis/coverage-analysis.md- 在编辑器托管环境(VS Code、Visual Studio或其他IDE)中:写入文件后在当前托管会话/编辑器上下文中打开该文件
- 除非宿主没有原生打开文件的机制,否则不要通过硬编码的shell命令(例如、
code或平台特定的打开命令)启动其他应用实例start - 在CLI或非编辑器环境中:打印报告的绝对路径,并明确说明已生成该文件
打开报告文件前无需询问确认。
生成报告时,所有固定标题、表格结构、符号和表情需严格遵循的内容。执行约束、优先级规则和样式需遵循。
references/output-format.mdreferences/guidelines.mdValidation
验证
- Verify that at least one file was generated after
coverage.cobertura.xmldotnet test - Confirm was written and contains data
TestResults/coverage-analysis/coverage-analysis.md - Spot-check one method's CRAP score: — a method with 100% coverage should have CRAP = complexity
comp² × (1 − cov)³ + comp - If ReportGenerator ran, verify exists
TestResults/coverage-analysis/reports/index.html
- 验证运行后至少生成一个
dotnet test文件coverage.cobertura.xml - 确认已写入且包含数据
TestResults/coverage-analysis/coverage-analysis.md - 抽查一个方法的CRAP分数:——覆盖率100%的方法其CRAP分数应等于复杂度
comp² × (1 − cov)³ + comp - 若运行了ReportGenerator,验证是否存在
TestResults/coverage-analysis/reports/index.html
Common Pitfalls
常见陷阱
- No Cobertura XML generated — the test project may lack a coverage provider. The skill auto-adds one, but if fails (offline/proxy), coverage collection silently produces nothing. Check for
dotnet add packagebinary files as a fallback indicator..coverage - Test failures (exit code 1) — coverage is still collected from passing tests. Do not abort; proceed with partial data and note the failures in the summary.
- ReportGenerator install failure — if fails (no internet), skip HTML/CSV report generation and continue with raw Cobertura XML analysis + script-based CRAP scores. Note the skip in the report.
dotnet tool install - Method name mismatches in Cobertura — async methods, lambdas, and local functions may have compiler-generated names. The scripts use the Cobertura method name/signature directly; verify against source if results look unexpected.
- Mixed coverage providers — when a solution contains both Coverlet and Microsoft CodeCoverage projects, the skill runs per-project to avoid dual-provider conflicts. This is slower but correct.
- 未生成Cobertura XML——测试项目可能缺少覆盖率工具。本工具会自动添加,但如果失败(离线/代理问题),覆盖率收集会静默无输出。可检查
dotnet add package二进制文件作为备用指标。.coverage - 测试失败(退出代码1)——仍会收集通过测试的覆盖率数据。请勿终止流程;继续使用部分数据执行,并在摘要中注明测试失败情况。
- ReportGenerator安装失败——若失败(无网络),跳过HTML/CSV报告生成,继续执行原始Cobertura XML分析+脚本计算CRAP分数。在报告中注明跳过情况。
dotnet tool install - Cobertura中的方法名称不匹配——异步方法、lambda表达式和局部函数可能具有编译器生成的名称。脚本直接使用Cobertura中的方法名称/签名;若结果异常,请对照源代码验证。
- 混合覆盖率工具——当解决方案同时包含Coverlet和Microsoft CodeCoverage项目时,本工具会按项目分别运行以避免双工具冲突。此方式耗时更长但结果准确。