coverage-analysis

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Coverage 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
    crap-score
    skill instead
  • 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
    dotnet test
    with coverage collection directly
  • 针对单个方法的CRAP分析——请改用
    crap-score
    工具
  • 编写或生成测试——本工具仅识别需要测试的位置,不负责编写测试
  • 与覆盖率或CRAP分析无关的常规测试执行
  • 无CRAP上下文的覆盖率报告——直接使用
    dotnet test
    搭配覆盖率收集功能即可

Inputs

输入参数

InputRequiredDefaultDescription
Project/solution pathNoCurrent directoryPath to the .NET solution or project
Line coverage thresholdNo80%Minimum acceptable line coverage
Branch coverage thresholdNo70%Minimum acceptable branch coverage
CRAP thresholdNo30Maximum acceptable CRAP score before flagging
Top N hotspotsNo10Number of risk hotspots to surface
输入项是否必填默认值描述
项目/解决方案路径当前目录.NET解决方案或项目的路径
行覆盖率阈值80%可接受的最低行覆盖率
分支覆盖率阈值70%可接受的最低分支覆盖率
CRAP阈值30触发标记的最高可接受CRAP分数
风险热点数量上限10展示的风险热点数量

Prerequisites

前置条件

  • .NET SDK installed (
    dotnet
    on PATH)
  • At least one test project referencing the production code (xUnit, NUnit, or MSTest)
  • Internet access for
    dotnet tool install
    (ReportGenerator) on first run, or ReportGenerator already installed globally
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(
    dotnet
    命令已加入系统PATH)
  • 至少有一个引用生产代码的测试项目(支持xUnit、NUnit或MSTest)
  • 首次运行时需联网以安装ReportGenerator(
    dotnet tool install
    ),或已全局安装ReportGenerator
本工具会自动检测每个测试项目的覆盖率工具状态,并选择侵入性最低的执行策略:
  • 若所有项目均使用Microsoft CodeCoverage,则统一使用该工具
  • 若没有项目使用Microsoft CodeCoverage,则统一使用Coverlet
  • 若解决方案混合使用两种工具,则按项目分别使用对应工具执行
无需预先配置runsettings文件或手动安装工具。

Workflow

工作流程

If the user provides a path to existing Cobertura XML (or coverage data is already present in
TestResults/
), 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.
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文件路径(或
TestResults/
目录中已存在覆盖率数据),则跳过步骤3-4(测试执行和工具检测),但仍需运行步骤5-6(ReportGenerator报告生成和CRAP分数计算)。风险热点表格和CRAP分数是每个输出的必填项——这是本工具相较于原始覆盖率数据的核心价值。
工作流程分为四个阶段,阶段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/

步骤2b:建议忽略
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.
dotnet test
is the slowest step, and ReportGenerator setup doesn't need coverage files, so running them concurrently cuts wall time significantly.
步骤3和步骤4相互独立——可同时启动。
dotnet test
是耗时最长的步骤,而ReportGenerator的设置不需要覆盖率文件,因此并行运行可大幅缩短总耗时。

Step 3: Detect coverage provider and run
dotnet test
with coverage collection

步骤3:检测覆盖率工具并运行带覆盖率收集的
dotnet test

Before running tests, detect which coverage provider the test projects use. Projects may reference
Microsoft.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.
powershell
undefined
运行测试前,先检测测试项目使用的覆盖率工具。项目可能引用
Microsoft.Testing.Extensions.CodeCoverage
(微软内置工具,.NET 9+版本常用)或
coverlet.collector
(开源工具,xUnit模板中的默认选项)。工具类型决定了
dotnet test
的参数——两者都会生成Cobertura XML格式的数据。
powershell
undefined

Detect 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
dotnet test
per entry point for the selected strategy:
  • In
    ms-codecoverage
    or
    coverlet
    mode: run a single command for the solution entry (or one per test project if no
    .sln
    was found).
  • In
    mixed-project
    mode: run one command per test project, using that project's existing provider to avoid dual-provider conflicts.
Coverlet (
coverlet.collector
):
powershell
$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 CodeCoverage (
Microsoft.Testing.Extensions.CodeCoverage
):
The command syntax depends on the .NET SDK version. In .NET 9, Microsoft.Testing.Platform arguments must be passed after the
--
separator. In .NET 10+,
--coverage
is a top-level
dotnet test
flag.
powershell
$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.collector
):
powershell
$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 CodeCoverage
Microsoft.Testing.Extensions.CodeCoverage
):
命令语法取决于.NET SDK版本。在.NET 9中,Microsoft.Testing.Platform参数必须在
--
分隔符之后传递。在.NET 10+中,
--coverage
dotnet test
的顶层标志。
powershell
$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>"
        --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 }

**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
COBERTURA_COUNT
is 0:
  • If
    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.
  • If no
    .coverage
    files either: stop and report — "Coverage files not generated. Ensure
    dotnet test
    completed successfully and check the build output for errors."
$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`):

```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)" }
COBERTURA_COUNT
为0:
  • VS_BINARY_COVERAGE
    > 0:向用户发出警告——"找到.coverage文件(VS二进制格式)但未找到Cobertura XML。这些文件可能是Visual Studio内置收集器生成的,默认输出二进制格式。本工具需要Cobertura XML格式数据。将使用检测到的工具重新运行并配置为输出Cobertura格式。" 然后重新运行上述对应的
    dotnet test
    命令(Coverlet或Microsoft CodeCoverage)并指定Cobertura格式。
  • 若未找到任何
    .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
RG_AVAILABLE:false
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.
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"
若安装失败(无网络),保持
RG_AVAILABLE:false
并继续执行步骤6中的原始Cobertura XML解析+脚本分析。跳过步骤5中的HTML/文本/CSV报告生成,并在输出中注明此情况。

Phase 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
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.
To locate the script: find the directory containing this skill's
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.
powershell
& "<skill-directory>/scripts/Compute-CrapScores.ps1" `
    -CoberturaPath @(<all COBERTURA file paths as array>) `
    -CrapThreshold <crap_threshold> `
    -TopN <top_n>
Script outputs:
TOTAL_METHODS:<n>
,
FLAGGED_METHODS:<n>
,
HOTSPOTS:<json>
(top-N sorted by CrapScore descending).
Also run
scripts/Extract-MethodCoverage.ps1
to get per-method coverage data for the Coverage Gaps table:
powershell
& "<skill-directory>/scripts/Extract-MethodCoverage.ps1" `
    -CoberturaPath @(<all COBERTURA file paths as array>) `
    -CoverageThreshold <line_threshold> `
    -BranchThreshold <branch_threshold> `
    -Filter below-threshold
Script 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.
运行
scripts/Compute-CrapScores.ps1
(与本SKILL.md文件同目录)。该脚本读取所有Cobertura XML文件,按每个方法应用公式
CRAP(m) = comp² × (1 − cov)³ + comp
计算CRAP分数,并返回前N个热点的JSON数据。
定位脚本:找到包含本工具SKILL.md文件的目录(工具加载器会提供此上下文),然后解析相对路径
scripts/Compute-CrapScores.ps1
。若无法确定脚本路径,则使用以下公式在线计算CRAP分数。
powershell
& "<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>
(按CrapScore降序排列的前N个热点)。
同时运行
scripts/Extract-MethodCoverage.ps1
以获取覆盖率缺口表格所需的方法级覆盖率数据:
powershell
& "<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
TestResults/coverage-analysis/coverage-analysis.md
under the test project directory. Print the full report to the console.
After saving the file, automatically open
TestResults/coverage-analysis/coverage-analysis.md
in the editor so the user can review it immediately.
  • 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
    ,
    start
    , or platform-specific open commands) unless the host has no native open-file mechanism.
  • 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
references/output-format.md
verbatim for all fixed headings, table structures, symbols, and emoji in the generated report. Use
references/guidelines.md
for execution constraints, prioritization rules, and style.
整理分析结果并保存至测试项目目录下的
TestResults/coverage-analysis/coverage-analysis.md
。将完整报告打印至控制台。
保存文件后,自动在编辑器中打开
TestResults/coverage-analysis/coverage-analysis.md
以便用户立即查看。
  • 在编辑器托管环境(VS Code、Visual Studio或其他IDE)中:写入文件后在当前托管会话/编辑器上下文中打开该文件
  • 除非宿主没有原生打开文件的机制,否则不要通过硬编码的shell命令(例如
    code
    start
    或平台特定的打开命令)启动其他应用实例
  • 在CLI或非编辑器环境中:打印报告的绝对路径,并明确说明已生成该文件
打开报告文件前无需询问确认。
生成报告时,所有固定标题、表格结构、符号和表情需严格遵循
references/output-format.md
的内容。执行约束、优先级规则和样式需遵循
references/guidelines.md

Validation

验证

  • Verify that at least one
    coverage.cobertura.xml
    file was generated after
    dotnet test
  • Confirm
    TestResults/coverage-analysis/coverage-analysis.md
    was written and contains data
  • Spot-check one method's CRAP score:
    comp² × (1 − cov)³ + comp
    — a method with 100% coverage should have CRAP = complexity
  • If ReportGenerator ran, verify
    TestResults/coverage-analysis/reports/index.html
    exists
  • 验证
    dotnet test
    运行后至少生成一个
    coverage.cobertura.xml
    文件
  • 确认
    TestResults/coverage-analysis/coverage-analysis.md
    已写入且包含数据
  • 抽查一个方法的CRAP分数:
    comp² × (1 − cov)³ + comp
    ——覆盖率100%的方法其CRAP分数应等于复杂度
  • 若运行了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
    dotnet add package
    fails (offline/proxy), coverage collection silently produces nothing. Check for
    .coverage
    binary files as a fallback indicator.
  • 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
    dotnet tool install
    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.
  • 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安装失败——若
    dotnet tool install
    失败(无网络),跳过HTML/CSV报告生成,继续执行原始Cobertura XML分析+脚本计算CRAP分数。在报告中注明跳过情况。
  • Cobertura中的方法名称不匹配——异步方法、lambda表达式和局部函数可能具有编译器生成的名称。脚本直接使用Cobertura中的方法名称/签名;若结果异常,请对照源代码验证。
  • 混合覆盖率工具——当解决方案同时包含Coverlet和Microsoft CodeCoverage项目时,本工具会按项目分别运行以避免双工具冲突。此方式耗时更长但结果准确。