Skip to content

Coverage for compiler-rewritten methods (async, iterator, generic) is dropped, scoring covered code as 0% #5

Description

@dillon7f

Summary

Methods the C# compiler rewrites are never matched to their coverage and silently default to 0.0. Because CRAP cubes the uncovered fraction, a fully covered async method can be reported as one of the worst methods in the codebase.

On the project I evaluated, 50 of 60 methods reported over threshold were already covered. The tool's output was ~83% false positives.

Reproduction

PageViewUpdater.UpdateAsync is 100% covered. The tool was handed the coverage file containing that fact and reported:

coverage=0  complexity=18  crap=342

The same file, at the same moment, contains:

<class name="...PageViewUpdater/&lt;UpdateAsync&gt;d__5" line-rate="1">

As a unit test:

[Fact]
public void AsyncMethod_CoverageOnCompilerGeneratedStateMachine_IsAttributedToSourceMethod()
{
    var complexity = new[] { MakeComplexity("UpdateAsync", signature: "(int)") };
    var coverage = new[]
    {
        MakeCoverage("MoveNext", className: "MyApp.Service/<UpdateAsync>d__5", coverage: 1.0)
    };

    var result = MethodCoverageMatcher.Match(complexity, coverage);

    result.Methods.Single().Coverage.Should().Be(1.0);   // fails: 0.0
}

Root cause

CoberturaMethodParser.NormalizeClassName only converts the nested-type separator:

var result = className.Replace('/', '.');

So MyApp.Service/<UpdateAsync>d__5 + method MoveNext produces the key
MyApp.Service.<UpdateAsync>d__5.MoveNext(), which cannot match the source key
MyApp.Service.UpdateAsync(int). Both passes in MethodCoverageMatcher miss and the
left-outer-join default of 0.0 applies.

This affects async methods, iterators, and generic methods. In the project I tested there were 1,014 MoveNext entries in a single coverage file.

Two facets

  1. State machines. Recognise Outer/<Method>d__N and attribute MoveNext to the owning method. The generated type carries no parameter list, so the recovered key has no signature and must resolve via the existing name-only fallback pass.

  2. Generic arity. The Roslyn side normalizes GetPracticeRights<TWorkflowStatus> to GetPracticeRights<>, while the state machine spells it <GetPracticeRights>d__9`1. The recovered key needs the same backtick-to-angle-bracket conversion MethodKeyHelper.NormalizeBacktickGenerics already performs, or the two still won't meet.

Note on ordering

Fixing this increases the impact of the multiple-coverage-file bug (separate issue), because recovered state-machine keys can only match through the name-only pass, which is exactly the pass that breaks when duplicate entries are present. The two should land together.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions