diff --git a/src/Crap4DotNet.Core/Matching/CoberturaMethodParser.cs b/src/Crap4DotNet.Core/Matching/CoberturaMethodParser.cs
index eb4df5b..74bf492 100644
--- a/src/Crap4DotNet.Core/Matching/CoberturaMethodParser.cs
+++ b/src/Crap4DotNet.Core/Matching/CoberturaMethodParser.cs
@@ -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)
diff --git a/src/Crap4DotNet.Core/Matching/MethodCoverageMatcher.cs b/src/Crap4DotNet.Core/Matching/MethodCoverageMatcher.cs
index 0238395..b8a7e5e 100644
--- a/src/Crap4DotNet.Core/Matching/MethodCoverageMatcher.cs
+++ b/src/Crap4DotNet.Core/Matching/MethodCoverageMatcher.cs
@@ -6,7 +6,7 @@ namespace Crap4DotNet.Core.Matching;
///
/// Performs a left-outer-join from complexity results to coverage entries per spec 6.4.
/// Every source method produces a result; unmatched methods default to coverage 0.0.
-/// Uses two-pass matching: exact canonical key first, then name-only fallback.
+/// Matching passes, in order: exact canonical key, relaxed key, name-only key.
///
public static class MethodCoverageMatcher
{
@@ -16,6 +16,7 @@ public static MatchResult Match(
{
// Build coverage lookups by normalized key
var fullKeyLookup = new Dictionary>(StringComparer.Ordinal);
+ var relaxedKeyLookup = new Dictionary>(StringComparer.Ordinal);
var nameKeyLookup = new Dictionary>(StringComparer.Ordinal);
foreach (var entry in coverageEntries)
@@ -23,15 +24,21 @@ public static MatchResult Match(
var fullKey = CoberturaMethodParser.ToCanonicalKey(entry);
AddToLookup(fullKeyLookup, fullKey, entry);
+ AddToLookup(relaxedKeyLookup, RelaxKey(fullKey), entry);
+
var nameKey = MethodKeyHelper.GetNameOnlyKey(fullKey);
AddToLookup(nameKeyLookup, nameKey, entry);
}
var matchedFullKeys = new HashSet(StringComparer.Ordinal);
+ var matchedRelaxedKeys = new HashSet(StringComparer.Ordinal);
var matchedNameKeys = new HashSet(StringComparer.Ordinal);
var methods = new List();
var unmatchedNames = new List();
var warnings = new List();
+ var sourceCountByRelaxedKey = complexityResults
+ .GroupBy(c => RelaxKey(RoslynMethodParser.ToCanonicalKey(c.Identity)), StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
foreach (var complexity in complexityResults)
{
@@ -49,6 +56,22 @@ public static MatchResult Match(
continue;
}
+ // Pass 1b: Relaxed key. Match only when exactly one coverage entry and exactly
+ // one source method have this key.
+ var relaxedKey = RelaxKey(fullKey);
+ if (relaxedKeyLookup.TryGetValue(relaxedKey, out var relaxedMatches)
+ && relaxedMatches.Count == 1
+ && sourceCountByRelaxedKey[relaxedKey] == 1)
+ {
+ matchedRelaxedKeys.Add(relaxedKey);
+ methods.Add(new MatchedMethod
+ {
+ Complexity = complexity,
+ Coverage = relaxedMatches[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)
@@ -80,7 +103,10 @@ public static MatchResult Match(
if (matchedFullKeys.Contains(kvp.Key))
continue;
- // Check if matched by name-only fallback
+ // Check if matched by the relaxed or name-only fallback
+ if (matchedRelaxedKeys.Contains(RelaxKey(kvp.Key)))
+ continue;
+
var nameKey = MethodKeyHelper.GetNameOnlyKey(kvp.Key);
if (matchedNameKeys.Contains(nameKey))
continue;
@@ -138,6 +164,19 @@ public static MatchResult Match(
};
}
+ ///
+ /// Canonical key without the method's generic arity and without ? annotations.
+ /// Cobertura method names often have no arity, and CLR signatures have no nullable reference types.
+ ///
+ private static string RelaxKey(string canonicalKey)
+ {
+ var sigStart = MethodKeyHelper.FindSignatureStart(canonicalKey);
+ return sigStart < 0
+ ? MethodKeyHelper.StripMethodGenericArity(canonicalKey)
+ : MethodKeyHelper.StripMethodGenericArity(canonicalKey[..sigStart])
+ + canonicalKey[sigStart..].Replace("?", "", StringComparison.Ordinal);
+ }
+
private static void AddToLookup(
Dictionary> lookup,
string key,
diff --git a/src/Crap4DotNet.Core/Matching/MethodIdentityNormalizer.cs b/src/Crap4DotNet.Core/Matching/MethodIdentityNormalizer.cs
index 8123776..9539ce8 100644
--- a/src/Crap4DotNet.Core/Matching/MethodIdentityNormalizer.cs
+++ b/src/Crap4DotNet.Core/Matching/MethodIdentityNormalizer.cs
@@ -74,6 +74,66 @@ public static List SplitTypeList(string typeList)
return result;
}
+ ///
+ /// Strip the generic arity marker from the method-name segment only.
+ ///
+ ///
+ /// Class-level arity is kept. Cobertura encodes it as a backtick (Cache`1).
+ ///
+ 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];
+ }
+
+ ///
+ /// Fold the out and in parameter modifiers to ref.
+ ///
+ ///
+ /// A CLR signature has one by-ref marker for all three modifiers.
+ /// ? annotations are kept, so Foo(int) and Foo(int?) stay distinct.
+ ///
+ 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;
+ });
+
+ return "(" + string.Join(", ", reduced) + ")";
+ }
+
///
/// Convert CLR backtick generic arity notation to angle bracket notation.
/// Cache`1 → Cache<>, Dictionary`2 → Dictionary<,>
diff --git a/src/Crap4DotNet.Core/Matching/RoslynMethodParser.cs b/src/Crap4DotNet.Core/Matching/RoslynMethodParser.cs
index a83d93e..731a75a 100644
--- a/src/Crap4DotNet.Core/Matching/RoslynMethodParser.cs
+++ b/src/Crap4DotNet.Core/Matching/RoslynMethodParser.cs
@@ -51,6 +51,6 @@ private static string NormalizeGenericTypeParams(string fullName)
return "<" + new string(',', paramCount - 1) + ">";
});
- return normalized + sigPart;
+ return normalized + MethodKeyHelper.NormalizeSignatureForMatching(sigPart);
}
}
diff --git a/tests/Crap4DotNet.Core.Tests/Matching/GenericAndOverloadMatchingTests.cs b/tests/Crap4DotNet.Core.Tests/Matching/GenericAndOverloadMatchingTests.cs
new file mode 100644
index 0000000..35ff20d
--- /dev/null
+++ b/tests/Crap4DotNet.Core.Tests/Matching/GenericAndOverloadMatchingTests.cs
@@ -0,0 +1,120 @@
+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;
+
+///
+/// Coverage matching for generic methods, parameter modifiers, nullable annotations, and overload sets.
+///
+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", "(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", "()")],
+ [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()
+ {
+ 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 NullableValueTypeOverloads_KeepTheirOwnCoverage()
+ {
+ var result = MethodCoverageMatcher.Match(
+ [Source("Foo", "(int)"), Source("Foo", "(int?)")],
+ [
+ Cover("Foo", "(System.Int32)", 0.2),
+ Cover("Foo", "(System.Nullable`1)", 0.8)
+ ]);
+
+ result.Methods[0].Coverage.Should().Be(0.2);
+ result.Methods[1].Coverage.Should().Be(0.8);
+ }
+
+ [Fact]
+ public void RelaxedMatch_SharedBySeveralSourceMethods_Refuses()
+ {
+ // Foo(int) and Foo(int) share the relaxed key Foo(int).
+ var result = MethodCoverageMatcher.Match(
+ [Source("Foo", "(int)"), Source("Foo", "(int)")],
+ [Cover("Foo", "(System.Int32)", 0.6)]);
+
+ result.Methods[0].Coverage.Should().Be(0.6);
+ result.Methods[1].Coverage.Should().Be(0.0);
+ }
+
+ [Fact]
+ public void GenuinelyUncoveredMethod_StillReportsZero()
+ {
+ var result = MethodCoverageMatcher.Match(
+ [Source("Untested", "(int)")],
+ [Cover("SomethingElse", "()", 1.0)]);
+
+ result.Methods[0].Coverage.Should().Be(0.0);
+ result.Warnings.Should().Contain(w => w.Code == "UNMATCHED_METHODS");
+ }
+}
diff --git a/tests/Crap4DotNet.Core.Tests/Matching/MethodCoverageMatcherTests.cs b/tests/Crap4DotNet.Core.Tests/Matching/MethodCoverageMatcherTests.cs
index 1479db4..a2853c2 100644
--- a/tests/Crap4DotNet.Core.Tests/Matching/MethodCoverageMatcherTests.cs
+++ b/tests/Crap4DotNet.Core.Tests/Matching/MethodCoverageMatcherTests.cs
@@ -345,9 +345,9 @@ 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
+ // `out int` and `System.Int32&` produce the same key.
var complexity = new[]
{
MakeComplexity("Process", signature: "(out int)")
@@ -360,9 +360,29 @@ 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()
+ {
+ var complexity = new[]
+ {
+ MakeComplexity("Find", signature: "(int)")
+ };
+ var coverage = new[]
+ {
+ // Neither entry matches Find exactly; both share its relaxed key.
+ 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 ===