ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CRAP 分数实战指南:用「变更风险反模式」指标定位 .NET 中高复杂度且低覆盖的代码

CRAP 分数实战指南:用「变更风险反模式」指标定位 .NET 中高复杂度且低覆盖的代码 CRAP 分数实战指南用「变更风险反模式」指标定位 .NET 中高复杂度且低覆盖的代码【免费下载链接】skillsRepository for skills to assist AI coding agents with .NET and C#项目地址: https://gitcode.com/GitHub_Trending/skills17/skillsCRAPChange Risk Anti-Patterns变更风险反模式分数把圈复杂度与代码覆盖率压缩成一个可排序的量化指标专门用于回答“哪个方法既难懂又没测够”。本文以 dotnet-test 插件 中的crap-score技能 为骨架完整讲解从覆盖率采集、圈复杂度计算、Cobertura 解析到风险排序与改进建议的六步工作流并结合仓库内真实评测夹具async 状态机映射、陈旧注释陷阱、经典项目安全路径等给出可验证的源码级证据。读完你将能够在任意 .NET 仓库中亲手算出每个方法的 CRAP 分数判断哪些代码“靠补测试就能救”哪些“只能靠重构降复杂度”。什么是 CRAP 分数一个公式两个维度CRAP 分数把**圈复杂度cyclomatic complexity和代码覆盖率code coverage**组合成一个单一指标用来量化“修改这段代码时引入回归的风险”$$\text{CRAP}(m) \text{comp}(m)^2 \times (1 - \text{cov}(m))^3 \text{comp}(m)$$其中$\text{comp}(m)$ 方法 $m$ 的圈复杂度$\text{cov}(m)$ 方法 $m$ 的代码覆盖率0.0 到 1.0。这个公式的两个极端情况值得记住也是后文验证清单的关键100% 覆盖的方法$(1 - 1)^3 0$此时 CRAP 复杂度本身即复杂度是该方法的 CRAP 下限0% 覆盖的方法CRAP 复杂度² 复杂度风险随复杂度平方级放大。风险等级划分CRAP 分数风险等级含义 5Low低简单且测试充分5 到 15Moderate中对大多数代码可接受15 到 30High高需要更多测试或简化 30Critical严重必须尽快重构并补充覆盖何时使用何时不该用crap-score技能的定位是精确到命名目标的 CRAP 计算。技能的 frontmatter 中明确写明了适用范围与边界见 SKILL.md。适用场景用户想评估哪些方法因“低覆盖 高复杂度”而存在风险用户询问某个具体方法、类或文件的 CRAP 分数用户想在命名的方法/类/文件范围内基于“覆盖 复杂度”风险决定下一步优先测什么用户想超越单纯覆盖率百分比评估测试质量。明确不适用应转交其他技能只想跑测试 → 使用run-tests技能想写新测试 → 使用code-testing-agent只需要覆盖率百分比、不做复杂度分析 → 不适用想要项目级覆盖率/CRAP 分析或全局优先级 → 使用coverage-analysis技能与crap-score同属 dotnet-test 插件的“Coverage risk”分组前者面向项目级热点后者面向命名目标。输入参数输入必填说明目标范围是待分析的方法名、类名或文件路径测试项目路径否测试项目路径默认在解决方案中自动发现测试项目源项目路径否被分析的源项目路径六步工作流从覆盖率到可执行建议Step 1采集代码覆盖率数据绝不估算CRAP 公式对覆盖率的敏感度极高——错误覆盖率会得出方向性错误的 CRAP 分数比不回答更糟。技能明确规定CRAP 分数必须基于真实的覆盖率数据。采集前先检查测试项目的.csproj中引用了哪个覆盖率包再选择对应命令覆盖率包命令输出位置coverlet.collectordotnet test --collect:XPlat Code Coverage --results-directory ./TestResults通常在TestResults/guid/coverage.cobertura.xml在结果目录下递归搜索例如TestResults/**/coverage.cobertura.xml或使用用户显式给出的覆盖率路径Microsoft.Testing.Extensions.CodeCoverage.NET 9dotnet test -- --coverage --coverage-output-format cobertura --coverage-output ./TestResults--coverage-output指定的路径Microsoft.Testing.Extensions.CodeCoverage.NET 10dotnet test --coverage --coverage-output-format cobertura --coverage-output ./TestResults--coverage-output指定的路径注意 .NET 9 与 .NET 10 的差异前者在--之后传覆盖率参数dotnet test -- --coverage后者直接作为dotnet test的参数dotnet test --coverage。经典非 SDK 项目的红线对于ToolsVersion、显式Compile Include项或packages.config的经典项目只能使用仓库自带的、能产出 Cobertura 的覆盖率命令。如果没有就请求 Cobertura XML 并停止——不要迁移项目也不要注入 SDK 风格覆盖率包。仓库的 classic-no-coverage 评测夹具 专门验证了这一点Legacy.Tests.csproj是ToolsVersion15.0的经典项目见 Legacy.Tests.csproj通过 packages.config 引用MSTest.TestAdapter3.5.2。对应的评测用例要求识别出经典项目后两个项目文件必须保持逐字节不变、不得运行dotnet add package或注入PackageReference并如实说明“没有真实覆盖率数据就无法给出可信的 CRAP 分数”。SDK 风格项目的回退链首个命令未产出 Cobertura XML 时按顺序尝试仅限 SDK 风格项目如果未引用任何覆盖率提供程序则添加dotnet add test.csproj package coverlet.collector然后重跑。绝不对packages.config或经典非 SDK 项目使用此回退。使用独立收集器——当测试宿主或共享程序集阻塞进程内收集器时依然可用dotnet tool install --global dotnet-coverage然后dotnet-coverage collect -f cobertura -o coverage.cobertura.xml dotnet test test.csproj。任意项目类型下如果已存在真实的二进制.coverage报告可用 ReportGenerator 转换或汇总转换已有报告dotnet tool install --global dotnet-reportgenerator-globaltool然后reportgenerator -reports:file -targetdir:cov -reporttypes:Cobertura。测试失败但仍执行了覆盖率取自已执行的那些测试——继续使用该数据但要在结果中注明失败。如果所有路径都失败报告“覆盖率无法采集”展示你尝试过的命令与报错然后停止。可以单独报告复杂度如果对用户有用但绝不发布基于“假定覆盖率”推算出的 CRAP 数值。使用报告前的校验确认报告可解析、至少包含一个类和一个方法、且包含所请求的目标。空报告或缺少目标的报告是采集失败或过滤问题不是 0% 覆盖率。能重新生成就重新生成否则停止、不发布 CRAP 分数。若用户提供了已有报告需说明该报告未重新生成除非本次分析中运行过仓库的覆盖率命令、建立了来源provenance否则不要把它描述为当前数据。Step 2计算圈复杂度优先使用机器产出的、来自仓库提供代码指标报告的逐方法复杂度或 Cobertura 方法上映射到当前源码的complexity属性。Microsoft.CodeAnalysis.Metrics可通过msbuild /t:Metrics生成方法级CyclomaticComplexity数据但未经用户批准不得添加该包或修改项目。如果没有任何机器指标就分析当前目标源码并标注为手工计数manual complexity count。每个方法的基础复杂度为 1以下每个判定点各加 1结构示例ifif (x 0)else ifelse if (y 0)case每个case 1:forfor (int i 0; ...)foreachforeach (var item in list)whilewhile (running)do...whiledo { } while (cond)catch每个catch (Exception ex)if (a b)\|\|或if (a \|\| b)??value ?? fallback?.obj?.Method()? :三元x 0 ? a : b模式匹配分支x is 0 and 10手工计数时读取源文件、给出逐结构分解不得用源码注释作为证据。如果报告中的complexity属性与当前源码的计数冲突报告该冲突且不把任一结果作为权威 CRAP 分数呈现。实战案例陈旧注释陷阱InvoiceEngine.cs 中ApplySurcharges上方写着// Complexity: 4但注释作者自己注明“这是陈旧的源自方法只校验发票参数的时代”。当前方法体实际包含6 个if/else if、1 个||、2 个、2 个??第 27 行invoice.StateCode ?? 与第 37 行invoice.MinimumCharge ?? 0m以及 2 个三元表达式——共 13 个判定点加基础复杂度 1真实圈复杂度为 14。对应的评测用例明确要求按当前方法体重算得到 14而不是 4 或 13。Step 3从 Cobertura XML 提取方法级覆盖率解析 Cobertura XML在目标class元素下找到每个方法的line-rate属性。如果方法级没有line-rate则从lines元素计算$$\text{cov}(m) \frac{\text{命中次数} 0 \text{ 的行数}}{\text{总行数}}$$Cobertura 中的方法名可能与源码不一致async 方法、lambda。名称对不上时按行范围匹配。实战案例async 状态机名称映射AsyncProcessor.cs 中源码方法是ProcessAsync而 coverage.cobertura.xml 中类名被编译器改写为Contoso.Risk.AsyncProcessor/ProcessAsyncd__0方法名是MoveNext。行号 9–21 的覆盖行对应源码中的await Task.Yield()、两个if和返回语句——通过行范围即可把MoveNext映射回ProcessAsync。该夹具中 8 行覆盖 4 行line-rate0.5即 50% 覆盖绝不能因为报告里找不到ProcessAsync就当作“缺失 0% 覆盖”。对应评测用例要求使用 50% 覆盖率而不是把方法当作缺失或 0%。交叉校验防矛盾报告当line-rate和lines同时存在时重算命中比率并与之比较。只允许正常报告舍入误差一个百分点差异更大说明报告自相矛盾——重新生成它或报告冲突并停止计算 CRAP。绝不悄悄选择能得出预期分数的那个值。Step 4计算 CRAP 分数对范围内的每个方法套用公式$$\text{CRAP}(m) \text{comp}(m)^2 \times (1 - \text{cov}(m))^3 \text{comp}(m)$$使用计算器或脚本完成算术并展示代入后的复杂度与覆盖率不要心算。两个可直接验证的算例partial-coverage 夹具中的ProcessOrder复杂度 10、覆盖 45% → CRAP 10² × (1 − 0.45)³ 10 100 × 0.1664 10 ≈26.6高风险——与 SKILL.md 示例表格中的数值完全一致async 夹具中的ProcessAsync复杂度 4、覆盖 50% → CRAP 4² × (1 − 0.5)³ 4 16 × 0.125 4 6中等风险。Step 5呈现结果按 CRAP 降序输出排序表格最高风险在最前| Method | Complexity | Coverage | CRAP Score | Risk | |---------------------------------|------------|----------|------------|----------| | OrderService.ProcessOrder | 10 | 45% | 26.6 | High | | OrderService.ValidateItems | 8 | 90% | 8.1 | Moderate | | OrderService.CalculateTotal | 3 | 100% | 3.0 | Low |结果中应包含摘要共分析多少方法、每个风险等级各有多少Top 违规者CRAP 30 的方法附具体建议快速取胜项Quick wins复杂度高、但覆盖率小幅提升就能显著拉低分数的方法。对应的评测用例还要求“分析文件内所有方法而非只看一个”并验证OrderService的ProcessOrder复杂度 10、覆盖 45%是最高风险——这是源于源码中五个if、两个||、一个for、一个??组成的真实分支结构。Step 6给出可执行建议对高 CRAP 方法建议二者之一或两者并用补测试——识别未覆盖分支建议具体测试用例降复杂度——对深层嵌套逻辑建议提取方法extract-method重构。计算把方法降到 CRAP 阈值 15 以下所需的覆盖率$$\text{cov}_{\text{needed}} 1 - \left(\frac{15 - \text{comp}}{\text{comp}^2}\right)^{1/3}$$该公式仅在 comp 15 时适用。当 comp ≥ 15 时方法在 100% 覆盖下的最小可能 CRAP 就是 comp 本身已经达到或超过阈值——仅靠覆盖率无法把 CRAP 拉回阈值以下必须先重构降低圈复杂度。报告措辞示例“要把ProcessOrder复杂度 10降到 CRAP 15 以下需要把覆盖率从 45% 提升到超过 63.2%”按整数百分比报告时至少 64%。验证cov_needed 1 − ((15 − 10)/100)^(1/3) 1 − 0.3684 ≈ 63.2%“ComplexMethod复杂度 18仅靠测试无法达到 CRAP 15——需要通过提取子方法降低复杂度。”实战案例复杂度本身阻断阈值InvoiceEngine.ClassifyAccount源码包含 9 个if、3 个三元、3 个和 1 个||圈复杂度 17高于 15。即使覆盖率拉到 100%其 CRAP 下限仍是 17。对应的评测用例要求明确表述“无论增加多少覆盖率都无法把该方法的 CRAP 降到 15 以下”并建议提取 balance-tier 分支来降复杂度而不是简单地回答“把覆盖率提高到 100%”。另一个方向性反例是RoundToCurrency源码复杂度 3、100% 覆盖CRAP 恰好等于 3下限处于 Low 风险。对应的评测用例要求不要为这个已完全覆盖的方法建议补测试。验证清单发布任何 CRAP 分数前逐项确认覆盖率数据采集成功Cobertura XML 存在且包含数据目标方法确实存在——缺失不是 0% 覆盖的证据每个覆盖率数字都来自该 XML——没有估算值、假定值或源码注释推导值line-rate与行命中比率同时存在时已交叉核对覆盖率数据中的方法名与源码已核对名称不符时按行范围匹配CRAP 分数经计算器或脚本输出确认100% 覆盖方法的 CRAP 恰好等于其复杂度。常见陷阱汇总采集失败时估算覆盖率绝不做——得到的 CRAP 分数在关键方向上是错的。走完 Step 1 的回退链然后报告阻塞点把空报告或缺失方法当作 0% 覆盖这是采集失败、过滤或方法映射问题不要制造分数信任自相矛盾的 Cobertura 字段比较line-rate与行命中比率超出舍入范围即停止信任源码中陈旧的复杂度注释从当前代码计算圈复杂度前人留下的// complexity: 7注释不是证据心算 CRAP用计算器或脚本并展示代入的输入遇到共享程序集或测试宿主收集器错误就放弃dotnet-coverage collect在进程外运行通常能在进程内收集器失败的地方成功使用陈旧的覆盖率数据用户要求当前结果或源码/二进制已变化时重新生成否则披露提供的报告未重新生成方法名不匹配async 方法、lambda、本地函数在 Cobertura 中可能使用编译器生成的改写名称名称对不上时按行范围匹配生成代码除非用户明确要求从分析中排除自动生成的文件如*.Designer.cs、*.g.cs。评测验证技能边界如何被守住该技能的能力边界并非口号而是被 tests/dotnet-test/crap-score/eval.yaml 中 9 个场景逐一钉死的主要验证点包括经典项目不注入覆盖率包且项目文件逐字节不变场景 1async 状态机名称映射回源码方法并用真实 50% 覆盖计算场景 2空报告不得产出任何数字 CRAP场景 3还要求输出不得匹配CRAP(?: score)?\s*(?:is||:)\s*\d先采集覆盖率再计算场景 6按当前代码重算复杂度而非信任陈旧注释场景 7复杂度 ≥ 15 时明确“补测试无效、必须重构”场景 8100% 覆盖方法 CRAP 等于复杂度下限场景 9。这些评测共同构成“绝不发布假定覆盖率下的 CRAP 数字”这一核心原则的可执行保障。小结CRAP 分数是把“复杂度”和“覆盖率”两个视角合并成单一风险信号的实用手段复杂度平方驱动风险放大覆盖率缺失则放大到立方。正确使用它的关键是纪律——真实覆盖率数据必要时走完整回退链、机器或可复核的复杂度计数、Cobertura 交叉校验、计算器验算以及“comp ≥ 15 时先重构再谈覆盖”的清醒认知。掌握了这套流程你就能在任何 .NET 仓库中快速生成按风险排序的方法清单把有限的测试精力投放到真正需要的地方。【免费下载链接】skillsRepository for skills to assist AI coding agents with .NET and C#项目地址: https://gitcode.com/GitHub_Trending/skills17/skills创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进