Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Crap4DotNet.Core/Matching/CoberturaMethodParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ public static string ToCanonicalKey(CoberturaMethodCoverage coverage)
var className = NormalizeClassName(coverage.ClassName);
var methodName = NormalizeMethodName(coverage.MethodName, className);
var signature = NormalizeSignature(coverage.Signature);
return $"{className}.{methodName}{signature}";
return $"{className}.{methodName}"
+ MethodKeyHelper.NormalizeSignatureForMatching(signature);
}

private static string NormalizeClassName(string className)
Expand Down
36 changes: 35 additions & 1 deletion src/Crap4DotNet.Core/Matching/MethodCoverageMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,22 @@ public static MatchResult Match(
{
// Build coverage lookups by normalized key
var fullKeyLookup = new Dictionary<string, List<CoberturaMethodCoverage>>(StringComparer.Ordinal);
var arityKeyLookup = new Dictionary<string, List<CoberturaMethodCoverage>>(StringComparer.Ordinal);
var nameKeyLookup = new Dictionary<string, List<CoberturaMethodCoverage>>(StringComparer.Ordinal);

foreach (var entry in coverageEntries)
{
var fullKey = CoberturaMethodParser.ToCanonicalKey(entry);
AddToLookup(fullKeyLookup, fullKey, entry);

AddToLookup(arityKeyLookup, StripArity(fullKey), entry);

var nameKey = MethodKeyHelper.GetNameOnlyKey(fullKey);
AddToLookup(nameKeyLookup, nameKey, entry);
}

var matchedFullKeys = new HashSet<string>(StringComparer.Ordinal);
var matchedArityKeys = new HashSet<string>(StringComparer.Ordinal);
var matchedNameKeys = new HashSet<string>(StringComparer.Ordinal);
var methods = new List<MatchedMethod>();
var unmatchedNames = new List<string>();
Expand All @@ -49,6 +53,24 @@ public static MatchResult Match(
continue;
}

// Pass 1b: Retry with the method's generic arity dropped. Roslyn always knows a
// method is generic and writes Foo<>; a Cobertura <method name> usually carries no
// method-level arity at all, so those two keys can never be equal and EVERY generic
// method would otherwise score 0.0 however well tested. Arity is kept in the exact
// key above rather than stripped there, so a genuine Find<T>/Find<T,U> pair stays
// distinguishable; this pass only relaxes it, and only when the result is unique.
var arityKey = StripArity(fullKey);
if (arityKeyLookup.TryGetValue(arityKey, out var arityMatches) && arityMatches.Count == 1)
{
matchedArityKeys.Add(arityKey);
methods.Add(new MatchedMethod
{
Complexity = complexity,
Coverage = arityMatches[0].Coverage
});
continue;
}

// Pass 2: Fallback to name-only key (without signature)
var nameKey = MethodKeyHelper.GetNameOnlyKey(fullKey);
if (nameKeyLookup.TryGetValue(nameKey, out var nameMatches) && nameMatches.Count == 1)
Expand Down Expand Up @@ -80,7 +102,10 @@ public static MatchResult Match(
if (matchedFullKeys.Contains(kvp.Key))
continue;

// Check if matched by name-only fallback
// Check if matched by the arity-relaxed or name-only fallback
if (matchedArityKeys.Contains(StripArity(kvp.Key)))
continue;

var nameKey = MethodKeyHelper.GetNameOnlyKey(kvp.Key);
if (matchedNameKeys.Contains(nameKey))
continue;
Expand Down Expand Up @@ -138,6 +163,15 @@ public static MatchResult Match(
};
}

/// <summary>Canonical key with the method's own generic arity removed.</summary>
private static string StripArity(string canonicalKey)
{
var sigStart = MethodKeyHelper.FindSignatureStart(canonicalKey);
return sigStart < 0
? MethodKeyHelper.StripMethodGenericArity(canonicalKey)
: MethodKeyHelper.StripMethodGenericArity(canonicalKey[..sigStart]) + canonicalKey[sigStart..];
}

private static void AddToLookup(
Dictionary<string, List<CoberturaMethodCoverage>> lookup,
string key,
Expand Down
76 changes: 76 additions & 0 deletions src/Crap4DotNet.Core/Matching/MethodIdentityNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,82 @@ public static List<string> SplitTypeList(string typeList)
return result;
}

/// <summary>
/// Strip the generic arity marker from the method-name segment only.
/// </summary>
/// <remarks>
/// Roslyn knows a method is generic and writes <c>Foo&lt;&gt;</c>; a Cobertura
/// <c>&lt;method name&gt;</c> carries no method-level arity at all, so the two keys can
/// never be equal while the marker survives. Class-level arity is left alone: Cobertura
/// does encode that, as a backtick (<c>Cache`1</c>), and dropping it would make
/// <c>Cache&lt;T&gt;.Get</c> and a non-generic <c>Cache.Get</c> collide.
/// Only the final segment is touched, and only when its angle brackets are balanced.
/// </remarks>
public static string StripMethodGenericArity(string namePart)
{
var depth = 0;
var lastDot = -1;
for (var i = 0; i < namePart.Length; i++)
{
switch (namePart[i])
{
case '<': depth++; break;
case '>': depth--; break;
case '.' when depth == 0: lastDot = i; break;
}
}

var segStart = lastDot + 1;
var segment = namePart[segStart..];
var open = segment.IndexOf('<', StringComparison.Ordinal);
if (open < 0 || !segment.EndsWith('>'))
return namePart;

return namePart[..segStart] + segment[..open];
}

/// <summary>
/// Reduce a normalized signature to the information both sides can actually carry.
/// </summary>
/// <remarks>
/// Two kinds of detail exist on the Roslyn side and nowhere in a CLR signature, and each
/// silently blocks the exact-signature pass — which is the only pass that can resolve an
/// overload set, because the name-only fallback deliberately refuses an ambiguous one:
/// <list type="bullet">
/// <item>parameter modifiers: C# distinguishes <c>out</c>/<c>in</c>/<c>ref</c>, the CLR
/// records one by-ref marker, so all three fold to <c>ref</c>;</item>
/// <item>nullable-reference annotations: <c>string?</c> and <c>string</c> are the same CLR
/// type. <c>?</c> is dropped on both sides rather than one, so <c>int?</c> (Roslyn) and
/// <c>Nullable&lt;int&gt;</c> (Cobertura, which normalizes to <c>int?</c>) still agree.</item>
/// </list>
/// The cost is that an overload set differing <em>only</em> by nullability or by
/// <c>out</c> vs <c>ref</c> becomes ambiguous; such a set cannot be declared in C# anyway
/// for the modifier case, and the name-only pass still refuses rather than guessing.
/// </remarks>
public static string NormalizeSignatureForMatching(string signature)
{
if (string.IsNullOrEmpty(signature) || signature == "()")
return "()";
if (!signature.StartsWith('(') || !signature.EndsWith(')'))
return signature;

var inner = signature[1..^1];
if (string.IsNullOrWhiteSpace(inner))
return "()";

var reduced = SplitTypeList(inner).Select(t =>
{
var x = t.Trim();
if (x.StartsWith("out ", StringComparison.Ordinal))
x = "ref " + x[4..];
else if (x.StartsWith("in ", StringComparison.Ordinal))
x = "ref " + x[3..];
return x.Replace("?", "", StringComparison.Ordinal);
});

return "(" + string.Join(", ", reduced) + ")";
}

/// <summary>
/// Convert CLR backtick generic arity notation to angle bracket notation.
/// Cache`1 → Cache&lt;&gt;, Dictionary`2 → Dictionary&lt;,&gt;
Expand Down
2 changes: 1 addition & 1 deletion src/Crap4DotNet.Core/Matching/RoslynMethodParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ private static string NormalizeGenericTypeParams(string fullName)
return "<" + new string(',', paramCount - 1) + ">";
});

return normalized + sigPart;
return normalized + MethodKeyHelper.NormalizeSignatureForMatching(sigPart);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using Crap4DotNet.Core.Complexity;
using Crap4DotNet.Core.Coverage;
using Crap4DotNet.Core.Matching;
using Crap4DotNet.Core.Models;
using FluentAssertions;
using Xunit;

namespace Crap4DotNet.Core.Tests.Matching;

/// <summary>
/// Coverage is silently discarded for two shapes that are ordinary in real code:
/// generic methods and overload sets. Every case below is taken verbatim from a
/// coverlet run over a production solution, where the Cobertura reported
/// branch-rate="1" for the method while the report scored it 0.0.
///
/// Three independent mismatches cause it, and each needs its own case because
/// fixing one still leaves the others failing:
/// * the Roslyn key carries method-level generic arity (Foo&lt;&gt;) that a
/// Cobertura method name never has;
/// * Roslyn writes C# parameter modifiers (out T) where the CLR signature
/// writes by-ref (T&amp;);
/// * Roslyn carries nullable-reference annotations (string?) that the CLR
/// signature cannot express at all.
/// </summary>
public sealed class GenericAndOverloadMatchingTests
{
private static MethodComplexityResult Source(string methodName, string signature, string className = "Helper") =>
new()
{
Identity = new MethodIdentity
{
Namespace = "MyApp",
ClassName = className,
MethodName = methodName,
Signature = signature,
FullName = $"MyApp.{className}.{methodName}{signature}",
FilePath = "Test.cs",
LineNumber = 1
},
Complexity = 10
};

private static CoberturaMethodCoverage Cover(string methodName, string signature, double coverage,
string className = "MyApp.Helper") =>
new() { ClassName = className, MethodName = methodName, Signature = signature, Coverage = coverage };

[Fact]
public void GenericMethod_WithByRefOutParameter_TakesItsCoverage()
{
var result = MethodCoverageMatcher.Match(
[Source("TryConvertToNumeric<T>", "(string, out T)")],
[Cover("TryConvertToNumeric", "(System.String,T&)", 1.0)]);

result.Methods.Should().HaveCount(1);
result.Methods[0].Coverage.Should().Be(1.0);
}

[Fact]
public void GenericMethod_NoParameters_TakesItsCoverage()
{
var result = MethodCoverageMatcher.Match(
[Source("GetObjectTypeName<T>", "()")],
[Cover("GetObjectTypeName", "()", 0.75)]);

result.Methods[0].Coverage.Should().Be(0.75);
}

[Fact]
public void NullableReferenceAnnotation_DoesNotBlockTheMatch()
{
var result = MethodCoverageMatcher.Match(
[Source("BuildLink", "(string, string, int, string?)")],
[Cover("BuildLink", "(System.String,System.String,System.Int32,System.String)", 0.5)]);

result.Methods[0].Coverage.Should().Be(0.5);
}

[Fact]
public void OverloadSet_PairsEachOverloadWithItsOwnCoverage()
{
// The name-only fallback deliberately refuses an ambiguous set, so an overload
// set can only be resolved by the exact-signature pass. That is what makes
// signature normalization load-bearing rather than cosmetic.
var result = MethodCoverageMatcher.Match(
[
Source("BuildLink", "(string, string, int, string?)"),
Source("BuildLink", "(EcsDtoBase, string, string?)")
],
[
Cover("BuildLink", "(System.String,System.String,System.Int32,System.String)", 0.25),
Cover("BuildLink", "(MyApp.Dto.EcsDtoBase,System.String,System.String)", 0.75)
]);

result.Methods.Should().HaveCount(2);
result.Methods[0].Coverage.Should().Be(0.25);
result.Methods[1].Coverage.Should().Be(0.75);
}

[Fact]
public void GenuinelyUncoveredMethod_StillReportsZero()
{
// The repair must not invent coverage: a source method with no coverage entry
// at all still defaults to 0.0 and is still reported as unmatched.
var result = MethodCoverageMatcher.Match(
[Source("Untested<T>", "(int)")],
[Cover("SomethingElse", "()", 1.0)]);

result.Methods[0].Coverage.Should().Be(0.0);
result.Warnings.Should().Contain(w => w.Code == "UNMATCHED_METHODS");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,13 @@ public void FallbackMatch_SignatureMismatch_SingleCandidate()
}

[Fact]
public void FallbackMatch_OverloadedMethods_NoFallback()
public void OverloadedMethods_ResolvedByTheExactSignaturePass()
{
// Two overloads with incompatible signatures → fallback finds multiple candidates → no match
// This case used to assert 0.0. It was never ambiguous: `out int` and `System.Int32&`
// are the same parameter, so the exact-signature pass can pick the right overload once
// C# modifiers and CLR by-ref are folded together. Reaching the name-only fallback at
// all was the bug -- the fallback is right to refuse two candidates, but it should
// never have been asked.
var complexity = new[]
{
MakeComplexity("Process", signature: "(out int)")
Expand All @@ -360,9 +364,37 @@ public void FallbackMatch_OverloadedMethods_NoFallback()

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

// Fallback finds 2 candidates for "Process" → ambiguous → defaults to 0.0
result.Methods.Should().ContainSingle()
.Which.Coverage.Should().Be(0.7);
}

[Fact]
public void ArityRelaxedMatch_AmbiguousCandidates_Refuses()
{
// The arity-relaxed pass exists because a Cobertura method name usually carries no
// method-level arity. When it DOES (coverlet emits Find`1 in some runs) a merged
// coverage set can hold both spellings of the same name, and both reduce to the same
// arity-stripped key. Two candidates means nothing identifies which belongs to the
// source method, so the pass declines rather than guessing -- inventing coverage is
// the one outcome worse than reporting none.
var complexity = new[]
{
MakeComplexity("Find<T>", signature: "(int)")
};
var coverage = new[]
{
// Deliberately neither is Find`1: an exact hit on Find<>(int) would be resolved by
// the pass above and never reach the relaxed one. These two differ in arity, so
// both reduce to Find(int) while matching the source key exactly zero times.
MakeCoverage("Find`2", signature: "(System.Int32)", coverage: 0.9),
MakeCoverage("Find", signature: "(System.Int32)", coverage: 0.1)
};

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

result.Methods.Should().ContainSingle()
.Which.Coverage.Should().Be(0.0);
result.Warnings.Should().Contain(w => w.Code == "UNMATCHED_METHODS");
}

// === Preserves order ===
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,18 @@ public void Cobertura_GenericParameterType()
}

[Fact]
public void Cobertura_NullableType()
{
public void Cobertura_NullableType_DropsTheAnnotationBecauseTheOtherSideCannotCarryIt()
{
// The canonical key is a MATCHING key, not a display name: it may only carry detail
// both sides can express. Nullable-REFERENCE annotations exist on the Roslyn side and
// nowhere in a CLR signature, so `?` is dropped -- and dropped on both sides, which is
// why Nullable<int> normalizing to `int?` must lose its `?` here too. Otherwise
// Roslyn's `int?` and this key would disagree and every such overload would go
// unmatched. Formatting for humans is the reporter's job, not this key's.
var cov = MakeCobertura(
signature: "(System.Nullable`1<System.Int32>)");
CoberturaMethodParser.ToCanonicalKey(cov)
.Should().Be("MyApp.Service.DoWork(int?)");
.Should().Be("MyApp.Service.DoWork(int)");
}

[Fact]
Expand Down