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 ===