From c6483619b70d10466159f7fa55c85137d002443f Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Tue, 13 Oct 2026 10:18:21 +0200 Subject: [PATCH 01/42] Fix post-release CRAP completeness, async grouping and exclusion metadata --- src/DotCov/CoverageReport.cs | 11 ++++-- src/DotCov/CrapAnalysis.cs | 13 +++++-- tests/DotCov.Tests/CliCrapTests.cs | 34 ++++++++++++++---- tests/DotCov.Tests/CrapAnalysisTests.cs | 40 +++++++++++++++++++++- tests/DotCov.Tests/CrapFoldingTests.cs | 19 ++++++++++ tests/DotCov.Tests/ExcludeTestCodeTests.cs | 33 ++++++++++++++++-- 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/src/DotCov/CoverageReport.cs b/src/DotCov/CoverageReport.cs index 7220969..61d1a18 100644 --- a/src/DotCov/CoverageReport.cs +++ b/src/DotCov/CoverageReport.cs @@ -791,6 +791,8 @@ public static CoverageReport Merge(CoverageReport a, CoverageReport b) WarnOnAmbiguousFileIdentity(a, b, warnings); + var exclusion = a.Exclusion.Union(b.Exclusion); + return new CoverageReport(merged.Values.ToList()) { Warnings = warnings, @@ -800,7 +802,12 @@ public static CoverageReport Merge(CoverageReport a, CoverageReport b) SourceRoots = a.SourceRoots.Concat(b.SourceRoots) .DistinctBy(PathIdentity.NormalizeRoot, StringComparer.Ordinal).ToList(), AmbiguousFiles = AmbiguousFile.Union(a.AmbiguousFiles.Concat(b.AmbiguousFiles)), - Exclusion = a.Exclusion.Union(b.Exclusion) + // The other input may reintroduce a previously excluded file. Keep the applied + // rules, but list only paths that remain absent, using the same identity as Files. + Exclusion = exclusion with + { + ExcludedFiles = exclusion.ExcludedFiles.Where(p => !merged.ContainsKey(p)).ToArray() + } }; } @@ -946,7 +953,7 @@ internal static List Remaining(IReadOnlyList ambig /// /// Exclusion patterns, distinct, in first-applied order. /// Keep exemptions, distinct, in first-applied order. -/// Paths removed, distinct, in first-seen order. +/// Paths removed and still absent from the report, distinct, in first-seen order. public sealed record FileExclusion( IReadOnlyList Patterns, IReadOnlyList Keep, diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 76e163e..db77ebb 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -118,7 +118,8 @@ internal CrapReport( /// Decide whether every method is at or below . A method exactly AT /// the threshold passes — the gate fires only strictly above, mirroring check's /// at-threshold-passes semantics (see ). The verdict needs - /// COMPLETE scoring: a single method makes it + /// COMPLETE scoring: a single method or + /// entry makes it /// , because the method the gate cannot score may be the /// one above the limit; zero scored methods is NoData too, and so is a report with /// . A gate that cannot see everything cannot vouch for anything. @@ -143,6 +144,11 @@ public CrapGateResult Evaluate(double maxCrap) Invariant($"{Unscored.Count} of {Methods.Count + Unscored.Count} methods could not be scored - ") + "supply --metrics (dotnet msbuild /t:Metrics); a verdict needs every method scored"); + if (UnmatchedMetricsMembers.Count > 0) + return new CrapGateResult(GateOutcome.NoData, maxCrap, Methods.Count, above, worst, + Invariant($"{UnmatchedMetricsMembers.Count} metrics members without coverage data - ") + + "a verdict needs every method scored"); + if (Methods.Count is 0) return new CrapGateResult(GateOutcome.NoData, maxCrap, 0, 0, null, "report carries no method-level data - nothing was measured per method"); @@ -271,6 +277,9 @@ private static List FoldRawEntries(IReadOnlyList order.Add(logical); } + // Any own state-machine body makes this the origin's body, even if a lambda + // created the group first. Never downgrade it when later entries are lambdas. + if (origin is { OwnBody: true }) logical.Origin = origin; logical.MergeLines(mc.LineHits); logical.MergeEmbeddedComplexity(mc.Complexity); } @@ -370,7 +379,7 @@ private sealed class LogicalMethod(MethodName name, string typeKey, int? arity, public readonly string TypeKey = typeKey; public readonly int? Arity = arity; /// Set for compiler-synthesized material; null for a method the source names directly. - public readonly SyntheticOrigin? Origin = origin; + public SyntheticOrigin? Origin = origin; public bool Folded => Origin is not null; public string File => Name.File; public readonly Dictionary Lines = new(); diff --git a/tests/DotCov.Tests/CliCrapTests.cs b/tests/DotCov.Tests/CliCrapTests.cs index 9ba0dce..c3f27da 100644 --- a/tests/DotCov.Tests/CliCrapTests.cs +++ b/tests/DotCov.Tests/CliCrapTests.cs @@ -240,11 +240,6 @@ await File.WriteAllTextAsync(metricsPath, """ - - - - - @@ -260,12 +255,37 @@ await File.WriteAllTextAsync(metricsPath, """ await Assert.That(code).IsEqualTo(1); await Assert.That(stderr).Contains("FAIL: worst CRAP 42.0"); - // The unmatched metrics member is listed, never silently dropped. - await Assert.That(stdout).Contains("void B.NeverCovered()"); + await Assert.That(stdout).Contains("MyApp.B.M"); } // ── Output formats ── + [Test] + [Arguments("table")] + [Arguments("md")] + [Arguments("json")] + public async Task Crap_UninstrumentedMetricsMethod_WithholdsPass_Exits3(string format) + { + var metrics = _ws.Write("incomplete.Metrics.xml", CodeMetricsReportBuilder.Create() + .Namespace("MyApp", t => t.Type("A", m => m + .Method("void A.M()", 5) + .Method("void A.NeverCovered()", 9))) + .ToBytes()); + + var (code, stdout, stderr) = await Run("crap", AtDefaultThreshold(), "--metrics", metrics, "--format", format); + + await Assert.That(code).IsEqualTo(DotCovCli.NoDataExitCode); + await Assert.That(stderr).StartsWith("NODATA: worst CRAP 30.0 (max 30)"); + await Assert.That(stderr).Contains("1 metrics members without coverage data"); + await Assert.That(stdout).DoesNotContain("PASS:"); + await Assert.That(stdout).Contains("void A.NeverCovered()"); + if (format is "json") + { + using var json = System.Text.Json.JsonDocument.Parse(stdout); + await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").GetString()).IsEqualTo("nodata"); + } + } + [Test] public async Task Crap_JsonFormat_EmitsGateAndMethods() { diff --git a/tests/DotCov.Tests/CrapAnalysisTests.cs b/tests/DotCov.Tests/CrapAnalysisTests.cs index 3f36375..f600098 100644 --- a/tests/DotCov.Tests/CrapAnalysisTests.cs +++ b/tests/DotCov.Tests/CrapAnalysisTests.cs @@ -464,6 +464,44 @@ public async Task MethodWithNoLines_ListedAsUnscored() await Assert.That(report.Unscored.Single().Reason).Contains("no line data"); } + [Test] + [Arguments(CodeMetricsMemberKind.Method, 1, 0)] + [Arguments(CodeMetricsMemberKind.Accessor, 1, 0)] + [Arguments(CodeMetricsMemberKind.Method, 6, 1)] + public async Task UnmatchedExecutableMetricsMember_IsNoData_WithMeasuredStatistics( + CodeMetricsMemberKind kind, int complexity, int aboveThreshold) + { + var report = CrapAnalysis.Analyze( + [Method("MyApp.A", "M", "()", complexity, [(1, 0)])], + [Member("MyApp.A", "Ghost", kind, 0, 9)]); + + var gate = report.Evaluate(30); + + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(gate.Outcome).IsEqualTo(GateOutcome.NoData); + await Assert.That(gate.IsPass).IsFalse(); + await Assert.That(gate.ScoredMethods).IsEqualTo(1); + await Assert.That(gate.AboveThreshold).IsEqualTo(aboveThreshold); + await Assert.That(gate.WorstScore).IsEqualTo(CrapAnalysis.Score(complexity, 0)); + await Assert.That(gate.Reason).Contains("1 metrics members without coverage data"); + await Assert.That(report.Evaluate(double.PositiveInfinity).Outcome).IsEqualTo(GateOutcome.Disabled); + } + + [Test] + public async Task MetricsAggregatesWithoutCoverage_DoNotPreventAMeasuredPass() + { + var report = CrapAnalysis.Analyze( + [Method("MyApp.A", "M", "()", 1, [(1, 1)])], + [ + Member("MyApp.A", "Value", CodeMetricsMemberKind.Property, null, 2), + Member("MyApp.A", "Field", CodeMetricsMemberKind.Field, null, 1), + Member("MyApp.A", "Changed", CodeMetricsMemberKind.Event, null, 1), + ]); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + // ── Exclusions ──────────────────────────────────────────────────────────── [Test] @@ -512,4 +550,4 @@ private static CodeMetricsMember Member( string typeName, string memberName, CodeMetricsMemberKind kind, int? arity, int complexity, string? display = null) => new(typeName, memberName, kind, arity, complexity, display ?? $"{typeName}.{memberName}"); -} \ No newline at end of file +} diff --git a/tests/DotCov.Tests/CrapFoldingTests.cs b/tests/DotCov.Tests/CrapFoldingTests.cs index 7ad47c3..6e3d66f 100644 --- a/tests/DotCov.Tests/CrapFoldingTests.cs +++ b/tests/DotCov.Tests/CrapFoldingTests.cs @@ -122,6 +122,25 @@ public async Task LambdasOfTwoOverloads_StayTwoGroups() await Assert.That(report.Methods).Contains(static m => m is { StartLine: 11, Coverage: 0.0 }); } + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task AsyncOverloadWithLambda_KeepsItsOwnBody_RegardlessOfEntryOrder(bool lambdaFirst) + { + // The covered sync overload would dilute the uncovered async body from 42 to 10.5 + // if the first lambda's OwnBody=false survived merging the state machine. + var sync = Method("Ns.T", "M", "(System.Int32)", 1, [(4, 1), (5, 1), (6, 1)]); + var lambda = Method("Ns.T/<>c", "b__1_0", "()", 1, [(12, 0)]); + var body = Method("Ns.T/d__1", "MoveNext", "()", 6, [(11, 0), (12, 0), (13, 0)]); + + var report = CrapAnalysis.Analyze(lambdaFirst ? [sync, lambda, body] : [sync, body, lambda]); + + await Assert.That(report.Methods.Select(static m => (m.StartLine, m.Complexity, m.Coverage, m.Score))) + .IsEquivalentTo([(4, 1, 1.0, 1.0), (11, 6, 0.0, 42.0)]); + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Fail); + } + [Test] public async Task LambdasOfOneMethod_FoldIntoItsOnlyOverload() { diff --git a/tests/DotCov.Tests/ExcludeTestCodeTests.cs b/tests/DotCov.Tests/ExcludeTestCodeTests.cs index 5f932cb..7be9b78 100644 --- a/tests/DotCov.Tests/ExcludeTestCodeTests.cs +++ b/tests/DotCov.Tests/ExcludeTestCodeTests.cs @@ -216,7 +216,7 @@ public async Task Test_GatesWithoutTestCode_AfterASuccessfulRun(CancellationToke // ── The library side: what an exclusion records ── [Test] - public async Task Exclude_Twice_AccumulatesRulesAndFiles_AndMergeUnionsThem() + public async Task Exclude_Twice_AccumulatesRulesAndFiles_AndMergeReconcilesThem() { var report = Cobertura.NewDoc() .AddClass("src/A.cs", c => c.Line(1, 1)) @@ -234,6 +234,35 @@ public async Task Exclude_Twice_AccumulatesRulesAndFiles_AndMergeUnionsThem() await Assert.That(twice.Exclusion.ExcludedFiles).IsEquivalentTo(["src/obj/B.g.cs", "tests/A.Tests/ATests.cs"]); await Assert.That(twice.Exclusion.Patterns).IsEquivalentTo([.. ExclusionRules.WellKnown, .. ExclusionRules.TestCode]); await Assert.That(twice.Exclusion.Keep).IsEquivalentTo(["Program.cs"]); - await Assert.That(merged.Exclusion.ExcludedFiles).IsEquivalentTo(["src/obj/B.g.cs", "tests/A.Tests/ATests.cs"]); + await Assert.That(merged.Files.Select(static f => f.Path)).IsEquivalentTo(["src/A.cs", "tests/A.Tests/ATests.cs"]); + await Assert.That(merged.Exclusion.ExcludedFiles).IsEquivalentTo(["src/obj/B.g.cs"]); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task Merge_ReintroducedFilesAreNotExcluded_AndAbsentPathsKeepTheirExactIdentity(bool excludedFirst) + { + var original = Cobertura.NewDoc() + .AddClass(TestFile, c => c.Line(1, 1)) + .AddClass("src/Only.g.cs", c => c.Line(2, 0)) + .Parse(); + var excluded = original.Exclude([TestFile, "Only.g.cs"], ["Program.cs"]); + var included = Cobertura.NewDoc() + .AddClass(TestFile, c => c.Line(1, 1)) + .AddClass("src/only.g.cs", c => c.Line(3, 0)) + .Parse(); + + var merged = excludedFirst ? CoverageReport.Merge(excluded, included) : CoverageReport.Merge(included, excluded); + + await Assert.That(merged.TotalLines).IsEqualTo(2); + await Assert.That(merged.TotalLinesHit).IsEqualTo(1); + await Assert.That(merged.Exclusion.ExcludedFiles).IsEquivalentTo(["src/Only.g.cs"]); + await Assert.That(merged.Exclusion.Patterns).IsEquivalentTo([TestFile, "Only.g.cs"]); + await Assert.That(merged.Exclusion.Keep).IsEquivalentTo(["Program.cs"]); + await Assert.That(excluded.Exclusion.ExcludedFiles).IsEquivalentTo([TestFile, "src/Only.g.cs"]); + using var json = JsonDocument.Parse(DotCov.Formatters.JsonFormatter.Format(merged)); + await Assert.That(json.RootElement.GetProperty("excluded").EnumerateArray().Select(static e => e.GetString()!)) + .IsEquivalentTo(["src/Only.g.cs"]); } } From 511ea1e6a77af677b32b4c562347d8425fd90b65 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Tue, 13 Oct 2026 10:58:33 +0200 Subject: [PATCH 02/42] Respect exclusions and non-executable declarations in CRAP completeness --- src/DotCov/CodeMetrics.cs | 37 ++++-- src/DotCov/CrapAnalysis.cs | 19 ++- src/DotCov/MethodCoverageReport.cs | 8 +- .../DotCov.Tests/CodeMetricsReportBuilder.cs | 9 +- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 120 ++++++++++++++++++ 5 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 tests/DotCov.Tests/CrapMetricsScopeTests.cs diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index fefabb3..e0e7bec 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -47,7 +47,14 @@ public readonly record struct CodeMetricsMember( CodeMetricsMemberKind Kind, int? Arity, int CyclomaticComplexity, - string DisplayName); + string DisplayName) +{ + /// + /// Executable lines reported by Roslyn. Zero identifies a declaration without a + /// measurable body; null means the metric is absent or invalid, not zero. + /// + public int? ExecutableLines { get; init; } +} /// /// Streaming reader for the Microsoft.CodeAnalysis.Metrics XML shape @@ -104,6 +111,7 @@ private sealed class PendingMember(string display, string element) public readonly string Display = display; public readonly string Element = element; public int? Complexity; + public int? ExecutableLines; } /// The document position ParseCore threads through its element handlers. @@ -146,7 +154,7 @@ private static void OnElementStart(XmlReader reader, ParserState state) break; case "Metric": - RecordComplexity(reader, state); + RecordMetric(reader, state); break; default: @@ -195,14 +203,24 @@ private static void CloseMember(ParserState state) state.Members.Add(Materialize(pending, state.Namespace, state.TypeChain, complexity)); } - private static void RecordComplexity(XmlReader reader, ParserState state) + private static void RecordMetric(XmlReader reader, ParserState state) { // Assign to the innermost OPEN member: a Property's own Metrics arrive before its // , so accessor metrics can never leak upward. - if (state.MemberStack.Count > 0 && - reader.GetAttribute("Name") is "CyclomaticComplexity" && - int.TryParse(reader.GetAttribute("Value"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - state.MemberStack.Peek().Complexity = value; + if (state.MemberStack.Count is 0 || + !int.TryParse(reader.GetAttribute("Value"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return; + + var pending = state.MemberStack.Peek(); + switch (reader.GetAttribute("Name")) + { + case "CyclomaticComplexity": + pending.Complexity = value; + break; + case "ExecutableLines": + pending.ExecutableLines = value >= 0 ? value : null; + break; + } } private static CodeMetricsMember Materialize( @@ -214,7 +232,10 @@ private static CodeMetricsMember Materialize( var enclosingSimpleName = SimpleName(typeChain[^1]); var (memberName, arity, kind) = ParseDisplay(pending.Display, pending.Element, enclosingSimpleName); - return new CodeMetricsMember(typeName, memberName, kind, arity, complexity, pending.Display); + return new CodeMetricsMember(typeName, memberName, kind, arity, complexity, pending.Display) + { + ExecutableLines = pending.ExecutableLines + }; } private static string SimpleName(string dottedType) diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index db77ebb..1fdfe88 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -206,7 +206,7 @@ internal static double DisplayScore(double score, double maxCrap) => public static CrapReport Analyze(MethodCoverageReport report, IReadOnlyList? metricsMembers = null) { ArgumentNullException.ThrowIfNull(report); - var analysis = Analyze(report.Methods, metricsMembers); + var analysis = AnalyzeCore(report.Methods, metricsMembers, report.ExcludedMethods); return new CrapReport(analysis.Methods, analysis.Unscored, analysis.UnmatchedMetricsMembers) { AmbiguousFiles = report.AmbiguousFiles, @@ -228,12 +228,26 @@ public static CrapReport Analyze(MethodCoverageReport report, IReadOnlyList public static CrapReport Analyze( IReadOnlyList methods, - IReadOnlyList? metricsMembers = null) + IReadOnlyList? metricsMembers = null) => + AnalyzeCore(methods, metricsMembers, []); + + private static CrapReport AnalyzeCore( + IReadOnlyList methods, + IReadOnlyList? metricsMembers, + IReadOnlyList excludedMethods) { var order = FoldRawEntries(methods); // 1. raw coverage → logical source methods FoldSyntheticGroups(order); // 2. state machines/lambdas → their origin var consumed = new HashSet(); + // Exclusions remove methods from the measurement scope, not from the metrics file. + // Match their identities without scoring them, even when they have no line data. + var (metricsIndex, accessorFallback) = IndexMetrics(metricsMembers); + var excluded = FoldRawEntries(excludedMethods); + FoldSyntheticGroups(excluded); + foreach (var logical in excluded.Where(static l => !l.FoldedAway)) + MatchMetrics(logical, metricsMembers, metricsIndex, accessorFallback, consumed); + var (scored, unscored) = ScoreMethods(order, metricsMembers, consumed); // 3. resolve comp, score var unmatched = CollectUnmatched(metricsMembers, consumed); // 4. honesty channel @@ -365,6 +379,7 @@ private static List CollectUnmatched(IReadOnlyList? m if (metricsMembers is not null) for (var i = 0; i < metricsMembers.Count; i++) if (metricsMembers[i].Kind is CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor && + metricsMembers[i].ExecutableLines is not 0 && !consumed.Contains(i)) unmatched.Add(metricsMembers[i].DisplayName); return unmatched; diff --git a/src/DotCov/MethodCoverageReport.cs b/src/DotCov/MethodCoverageReport.cs index bfede36..e839469 100644 --- a/src/DotCov/MethodCoverageReport.cs +++ b/src/DotCov/MethodCoverageReport.cs @@ -41,6 +41,10 @@ public MethodCoverageReport( /// The exclusions applied to this report and the files they removed. public FileExclusion Exclusion { get; init; } = FileExclusion.None; + // Retain identities removed by Exclude for metrics completeness accounting only. + // They must never contribute lines, complexity, or unscored rows to the selected scope. + internal IReadOnlyList ExcludedMethods { get; init; } = []; + /// /// Remove every method whose file matches , unless it matches /// — exactly the substring-match semantics of @@ -59,7 +63,9 @@ public MethodCoverageReport Exclude(IEnumerable patterns, IEnumerable excluded.Contains(m.File, StringComparer.Ordinal))).ToArray() }; } } diff --git a/tests/DotCov.Tests/CodeMetricsReportBuilder.cs b/tests/DotCov.Tests/CodeMetricsReportBuilder.cs index 6db35cf..cf922c9 100644 --- a/tests/DotCov.Tests/CodeMetricsReportBuilder.cs +++ b/tests/DotCov.Tests/CodeMetricsReportBuilder.cs @@ -67,18 +67,23 @@ public TypeListBuilder Type(string name, Action members) internal sealed class MemberListBuilder(XElement members) { - public MemberListBuilder Method(string display, int complexity) => Member("Method", display, complexity, null); + public MemberListBuilder Method(string display, int complexity, int? executableLines = null) => + Member("Method", display, complexity, null, executableLines); public MemberListBuilder Property(string display, int complexity, Action? accessors = null) => Member("Property", display, complexity, accessors); - private MemberListBuilder Member(string element, string display, int complexity, Action? accessors) + private MemberListBuilder Member(string element, string display, int complexity, Action? accessors, + int? executableLines = null) { var member = new XElement(element, new XAttribute("Name", display), new XAttribute("File", "/src/Probe.cs"), new XAttribute("Line", "1"), Metrics(complexity)); + if (executableLines is { } lines) + member.Element("Metrics")!.Add(new XElement("Metric", + new XAttribute("Name", "ExecutableLines"), new XAttribute("Value", lines))); if (accessors is not null) { var list = new XElement("Accessors"); diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs new file mode 100644 index 0000000..3281547 --- /dev/null +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -0,0 +1,120 @@ +using System.Text; +using System.Text.Json; +using DotCov.Tests.Infrastructure; +using DotCov.Tool; + +namespace DotCov.Tests; + +public sealed class CrapMetricsScopeTests +{ + [Test] + [Arguments("0", 0)] + [Arguments("3", 3)] + [Arguments("-1", null)] + [Arguments("unknown", null)] + [Arguments("", null)] + [Arguments("2147483648", null)] + public async Task ExecutableLines_ArePreserved_AndOnlyExplicitZeroExemptsMissingCoverage( + string value, int? expected) + { + var xml = $""" + + + + + + + """; + using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + var metrics = CodeMetricsReader.Parse(input); + await Assert.That(metrics.Single().ExecutableLines).IsEqualTo(expected); + + var report = CrapAnalysis.Analyze(KeptCoverage(), metrics); + await Assert.That(report.Evaluate(30).Outcome) + .IsEqualTo(expected is 0 ? GateOutcome.Pass : GateOutcome.NoData); + } + + [Test] + public async Task ExecutableLines_StayOnTheirOwnMember_AndDoNotEraseMeasuredBodies() + { + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.Kept()", 6, 0) + .Method("void A.InterfaceMethod()", 1, 0) + .Property("int A.AbstractValue", 2, a => a + .Method("int A.AbstractValue.get", 1, 0) + .Method("void A.AbstractValue.set", 1, 0)) + .Method("void A.Unknown()", 1)); + + await Assert.That(metrics.Single(m => m.MemberName == "AbstractValue").ExecutableLines).IsNull(); + await Assert.That(metrics.Single(m => m.MemberName == "Unknown").ExecutableLines).IsNull(); + var report = CrapAnalysis.Analyze(KeptCoverage(), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.Unknown()"]); + await Assert.That(report.Methods.Single().Method).IsEqualTo("App.A.Kept"); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task RepeatedExclusions_AccountForRemovedIdentities_WithoutScoringThem(bool withMetrics) + { + var coverage = Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("Program.cs", "App.Program", c => c.Method("Main", "()", null, _ => { })) + .AddClass("tests/Test.cs", "App.Test", c => c.Method("Run", "()", "9", m => m.Line(1, 0))) + .AddClass("tests/Test.cs", "App.Test/<>c", c => c.Method("b__0_0", "()", "9", m => m.Line(2, 0))); + using var input = new MemoryStream(coverage.ToBytes()); + var original = CoberturaParser.ParseMethods(input); + var filtered = original.Exclude(["Program.cs"], []).Exclude(["tests/"], []); + var metrics = CodeMetricsReportBuilder.Create().Namespace("App", t => t + .Type("A", m => m.Method("void A.Kept()", 1)) + .Type("Program", m => m.Method("void Program.Main()", 9)) + .Type("Test", m => m.Method("void Test.Run()", 9))).Parse(); + + var report = CrapAnalysis.Analyze(filtered, withMetrics ? metrics : null); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + await Assert.That(report.Methods.Count).IsEqualTo(1); + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(original.Methods.Count).IsEqualTo(4); + await Assert.That(original.Exclusion.ExcludedFiles).IsEmpty(); + } + + [Test] + [Arguments(false, false, 0, "pass")] + [Arguments(true, false, 1, "fail")] + [Arguments(false, true, 3, "nodata")] + public async Task Cli_ExclusionsAndDeclarations_RespectSelectedScope( + bool keepProgram, bool missingExecutable, int expectedExit, string expectedOutcome) + { + using var ws = TempWorkspace.Create("dotcov-crap-scope-"); + var coverage = ws.Write("coverage.cobertura.xml", Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", null, m => m.Line(1, 1))) + .AddClass("Program.cs", "App.Program/
d__0", c => c.Method("MoveNext", "()", "9", m => m.Line(1, 0))) + .ToBytes()); + var metrics = ws.Write("metrics.xml", CodeMetricsReportBuilder.Create().Namespace("App", t => t + .Type("A", m => m.Method("void A.Kept()", 1, 1) + .Method("void A.Missing()", 1, missingExecutable ? 1 : 0) + .Property("int A.AbstractValue", 1, a => a.Method("int A.AbstractValue.get", 1, 0))) + .Type("Program", m => m.Method("void Program.Main()", 9, 1))).ToBytes()); + List args = ["crap", coverage, "--metrics", metrics, "--exclude-generated", "--format", "json"]; + if (keepProgram) args.AddRange(["--keep", "Program.cs"]); + var stdout = new StringWriter(); + var stderr = new StringWriter(); + + var exit = await DotCovCli.RunAsync(args.ToArray(), stdout, stderr); + + await Assert.That(exit).IsEqualTo(expectedExit); + using var json = JsonDocument.Parse(stdout.ToString()); + await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").GetString()).IsEqualTo(expectedOutcome); + await Assert.That(json.RootElement.GetProperty("methods").GetArrayLength()).IsEqualTo(keepProgram ? 2 : 1); + if (missingExecutable) + await Assert.That(stderr.ToString()).Contains("1 metrics members without coverage data"); + } + + private static MethodCoverageReport KeptCoverage() + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))).ToBytes()); + return CoberturaParser.ParseMethods(input); + } +} From 84f449d2c03c62652f339158bff70f2eebba4ebd Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 11:53:34 +0200 Subject: [PATCH 03/42] Fix ambiguous exclusion accounting and prepare DotCov 3.0.1 --- .github/actions/badge/action.yml | 2 +- Directory.Build.props | 13 ++--- README.md | 4 +- docs/releases/3.0.1.md | 34 ++++++++++++ src/DotCov/CrapAnalysis.cs | 22 +++++++- src/DotCov/MethodCoverageReport.cs | 3 +- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 61 +++++++++++++++++++++ 7 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 docs/releases/3.0.1.md diff --git a/.github/actions/badge/action.yml b/.github/actions/badge/action.yml index c6a6552..1cc83eb 100644 --- a/.github/actions/badge/action.yml +++ b/.github/actions/badge/action.yml @@ -46,7 +46,7 @@ inputs: default: 'true' version: description: dotcov.tool version to install. - default: '3.0.0' + default: '3.0.1' command: description: >- Command that runs dotcov instead of installing it, split on whitespace, e.g. diff --git a/Directory.Build.props b/Directory.Build.props index 1d5b625..2a65ad3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ - 3.0.0 + 3.0.1 local $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix) @@ -29,12 +29,11 @@ -DotCov 3.0.0 changes three gate contracts; every number a gate decides on may move. -1. Thresholds are explicit: check, test, the Fallout target and the badge action have no default --min-line 80. Pass --min-line, --min-branch, or both (0 to 100); without either the command exits 2 before reading a report. -2. The CRAP gate needs every method scored: one unscored method makes it NODATA (exit 3). Emitters without a usable complexity attribute need --metrics. -3. File identity is deterministic: several source roots no longer trigger a disk probe; unresolved files make check, crap and test NODATA until --probe-sources or --source-root. -Fixes: contradictory condition detail and empty class summaries no longer inflate branch and line rates; condition identity no longer depends on XML packaging; CRAP rows stay per file and per overload, raising diluted scores; ./src/C.cs and src/C.cs count once; ParseAsync finishes method-only classes. -New: --exclude-test-code. Full notes: https://github.com/ANcpLua/dotcov/blob/main/docs/releases/$(VersionPrefix).md +DotCov 3.0.1 restores the 3.0 complete-scoring and exclusion contracts. +CRAP returns NODATA when executable metrics members lack coverage. Explicitly non-executable declarations and uniquely matched excluded methods do not block a measured pass; ambiguous excluded overloads remain NODATA. +Async methods preserve their own body regardless of lambda input order, preventing score dilution into synchronous overloads. +Merged reports no longer list files as excluded when another input contributes their coverage. Exclusion identity retention uses a set lookup. +Full notes: https://github.com/ANcpLua/dotcov/blob/main/docs/releases/$(VersionPrefix).md diff --git a/README.md b/README.md index 5249c0b..ce3d81e 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ permissions: steps: # … checkout, setup-dotnet, and a test run writing Cobertura to TestResults/ - - uses: ANcpLua/dotcov/.github/actions/badge@v3.0.0 + - uses: ANcpLua/dotcov/.github/actions/badge@v3.0.1 with: reports: TestResults min-line: 80 @@ -249,5 +249,5 @@ fallout Coverage # tests plus the coverage gate ## Feedback [Issues](https://github.com/ANcpLua/dotcov/issues) · -[Release notes](docs/releases/3.0.0.md) · +[Release notes](docs/releases/3.0.1.md) · [MIT](LICENSE) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md new file mode 100644 index 0000000..2b4c8ee --- /dev/null +++ b/docs/releases/3.0.1.md @@ -0,0 +1,34 @@ +# DotCov 3.0.1 + +This patch restores the complete-scoring and exclusion contracts introduced in 3.0. +Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. + +- **Missing executable metrics members prevent a CRAP PASS.** A method or accessor in + the metrics file without a coverage counterpart makes the gate `NODATA` (exit `3`). + Measured scores and offender counts remain available in the report. +- **Exclusions and declarations are accounted for.** Methods intentionally removed by + file exclusions are matched for completeness without scoring them. A match must be + unique: ambiguous overloads remain unmatched rather than hiding an unmeasured sibling. + `--keep` exemptions remain measured. Roslyn metrics with `ExecutableLines=0` do not + require coverage; missing or invalid values do not waive the completeness check. +- **Async overloads keep their own bodies in either input order.** A lambda appearing + before its async state-machine body no longer lets that body merge into a synchronous + overload and dilute its CRAP score. +- **Merged exclusion metadata reflects the resulting files.** A file contributed by + another report is no longer also listed as excluded. Applied rules and keep exemptions + remain available. Retaining excluded method identities uses a set lookup to avoid + quadratic scans on reports with many excluded files. + +The library adds nullable `CodeMetricsMember.ExecutableLines`; existing constructor and +analysis signatures remain available. CLI flags, exit codes, and threshold rules are +unchanged from 3.0.0. + +## Upgrade + +```bash +dotnet tool update -g DotCov.Tool --version 3.0.1 +``` + +Update the badge action to `ANcpLua/dotcov/.github/actions/badge@v3.0.1` and the `DotCov` +and `DotCov.Fallout` package references to `3.0.1`. The explicit thresholds and file +identity requirements from the [3.0 release notes](3.0.0.md) still apply. diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 1fdfe88..b3db6e1 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -242,11 +242,11 @@ private static CrapReport AnalyzeCore( var consumed = new HashSet(); // Exclusions remove methods from the measurement scope, not from the metrics file. // Match their identities without scoring them, even when they have no line data. - var (metricsIndex, accessorFallback) = IndexMetrics(metricsMembers); + var (metricsIndex, _) = IndexMetrics(metricsMembers); var excluded = FoldRawEntries(excludedMethods); FoldSyntheticGroups(excluded); foreach (var logical in excluded.Where(static l => !l.FoldedAway)) - MatchMetrics(logical, metricsMembers, metricsIndex, accessorFallback, consumed); + MatchExcludedMetrics(logical, metricsMembers, metricsIndex, consumed); var (scored, unscored) = ScoreMethods(order, metricsMembers, consumed); // 3. resolve comp, score var unmatched = CollectUnmatched(metricsMembers, consumed); // 4. honesty channel @@ -254,6 +254,24 @@ private static CrapReport AnalyzeCore( return new CrapReport(scored, unscored, unmatched); } + // Scoring may conservatively take the maximum complexity of several candidates. + // Exclusions cannot use that fallback: consuming siblings would hide missing coverage. + // Without source-file/signature identity, only a unique compatible member is proven. + private static void MatchExcludedMetrics( + LogicalMethod logical, + IReadOnlyList? members, + Dictionary> metricsIndex, + HashSet consumed) + { + if (members is null) return; + var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; + if (!metricsIndex.TryGetValue(key, out var candidates)) return; + + var compatible = candidates.Where(i => logical.Arity is null || members[i].Arity is null || + logical.Arity == members[i].Arity).ToList(); + if (compatible.Count is 1) consumed.Add(compatible[0]); + } + /// /// Step 1: fold raw coverage entries into logical source methods, per FILE: the same type /// and method name in two files — two assemblies' Ns.T.M(Int32), or two parts of a diff --git a/src/DotCov/MethodCoverageReport.cs b/src/DotCov/MethodCoverageReport.cs index e839469..578e26d 100644 --- a/src/DotCov/MethodCoverageReport.cs +++ b/src/DotCov/MethodCoverageReport.cs @@ -59,13 +59,14 @@ public MethodCoverageReport Exclude(IEnumerable patterns, IEnumerable m.File, rules, keepRules); + var excludedPaths = excluded.ToHashSet(StringComparer.Ordinal); return new MethodCoverageReport(kept, Warnings, SourceRoots) { AmbiguousFiles = ExclusionRules.Remaining(AmbiguousFiles, excluded), Exclusion = Exclusion.Then(rules, keepRules, excluded), ExcludedMethods = ExcludedMethods.Concat( - Methods.Where(m => excluded.Contains(m.File, StringComparer.Ordinal))).ToArray() + Methods.Where(m => excludedPaths.Contains(m.File))).ToArray() }; } } diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 3281547..73bfe79 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -7,6 +7,67 @@ namespace DotCov.Tests; public sealed class CrapMetricsScopeTests { + [Test] + [Arguments("(System.Int32)", "void A.M(int x)", "void A.M(string x)", 2)] + [Arguments("", "void A.M(int x)", "void A.M(string x)", 2)] + [Arguments("(System.Int32)", "void A.M(int x)", "void A.M(int x, int y)", 1)] + [Arguments("(System.Int32)", "void A.M(int x, int y)", "void A.Other()", 2)] + [Arguments("(System.Int32)", "void A.M", "void A.Other()", 1)] + public async Task ExcludedOverloads_OnlyConsumeUniqueCompatibleMetrics( + string signature, string first, string second, int unmatched) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.A", c => c.Method("M", signature, "1", m => m.Line(2, 1))) + .ToBytes()); + var filtered = CoberturaParser.ParseMethods(input).Exclude([".g.cs"], []); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method(first, 1, 1).Method(second, 9, 1)); + + var report = CrapAnalysis.Analyze(filtered, metrics); + + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(unmatched); + await Assert.That(report.UnmatchedMetricsMembers).Contains(second); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + await Assert.That(report.Methods.Count).IsEqualTo(1); + } + + [Test] + public async Task ExcludedMethodWithNoMetricsMatch_DoesNotHideAnotherMember() + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.A", c => c.Method("Generated", "()", "1", m => m.Line(2, 1))) + .ToBytes()); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input).Exclude([".g.cs"], []), + CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.Missing()", 1, 1))); + + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.Missing()"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + public async Task Cli_ExcludedSameArityOverload_CannotHideAnUninstrumentedSibling() + { + using var ws = TempWorkspace.Create("dotcov-crap-overload-scope-"); + var coverage = ws.Write("coverage.cobertura.xml", Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.A", c => c.Method("M", "(System.Int32)", "1", m => m.Line(2, 1))) + .ToBytes()); + var metrics = ws.Write("metrics.xml", CodeMetricsReportBuilder.Create().Namespace("App", t => t + .Type("A", m => m.Method("void A.M(int x)", 1, 1).Method("void A.M(string x)", 9, 1))).ToBytes()); + var stdout = new StringWriter(); + var stderr = new StringWriter(); + + var exit = await DotCovCli.RunAsync( + ["crap", coverage, "--metrics", metrics, "--exclude-generated", "--format", "json"], stdout, stderr); + + await Assert.That(exit).IsEqualTo(DotCovCli.NoDataExitCode); + using var json = JsonDocument.Parse(stdout.ToString()); + await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").GetString()).IsEqualTo("nodata"); + await Assert.That(stderr.ToString()).Contains("2 metrics members without coverage data"); + } + [Test] [Arguments("0", 0)] [Arguments("3", 3)] From fc66e14e100da5886f9e0cbae7b462e36b18128a Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 11:54:56 +0200 Subject: [PATCH 04/42] Pin patched cryptography dependencies required by NuGet audit --- Directory.Packages.props | 5 +++++ docs/releases/3.0.1.md | 3 +++ 2 files changed, 8 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 518c719..f11ed71 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,9 @@ + + true 1.74.0 10.1.0 10.4.0 @@ -13,6 +16,8 @@ + + diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 2b4c8ee..4d4fe12 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -18,6 +18,9 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. another report is no longer also listed as excluded. Applied rules and keep exemptions remain available. Retaining excluded method identities uses a set lookup to avoid quadratic scans on reports with many excluded files. +- **Patched Fallout dependencies.** The Fallout integration constrains its transitive + `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies + to 10.0.13, addressing the security advisories published on October 13, 2026. The library adds nullable `CodeMetricsMember.ExecutableLines`; existing constructor and analysis signatures remain available. CLI flags, exit codes, and threshold rules are From dfbe8f17c40616dd6bb99b6dca3eb931c0984d27 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:01:42 +0200 Subject: [PATCH 05/42] Require unique executable metrics matches for coverage completeness --- docs/releases/3.0.1.md | 5 +- src/DotCov/CrapAnalysis.cs | 19 +++++-- tests/DotCov.Tests/CrapAnalysisEdgeTests.cs | 5 +- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 61 +++++++++++++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 4d4fe12..a3f664b 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -6,9 +6,12 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. - **Missing executable metrics members prevent a CRAP PASS.** A method or accessor in the metrics file without a coverage counterpart makes the gate `NODATA` (exit `3`). Measured scores and offender counts remain available in the report. + Ambiguous measured overload matches retain conservative complexity scores but cannot + silently mark an unmeasured sibling as covered. - **Exclusions and declarations are accounted for.** Methods intentionally removed by file exclusions are matched for completeness without scoring them. A match must be - unique: ambiguous overloads remain unmatched rather than hiding an unmeasured sibling. + unique among executable candidates: declarations without bodies do not create false + ambiguity, while ambiguous executable overloads remain unmatched. `--keep` exemptions remain measured. Roslyn metrics with `ExecutableLines=0` do not require coverage; missing or invalid values do not waive the completeness check. - **Async overloads keep their own bodies in either input order.** A lambda appearing diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index b3db6e1..96ab0a8 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -269,7 +269,14 @@ private static void MatchExcludedMetrics( var compatible = candidates.Where(i => logical.Arity is null || members[i].Arity is null || logical.Arity == members[i].Arity).ToList(); - if (compatible.Count is 1) consumed.Add(compatible[0]); + ConsumeUniqueExecutableMetrics(compatible, members, consumed); + } + + private static void ConsumeUniqueExecutableMetrics( + IEnumerable candidates, IReadOnlyList members, HashSet consumed) + { + var executable = candidates.Where(i => members[i].ExecutableLines is not 0).Take(2).ToList(); + if (executable.Count is 1) consumed.Add(executable[0]); } /// @@ -506,8 +513,8 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( } /// - /// The metrics-file complexity for , marking every consulted - /// member as matched; null when the table has no counterpart. + /// The metrics-file complexity for , marking only a unique + /// executable candidate as matched; null when the table has no counterpart. /// private static int? MatchMetrics( LogicalMethod logical, @@ -523,8 +530,8 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( { // Overload disambiguation by arity when both sides know it. When arity is unknown // (folded state machines/lambdas) or nothing matches it, take the MAX complexity - // among the same-named candidates — the conservative direction for a gate — and - // mark every candidate consulted as matched. + // among the same-named candidates — the conservative direction for a gate. + // Consulting an ambiguous candidate does not prove it has coverage. var pool = candidates; if (logical.Arity is { } a) { @@ -532,10 +539,10 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( if (exact.Count > 0) pool = exact; } + ConsumeUniqueExecutableMetrics(pool, members, consumed); var best = 0; foreach (var i in pool) { - consumed.Add(i); best = Math.Max(best, members[i].CyclomaticComplexity); } return best; diff --git a/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs b/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs index e5324cd..f80cd12 100644 --- a/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs +++ b/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs @@ -226,7 +226,7 @@ public async Task MetricsSupplied_ButNoCounterpart_ListedUnscoredWithTheMetricsR } [Test] - public async Task UnparseableSignature_ArityUnknown_TakesTheMaxAcrossOverloads() + public async Task UnparseableSignature_ArityUnknown_TakesTheMaxButLeavesAmbiguousMetricsUnmatched() { var metrics = new[] { @@ -240,7 +240,8 @@ public async Task UnparseableSignature_ArityUnknown_TakesTheMaxAcrossOverloads() var m = await Assert.That(report.Methods).HasSingleItem(); await Assert.That(m.Complexity).IsEqualTo(7); - await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } } diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 73bfe79..c86576e 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -7,6 +7,67 @@ namespace DotCov.Tests; public sealed class CrapMetricsScopeTests { + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task MeasuredOverload_CannotConsumeItsUnmeasuredSibling(bool embeddedComplexity) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("M", "(System.Int32)", + embeddedComplexity ? "1" : null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(int x)", 1, 1).Method("void A.M(string x)", 9, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(embeddedComplexity ? 1 : 9); + await Assert.That(report.UnmatchedMetricsMembers) + .IsEquivalentTo(["void A.M(int x)", "void A.M(string x)"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ZeroBodyOverload_DoesNotMakeExecutableMatchAmbiguous(bool exclude) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.A", c => c.Method("M", "(System.String)", "1", m => m.Line(2, 1))) + .ToBytes()); + var coverage = CoberturaParser.ParseMethods(input); + if (exclude) coverage = coverage.Exclude([".g.cs"], []); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(int x)", 1, 0).Method("void A.M(string x)", 9, 1)); + + var report = CrapAnalysis.Analyze(coverage, metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Count).IsEqualTo(exclude ? 1 : 2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + public async Task Cli_MeasuredSameArityOverload_CannotPassWithAnUninstrumentedSibling() + { + using var ws = TempWorkspace.Create("dotcov-crap-measured-overload-"); + var coverage = ws.Write("coverage.cobertura.xml", Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("M", "(System.Int32)", "1", m => m.Line(1, 1))) + .ToBytes()); + var metrics = ws.Write("metrics.xml", CodeMetricsReportBuilder.Create().Namespace("App", t => t + .Type("A", m => m.Method("void A.M(int x)", 1, 1).Method("void A.M(string x)", 9, 1))).ToBytes()); + var stdout = new StringWriter(); + var stderr = new StringWriter(); + + var exit = await DotCovCli.RunAsync( + ["crap", coverage, "--metrics", metrics, "--format", "json"], stdout, stderr); + + await Assert.That(exit).IsEqualTo(DotCovCli.NoDataExitCode); + using var json = JsonDocument.Parse(stdout.ToString()); + await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").GetString()).IsEqualTo("nodata"); + await Assert.That(stderr.ToString()).Contains("2 metrics members without coverage data"); + } + [Test] [Arguments("(System.Int32)", "void A.M(int x)", "void A.M(string x)", 2)] [Arguments("", "void A.M(int x)", "void A.M(string x)", 2)] From 854bbd9256bf42ed19ab2472ae2512e512f66b89 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:02:10 +0200 Subject: [PATCH 06/42] Align metrics matching documentation with completeness semantics --- src/DotCov/CrapAnalysis.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 96ab0a8..11ca457 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -491,9 +491,9 @@ private static (Dictionary> Index, Dictionary Acc } /// - /// Two separate decisions. First the metrics table is consulted and every consulted member - /// is marked matched — a member with a coverage counterpart is matched whether or not its - /// value ends up being used. Then the value is chosen: embedded complexity wins because it + /// Two separate decisions. First the metrics table is consulted and a unique executable + /// counterpart is marked matched, whether or not its value ends up being used. + /// Then the value is chosen: embedded complexity wins because it /// measured the exact assembly that was covered; the metrics value fills in only when the /// report carries none. /// From 4fe91099b2d97453633f406320af8648b9fc7d57 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:08:29 +0200 Subject: [PATCH 07/42] Reject incompatible metrics arities while preserving extension receivers --- docs/releases/3.0.1.md | 3 +- src/DotCov/CodeMetrics.cs | 9 +++++ src/DotCov/CrapAnalysis.cs | 24 +++++------- tests/DotCov.Tests/CrapAnalysisEdgeTests.cs | 2 +- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 42 +++++++++++++++++++++ 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index a3f664b..125f1f8 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -7,7 +7,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. the metrics file without a coverage counterpart makes the gate `NODATA` (exit `3`). Measured scores and offender counts remain available in the report. Ambiguous measured overload matches retain conservative complexity scores but cannot - silently mark an unmeasured sibling as covered. + silently mark an unmeasured sibling as covered. Known parameter-count mismatches are + not accepted as coverage matches; extension-block receiver parameters remain supported. - **Exclusions and declarations are accounted for.** Methods intentionally removed by file exclusions are matched for completeness without scoring them. A match must be unique among executable candidates: declarations without bodies do not create false diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index e0e7bec..cac4cd8 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -439,6 +439,15 @@ private static string LastDotSegment(string head) return lastDot < 0 ? head[start..] : head[(start + lastDot + 1)..]; } + // Instance extension-block members add the receiver to their compiled parameter list. + // Inspect the declaring display prefix, not parameter/default-value text. + internal static bool IsExtensionBlockMember(string display) + { + var (head, _) = SplitParameterList(display); + var start = BalancedText.LastIndexOfTopLevel(head, ' ') + 1; + return StripGenericsPerSegment(head[start..]).StartsWith("extension(", StringComparison.Ordinal); + } + /// /// C# operator token → CLS op_* method name: a mechanical mapping, so a table, not a /// switch. Unary/binary + and - share a token and are disambiguated by arity diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 11ca457..738d07e 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -267,11 +267,14 @@ private static void MatchExcludedMetrics( var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; if (!metricsIndex.TryGetValue(key, out var candidates)) return; - var compatible = candidates.Where(i => logical.Arity is null || members[i].Arity is null || - logical.Arity == members[i].Arity).ToList(); + var compatible = candidates.Where(i => HasCompatibleArity(logical, members[i])); ConsumeUniqueExecutableMetrics(compatible, members, consumed); } + private static bool HasCompatibleArity(LogicalMethod logical, CodeMetricsMember member) => + logical.Arity is null || member.Arity is null || logical.Arity == member.Arity || + (logical.Arity == member.Arity + 1 && CodeMetricsReader.IsExtensionBlockMember(member.DisplayName)); + private static void ConsumeUniqueExecutableMetrics( IEnumerable candidates, IReadOnlyList members, HashSet consumed) { @@ -528,18 +531,11 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; if (metricsIndex.TryGetValue(key, out var candidates)) { - // Overload disambiguation by arity when both sides know it. When arity is unknown - // (folded state machines/lambdas) or nothing matches it, take the MAX complexity - // among the same-named candidates — the conservative direction for a gate. - // Consulting an ambiguous candidate does not prove it has coverage. - var pool = candidates; - if (logical.Arity is { } a) - { - var exact = candidates.Where(i => members[i].Arity == a).ToList(); - if (exact.Count > 0) pool = exact; - } - - ConsumeUniqueExecutableMetrics(pool, members, consumed); + // Keep the conservative complexity fallback when no arity matches, but never + // treat a provably incompatible candidate as evidence of complete coverage. + var compatible = candidates.Where(i => HasCompatibleArity(logical, members[i])).ToList(); + var pool = compatible.Count > 0 ? compatible : candidates; + ConsumeUniqueExecutableMetrics(compatible, members, consumed); var best = 0; foreach (var i in pool) { diff --git a/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs b/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs index f80cd12..13914f6 100644 --- a/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs +++ b/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs @@ -144,7 +144,7 @@ public async Task StaticConstructor_MatchesItsMetricsMember() public async Task ExtensionBlockMembers_MatchTheStaticClassImplementations() { // IL: P.Ext.Twice(string) and P.Ext.get_IsBig(string) — the receiver becomes the first - // parameter, so arity differs from the display and the name match decides. + // parameter, so matching accounts for that receiver in the extension-block display. var metrics = CodeMetricsReportBuilder.ParseType("P", "Ext.extension(string)", m => m .Method("int extension(string).Twice()", 3) .Property("bool extension(string).IsBig", 2, a => a.Method("bool extension(string).IsBig.get", 2))); diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index c86576e..a639608 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -7,6 +7,48 @@ namespace DotCov.Tests; public sealed class CrapMetricsScopeTests { + [Test] + [Arguments(false, "void A.M(int x)")] + [Arguments(true, "void A.M(int x)")] + [Arguments(false, "void A.M(string x = \" extension(string).M\")")] + public async Task DifferentKnownArities_CannotEstablishCoverage(bool embeddedComplexity, string display) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("M", "(System.Int32,System.Int32)", + embeddedComplexity ? "1" : null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method(display, 9, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(embeddedComplexity ? 1 : 9); + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo([display]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task ExtensionReceiver_IsCompatibleForMeasuredAndExcludedMembers(bool exclude, bool generic) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/Ext.g.cs", "App.Ext", c => c.Method("M", "(System.String,System.Int32)", + "1", m => m.Line(2, 1))).ToBytes()); + var coverage = CoberturaParser.ParseMethods(input); + if (exclude) coverage = coverage.Exclude([".g.cs"], []); + var extension = generic ? "extension(T)" : "extension(string)"; + var metrics = CodeMetricsReportBuilder.ParseType("App", $"Ext.{extension}", m => m + .Method($"int {extension}.M(int x)", 1, 1)); + + var report = CrapAnalysis.Analyze(coverage, metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Count).IsEqualTo(exclude ? 1 : 2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + [Test] [Arguments(false)] [Arguments(true)] From aaa9fbddd9bc7cef32a9933dc0279534562fe9af Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:11:27 +0200 Subject: [PATCH 08/42] Avoid redundant lifted-null branches in arity compatibility --- src/DotCov/CrapAnalysis.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 738d07e..1990256 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -271,9 +271,12 @@ private static void MatchExcludedMetrics( ConsumeUniqueExecutableMetrics(compatible, members, consumed); } - private static bool HasCompatibleArity(LogicalMethod logical, CodeMetricsMember member) => - logical.Arity is null || member.Arity is null || logical.Arity == member.Arity || - (logical.Arity == member.Arity + 1 && CodeMetricsReader.IsExtensionBlockMember(member.DisplayName)); + private static bool HasCompatibleArity(LogicalMethod logical, CodeMetricsMember member) + { + if (logical.Arity is not { } coverageArity || member.Arity is not { } metricsArity) return true; + return coverageArity == metricsArity || + (coverageArity == metricsArity + 1 && CodeMetricsReader.IsExtensionBlockMember(member.DisplayName)); + } private static void ConsumeUniqueExecutableMetrics( IEnumerable candidates, IReadOnlyList members, HashSet consumed) From de457b86d5c18ea65d5e92008a452df265c4ae11 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:19:16 +0200 Subject: [PATCH 09/42] Deduplicate excluded metrics identities before matching candidates --- docs/releases/3.0.1.md | 3 ++- src/DotCov/CrapAnalysis.cs | 5 +++- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 26 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 125f1f8..5364a99 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -21,7 +21,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. - **Merged exclusion metadata reflects the resulting files.** A file contributed by another report is no longer also listed as excluded. Applied rules and keep exemptions remain available. Retaining excluded method identities uses a set lookup to avoid - quadratic scans on reports with many excluded files. + quadratic scans on reports with many excluded files. Repeated excluded metrics identities + are matched once instead of rescanning their candidates for every raw signature. - **Patched Fallout dependencies.** The Fallout integration constrains its transitive `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies to 10.0.13, addressing the security advisories published on October 13, 2026. diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 1990256..0d8f0bb 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -245,7 +245,10 @@ private static CrapReport AnalyzeCore( var (metricsIndex, _) = IndexMetrics(metricsMembers); var excluded = FoldRawEntries(excludedMethods); FoldSyntheticGroups(excluded); - foreach (var logical in excluded.Where(static l => !l.FoldedAway)) + // Different raw signatures/files can have the same available metrics identity. + // Account for that identity once, even when every candidate is a zero-body declaration. + foreach (var logical in excluded.Where(static l => !l.FoldedAway) + .DistinctBy(static l => (l.TypeKey, MethodIdentity.MetricsMemberName(l.Name.Method), l.Arity))) MatchExcludedMetrics(logical, metricsMembers, metricsIndex, consumed); var (scored, unscored) = ScoreMethods(order, metricsMembers, consumed); // 3. resolve comp, score diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index a639608..5902bae 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -7,6 +7,32 @@ namespace DotCov.Tests; public sealed class CrapMetricsScopeTests { + [Test] + public async Task RepeatedExcludedIdentities_KeepDistinctAritiesAndMissingExecutableMembers() + { + var coverage = Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.A", c => + { + for (var i = 0; i < 1000; i++) + c.Method("M", $"(App.Arg{i})", "1", m => m.Line(2, 1)); + c.Method("M", "(System.Int32,System.Int32)", "1", m => m.Line(3, 1)); + }); + using var input = new MemoryStream(coverage.ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => + { + for (var i = 0; i < 1000; i++) m.Method($"void A.M(Arg{i} x)", 1, 0); + m.Method("void A.M(int x, int y)", 1, 1); + m.Method("void A.M(int x, int y, int z)", 1, 1); + }); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input).Exclude([".g.cs"], []), metrics); + + await Assert.That(report.Methods.Count).IsEqualTo(1); + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.M(int x, int y, int z)"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + [Test] [Arguments(false, "void A.M(int x)")] [Arguments(true, "void A.M(int x)")] From 7aaf3601ec1fac2706442b1e2a77463a74130999 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:19:39 +0200 Subject: [PATCH 10/42] Pre-filter zero-body metrics from the exclusion index --- src/DotCov/CrapAnalysis.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 0d8f0bb..6775516 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -242,7 +242,7 @@ private static CrapReport AnalyzeCore( var consumed = new HashSet(); // Exclusions remove methods from the measurement scope, not from the metrics file. // Match their identities without scoring them, even when they have no line data. - var (metricsIndex, _) = IndexMetrics(metricsMembers); + var (metricsIndex, _) = IndexMetrics(metricsMembers, executableOnly: true); var excluded = FoldRawEntries(excludedMethods); FoldSyntheticGroups(excluded); // Different raw signatures/files can have the same available metrics identity. @@ -467,7 +467,7 @@ public void MergeEmbeddedComplexity(int? complexity) } private static (Dictionary> Index, Dictionary AccessorFallback) IndexMetrics( - IReadOnlyList? members) + IReadOnlyList? members, bool executableOnly = false) { var index = new Dictionary>(StringComparer.Ordinal); var accessorFallback = new Dictionary(StringComparer.Ordinal); @@ -476,6 +476,9 @@ private static (Dictionary> Index, Dictionary Acc for (var i = 0; i < members.Count; i++) { var m = members[i]; + // Exclusion accounting never consumes declarations without executable lines. + // Drop them once here instead of reconsidering them for every excluded arity. + if (executableOnly && m.ExecutableLines is 0) continue; switch (m.Kind) { case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: From 2b4367997dd9b20e4bd28d48d02b905dbcf4a2b1 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:28:46 +0200 Subject: [PATCH 11/42] Index metrics summaries by arity and parse extension displays once --- docs/releases/3.0.1.md | 2 + src/DotCov/CrapAnalysis.cs | 118 ++++++++++---------- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 48 ++++++++ 3 files changed, 110 insertions(+), 58 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 5364a99..292d03e 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -23,6 +23,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. remain available. Retaining excluded method identities uses a set lookup to avoid quadratic scans on reports with many excluded files. Repeated excluded metrics identities are matched once instead of rescanning their candidates for every raw signature. + Metrics candidates are summarized by parameter count, and extension displays are parsed + once, so repeated measured methods also avoid candidate scans and repeated string parsing. - **Patched Fallout dependencies.** The Fallout integration constrains its transitive `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies to 10.0.13, addressing the security advisories published on October 13, 2026. diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 6775516..51fc76e 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -242,16 +242,16 @@ private static CrapReport AnalyzeCore( var consumed = new HashSet(); // Exclusions remove methods from the measurement scope, not from the metrics file. // Match their identities without scoring them, even when they have no line data. - var (metricsIndex, _) = IndexMetrics(metricsMembers, executableOnly: true); + var metricsIndex = IndexMetrics(metricsMembers); var excluded = FoldRawEntries(excludedMethods); FoldSyntheticGroups(excluded); // Different raw signatures/files can have the same available metrics identity. // Account for that identity once, even when every candidate is a zero-body declaration. foreach (var logical in excluded.Where(static l => !l.FoldedAway) .DistinctBy(static l => (l.TypeKey, MethodIdentity.MetricsMemberName(l.Name.Method), l.Arity))) - MatchExcludedMetrics(logical, metricsMembers, metricsIndex, consumed); + MatchExcludedMetrics(logical, metricsIndex, consumed); - var (scored, unscored) = ScoreMethods(order, metricsMembers, consumed); // 3. resolve comp, score + var (scored, unscored) = ScoreMethods(order, metricsMembers, metricsIndex, consumed); // 3. resolve comp, score var unmatched = CollectUnmatched(metricsMembers, consumed); // 4. honesty channel return new CrapReport(scored, unscored, unmatched); @@ -262,30 +262,19 @@ private static CrapReport AnalyzeCore( // Without source-file/signature identity, only a unique compatible member is proven. private static void MatchExcludedMetrics( LogicalMethod logical, - IReadOnlyList? members, - Dictionary> metricsIndex, + MetricsIndex metricsIndex, HashSet consumed) { - if (members is null) return; var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; - if (!metricsIndex.TryGetValue(key, out var candidates)) return; - - var compatible = candidates.Where(i => HasCompatibleArity(logical, members[i])); - ConsumeUniqueExecutableMetrics(compatible, members, consumed); - } - - private static bool HasCompatibleArity(LogicalMethod logical, CodeMetricsMember member) - { - if (logical.Arity is not { } coverageArity || member.Arity is not { } metricsArity) return true; - return coverageArity == metricsArity || - (coverageArity == metricsArity + 1 && CodeMetricsReader.IsExtensionBlockMember(member.DisplayName)); + var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity); + if (compatible.UniqueMember is { } index) consumed.Add(index); } - private static void ConsumeUniqueExecutableMetrics( - IEnumerable candidates, IReadOnlyList members, HashSet consumed) + private static MetricsCandidates CompatibleMetrics(MetricsIndex index, string key, int? arity) { - var executable = candidates.Where(i => members[i].ExecutableLines is not 0).Take(2).ToList(); - if (executable.Count is 1) consumed.Add(executable[0]); + if (arity is null) return index.All.GetValueOrDefault(key); + return MergeCandidates(index.ByArity.GetValueOrDefault((key, arity)), + index.ByArity.GetValueOrDefault((key, null))); } /// @@ -367,9 +356,9 @@ private static void FoldSyntheticGroups(List order) private static (List Scored, List Unscored) ScoreMethods( List order, IReadOnlyList? metricsMembers, + MetricsIndex metricsIndex, HashSet consumed) { - var (metricsIndex, accessorFallback) = IndexMetrics(metricsMembers); var scored = new List(); var unscored = new List(); @@ -385,7 +374,7 @@ private static (List Scored, List Unscored) Scor continue; } - var (complexity, source) = ResolveComplexity(logical, metricsMembers, metricsIndex, accessorFallback, consumed); + var (complexity, source) = ResolveComplexity(logical, metricsIndex, consumed); if (complexity is not { } comp) { unscored.Add(new CrapUnscoredMethod(display, logical.File, @@ -466,25 +455,50 @@ public void MergeEmbeddedComplexity(int? complexity) } } - private static (Dictionary> Index, Dictionary AccessorFallback) IndexMetrics( - IReadOnlyList? members, bool executableOnly = false) + private readonly record struct MetricsCandidates(int? Complexity, int? UniqueMember, int ExecutableCount); + + private sealed class MetricsIndex { - var index = new Dictionary>(StringComparer.Ordinal); - var accessorFallback = new Dictionary(StringComparer.Ordinal); - if (members is null) return (index, accessorFallback); + public readonly Dictionary All = new(StringComparer.Ordinal); + public readonly Dictionary<(string Key, int? Arity), MetricsCandidates> ByArity = new(); + public readonly Dictionary AccessorFallback = new(StringComparer.Ordinal); + } + + private static MetricsCandidates MergeCandidates(MetricsCandidates left, MetricsCandidates right) + { + var complexity = left.Complexity is { } l && right.Complexity is { } r + ? Math.Max(l, r) : left.Complexity ?? right.Complexity; + var count = Math.Min(2, left.ExecutableCount + right.ExecutableCount); + return new MetricsCandidates(complexity, count is 1 ? left.UniqueMember ?? right.UniqueMember : null, count); + } + + private static void AddCandidates(Dictionary index, TKey key, + MetricsCandidates candidate) where TKey : notnull => + index[key] = MergeCandidates(index.GetValueOrDefault(key), candidate); + + private static MetricsIndex IndexMetrics(IReadOnlyList? members) + { + var index = new MetricsIndex(); + if (members is null) return index; for (var i = 0; i < members.Count; i++) { var m = members[i]; - // Exclusion accounting never consumes declarations without executable lines. - // Drop them once here instead of reconsidering them for every excluded arity. - if (executableOnly && m.ExecutableLines is 0) continue; switch (m.Kind) { case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: var key = $"{m.TypeName}|{m.MemberName}"; - if (!index.TryGetValue(key, out var list)) index[key] = list = []; - list.Add(i); + // Summarize each bucket once. Zero-body declarations retain their scoring + // value but never count toward executable-match ambiguity. + var complexity = Math.Max(0, m.CyclomaticComplexity); + var candidate = m.ExecutableLines is 0 + ? new MetricsCandidates(complexity, null, 0) + : new MetricsCandidates(complexity, i, 1); + AddCandidates(index.All, key, candidate); + AddCandidates(index.ByArity, (key, m.Arity), candidate); + // Parse the extension display once, not inside every method/candidate match. + if (m.Arity is { } arity && CodeMetricsReader.IsExtensionBlockMember(m.DisplayName)) + AddCandidates(index.ByArity, (key, arity + 1), candidate); break; case CodeMetricsMemberKind.Property: @@ -493,13 +507,13 @@ private static (Dictionary> Index, Dictionary Acc // used only when no accessor entry matched. An aggregate below 1 summed no // declared accessor (a positional record property) — nothing was measured. if (m.CyclomaticComplexity < 1) break; - accessorFallback.TryAdd($"{m.TypeName}|get_{m.MemberName}", i); - accessorFallback.TryAdd($"{m.TypeName}|set_{m.MemberName}", i); + index.AccessorFallback.TryAdd($"{m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); + index.AccessorFallback.TryAdd($"{m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); break; } } - return (index, accessorFallback); + return index; } /// @@ -511,12 +525,10 @@ private static (Dictionary> Index, Dictionary Acc /// private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( LogicalMethod logical, - IReadOnlyList? members, - Dictionary> metricsIndex, - Dictionary accessorFallback, + MetricsIndex metricsIndex, HashSet consumed) { - var fromMetrics = MatchMetrics(logical, members, metricsIndex, accessorFallback, consumed); + var fromMetrics = MatchMetrics(logical, metricsIndex, consumed); if (logical.EmbeddedComplexity is { } embedded) return (embedded, CrapComplexitySource.CoverageReport); @@ -530,33 +542,23 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( /// private static int? MatchMetrics( LogicalMethod logical, - IReadOnlyList? members, - Dictionary> metricsIndex, - Dictionary accessorFallback, + MetricsIndex metricsIndex, HashSet consumed) { - if (members is null) return null; - var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; - if (metricsIndex.TryGetValue(key, out var candidates)) + if (metricsIndex.All.TryGetValue(key, out var candidates)) { // Keep the conservative complexity fallback when no arity matches, but never // treat a provably incompatible candidate as evidence of complete coverage. - var compatible = candidates.Where(i => HasCompatibleArity(logical, members[i])).ToList(); - var pool = compatible.Count > 0 ? compatible : candidates; - ConsumeUniqueExecutableMetrics(compatible, members, consumed); - var best = 0; - foreach (var i in pool) - { - best = Math.Max(best, members[i].CyclomaticComplexity); - } - return best; + var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity); + if (compatible.UniqueMember is { } index) consumed.Add(index); + return compatible.Complexity ?? candidates.Complexity; } - if (accessorFallback.TryGetValue(key, out var propertyIndex)) + if (metricsIndex.AccessorFallback.TryGetValue(key, out var property)) { - consumed.Add(propertyIndex); - return members[propertyIndex].CyclomaticComplexity; + consumed.Add(property.Index); + return property.Complexity; } return null; diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 5902bae..42643aa 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -7,6 +7,54 @@ namespace DotCov.Tests; public sealed class CrapMetricsScopeTests { + [Test] + [Arguments(0, 0, GateOutcome.Pass)] + [Arguments(0, 1, GateOutcome.Pass)] + [Arguments(1, 0, GateOutcome.Pass)] + [Arguments(1, 1, GateOutcome.NoData)] + public async Task UnknownAndExactArityBuckets_CombineExecutableIdentityAndComplexity( + int unknownLines, int exactLines, GateOutcome expected) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("M", "(System.Int32)", null, m => m.Line(1, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M", 7, unknownLines).Method("void A.M(int x)", 9, exactLines)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(9); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(expected); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(expected is GateOutcome.NoData ? 2 : 0); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task RepeatedMeasuredArities_ReuseLongDisplaySummaries(bool extension) + { + const int count = 1000; + var padding = new string('x', 2048); + var owner = extension ? "extension(string)" : "Ext"; + var metrics = CodeMetricsReportBuilder.ParseType("App", extension ? $"Ext.{owner}" : owner, m => + { + for (var i = 0; i < count; i++) + m.Method($"int {owner}.M(string x = \"{padding}{i}\")", 9, 1); + }); + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/Ext.cs", "App.Ext", c => + { + for (var i = 0; i < count; i++) + c.Method("M", $"(App.Arg{i},System.String)", null, m => m.Line(i + 1, 1)); + }).ToBytes()); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.Methods.Count).IsEqualTo(count); + await Assert.That(report.Methods.Select(m => m.Complexity).Distinct()).IsEquivalentTo([9]); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(count); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + [Test] public async Task RepeatedExcludedIdentities_KeepDistinctAritiesAndMissingExecutableMembers() { From c39ba620f48f7b4ac62c544dc113f88f4cc058cf Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:33:58 +0200 Subject: [PATCH 12/42] Preserve indexer accessor arity for metrics completeness --- docs/releases/3.0.1.md | 2 ++ src/DotCov/CodeMetrics.cs | 19 ++++++++---- .../CodeMetricsReaderEdgeTests.cs | 15 ++++++++++ tests/DotCov.Tests/CrapMetricsScopeTests.cs | 30 +++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 292d03e..dcc709b 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -9,6 +9,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are not accepted as coverage matches; extension-block receiver parameters remain supported. + Indexer accessors retain their parameter counts, including the setter value, so overloads + with different numbers of indices can be matched independently. - **Exclusions and declarations are accounted for.** Methods intentionally removed by file exclusions are matched for completeness without scoring them. A match must be unique among executable candidates: declarations without bodies do not create false diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index cac4cd8..46181a5 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -38,7 +38,8 @@ public enum CodeMetricsMemberKind /// Name attribute so it can match Cobertura method names directly. /// /// Member kind — accessors and methods are gate-matchable; aggregates are fallbacks. -/// Parameter count when the display string carries a parameter list, else null. +/// Parameter count from a method or indexer display, including the value +/// parameter for indexer setters; otherwise null. /// Roslyn's cyclomatic complexity for the member. /// The raw Name attribute, kept verbatim for unmatched-member listings. public readonly record struct CodeMetricsMember( @@ -308,8 +309,8 @@ private static (string MemberName, int? Arity, CodeMetricsMemberKind Kind) Parse string head, int? arity, string enclosingSimpleName) { var segment = LastDotSegment(head); - if (TryAccessorName(head, segment, arity, out var accessor)) - return (accessor, null, CodeMetricsMemberKind.Accessor); + if (TryAccessorName(head, segment, arity, out var accessor, out var accessorArity)) + return (accessor, accessorArity, CodeMetricsMemberKind.Accessor); if (TryOperatorName(head, arity, out var op)) return (op, arity, CodeMetricsMemberKind.Method); @@ -341,14 +342,22 @@ private static (string MemberName, int? Arity, CodeMetricsMemberKind Kind) Parse /// Accessor displays are paren-less and end in .get / .set / .init / /// .add / .remove — normalized to the coverage spelling (get_Value). /// - private static bool TryAccessorName(string head, string segment, int? arity, out string name) + private static bool TryAccessorName(string head, string segment, int? arity, + out string name, out int? accessorArity) { name = ""; + accessorArity = null; if (arity is not null || !AccessorPrefixes.TryGetValue(segment, out var prefix)) return false; var owner = LastDotSegment(head[..(head.Length - segment.Length - 1)]); - if (owner.StartsWith("this[", StringComparison.Ordinal)) owner = "Item"; + if (owner.StartsWith("this[", StringComparison.Ordinal)) + { + if (owner.EndsWith(']')) + accessorArity = BalancedText.CountTopLevelItems(owner.AsSpan(5, owner.Length - 6)) + + (prefix == "set_" ? 1 : 0); + owner = "Item"; + } name = prefix + owner; return true; } diff --git a/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs index 647b3bd..c1be07b 100644 --- a/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs +++ b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs @@ -252,6 +252,21 @@ await Assert.That(members.Select(m => (m.MemberName, m.Kind))).IsEquivalentTo( ]); } + [Test] + [Arguments("int A.this[int i].get", 1)] + [Arguments("void A.this[int i].set", 2)] + [Arguments("void A.this[int i].init", 2)] + [Arguments("int A.this[int i, int j].get", 2)] + [Arguments("void A.this[Dictionary map, (int, int) pair, int[,] grid].set", 4)] + [Arguments("int A.this[int i]invalid.get", null)] + public async Task IndexerAccessorArity_IncludesIndicesAndSetterValue(string display, int? expected) + { + var member = await One(ParseMethod(display, type: "A")); + + await Assert.That(member.Kind).IsEqualTo(CodeMetricsMemberKind.Accessor); + await Assert.That(member.Arity).IsEqualTo(expected); + } + [Test] public async Task ArrayReturnOperator_BracketsBeforeTheOperatorMarkerStayBalanced() { diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 42643aa..aced072 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -7,6 +7,36 @@ namespace DotCov.Tests; public sealed class CrapMetricsScopeTests { + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task OverloadedIndexers_UseAccessorParameterCountsForCompleteness(bool missingSetter) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => + { + c.Method("get_Item", "(System.Int32)", null, m => m.Line(1, 1)); + c.Method("set_Item", "(System.Int32,System.String)", null, m => m.Line(2, 1)); + c.Method("get_Item", "(System.Int32,System.Int32)", null, m => m.Line(3, 1)); + if (!missingSetter) + c.Method("set_Item", "(System.Int32,System.Int32,System.String)", null, m => m.Line(4, 1)); + }).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Property("string A.this[int x]", 2, a => a + .Method("string A.this[int x].get", 1, 1) + .Method("void A.this[int x].set", 1, 1)) + .Property("string A.this[int x, int y]", 2, a => a + .Method("string A.this[int x, int y].get", 1, 1) + .Method("void A.this[int x, int y].set", 1, 1))); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.Methods.Count).IsEqualTo(missingSetter ? 3 : 4); + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(missingSetter + ? ["void A.this[int x, int y].set"] : Array.Empty()); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(missingSetter ? GateOutcome.NoData : GateOutcome.Pass); + } + [Test] [Arguments(0, 0, GateOutcome.Pass)] [Arguments(0, 1, GateOutcome.Pass)] From 53122773a2f85654d88bcacb8db164a01051ec42 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:35:52 +0200 Subject: [PATCH 13/42] Close remaining metrics scope and complexity review findings --- docs/releases/3.0.1.md | 13 +- src/DotCov/CodeMetrics.cs | 21 ++-- src/DotCov/CrapAnalysis.cs | 20 +-- src/DotCov/MethodCoverageReport.cs | 29 +++++ tests/DotCov.Tests/CrapMetricsScopeTests.cs | 130 +++++++++++++++++++- 5 files changed, 180 insertions(+), 33 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index dcc709b..b65f51c 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -8,15 +8,18 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. Measured scores and offender counts remain available in the report. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are - not accepted as coverage matches; extension-block receiver parameters remain supported. + not accepted as coverage matches. Extension-block displays do not identify static versus + instance members, so an inferred receiver parameter cannot establish a coverage match. Indexer accessors retain their parameter counts, including the setter value, so overloads with different numbers of indices can be matched independently. - **Exclusions and declarations are accounted for.** Methods intentionally removed by file exclusions are matched for completeness without scoring them. A match must be unique among executable candidates: declarations without bodies do not create false ambiguity, while ambiguous executable overloads remain unmatched. + Metrics source paths apply these rules even when a file is absent from coverage. `--keep` exemptions remain measured. Roslyn metrics with `ExecutableLines=0` do not - require coverage; missing or invalid values do not waive the completeness check. + require coverage or supply executable complexity; missing or invalid values do not waive + the completeness check. - **Async overloads keep their own bodies in either input order.** A lambda appearing before its async state-machine body no longer lets that body merge into a synchronous overload and dilute its CRAP score. @@ -25,13 +28,13 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. remain available. Retaining excluded method identities uses a set lookup to avoid quadratic scans on reports with many excluded files. Repeated excluded metrics identities are matched once instead of rescanning their candidates for every raw signature. - Metrics candidates are summarized by parameter count, and extension displays are parsed - once, so repeated measured methods also avoid candidate scans and repeated string parsing. + Metrics candidates are summarized by parameter count, so repeated measured methods + also avoid candidate scans and repeated string parsing. - **Patched Fallout dependencies.** The Fallout integration constrains its transitive `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies to 10.0.13, addressing the security advisories published on October 13, 2026. -The library adds nullable `CodeMetricsMember.ExecutableLines`; existing constructor and +The library adds nullable `CodeMetricsMember.ExecutableLines` and `File`; existing constructor and analysis signatures remain available. CLI flags, exit codes, and threshold rules are unchanged from 3.0.0. diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 46181a5..5249d4c 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -55,6 +55,9 @@ public readonly record struct CodeMetricsMember( /// measurable body; null means the metric is absent or invalid, not zero. /// public int? ExecutableLines { get; init; } + + /// Source path reported by the metrics producer, when available. + public string? File { get; init; } } /// @@ -107,10 +110,11 @@ public static IReadOnlyList ParseFile(string path, long maxCh } } - private sealed class PendingMember(string display, string element) + private sealed class PendingMember(string display, string element, string? file) { public readonly string Display = display; public readonly string Element = element; + public readonly string? File = file; public int? Complexity; public int? ExecutableLines; } @@ -191,7 +195,8 @@ private static void OpenMember(XmlReader reader, ParserState state) // A member outside a NamedType is not ours; an empty member element carries no // Metrics — nothing to record. if (state.TypeChain.Count > 0 && !reader.IsEmptyElement) - state.MemberStack.Push(new PendingMember(reader.GetAttribute("Name") ?? "", reader.LocalName)); + state.MemberStack.Push(new PendingMember(reader.GetAttribute("Name") ?? "", reader.LocalName, + reader.GetAttribute("File"))); } private static void CloseMember(ParserState state) @@ -235,7 +240,8 @@ private static CodeMetricsMember Materialize( return new CodeMetricsMember(typeName, memberName, kind, arity, complexity, pending.Display) { - ExecutableLines = pending.ExecutableLines + ExecutableLines = pending.ExecutableLines, + File = pending.File }; } @@ -448,15 +454,6 @@ private static string LastDotSegment(string head) return lastDot < 0 ? head[start..] : head[(start + lastDot + 1)..]; } - // Instance extension-block members add the receiver to their compiled parameter list. - // Inspect the declaring display prefix, not parameter/default-value text. - internal static bool IsExtensionBlockMember(string display) - { - var (head, _) = SplitParameterList(display); - var start = BalancedText.LastIndexOfTopLevel(head, ' ') + 1; - return StripGenericsPerSegment(head[start..]).StartsWith("extension(", StringComparison.Ordinal); - } - /// /// C# operator token → CLS op_* method name: a mechanical mapping, so a table, not a /// switch. Unary/binary + and - share a token and are disambiguated by arity diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 51fc76e..a99e063 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -206,11 +206,12 @@ internal static double DisplayScore(double score, double maxCrap) => public static CrapReport Analyze(MethodCoverageReport report, IReadOnlyList? metricsMembers = null) { ArgumentNullException.ThrowIfNull(report); - var analysis = AnalyzeCore(report.Methods, metricsMembers, report.ExcludedMethods); + var (selectedMetrics, exclusion) = report.SelectMetrics(metricsMembers); + var analysis = AnalyzeCore(report.Methods, selectedMetrics, report.ExcludedMethods); return new CrapReport(analysis.Methods, analysis.Unscored, analysis.UnmatchedMetricsMembers) { AmbiguousFiles = report.AmbiguousFiles, - Exclusion = report.Exclusion + Exclusion = exclusion }; } @@ -488,17 +489,16 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe { case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: var key = $"{m.TypeName}|{m.MemberName}"; - // Summarize each bucket once. Zero-body declarations retain their scoring - // value but never count toward executable-match ambiguity. + // Summarize each executable bucket once. var complexity = Math.Max(0, m.CyclomaticComplexity); + // Retain an empty bucket for explicit zero-body accessors so an aggregate + // cannot restore the declaration's non-executable complexity as a fallback. var candidate = m.ExecutableLines is 0 - ? new MetricsCandidates(complexity, null, 0) - : new MetricsCandidates(complexity, i, 1); + ? default : new MetricsCandidates(complexity, i, 1); AddCandidates(index.All, key, candidate); AddCandidates(index.ByArity, (key, m.Arity), candidate); - // Parse the extension display once, not inside every method/candidate match. - if (m.Arity is { } arity && CodeMetricsReader.IsExtensionBlockMember(m.DisplayName)) - AddCandidates(index.ByArity, (key, arity + 1), candidate); + // The metrics display omits static/instance information for extension + // blocks. An inferred receiver must not prove a match for a static member. break; case CodeMetricsMemberKind.Property: @@ -506,7 +506,7 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe // aggregate (sum over its accessors) is the honest-but-coarser fallback, // used only when no accessor entry matched. An aggregate below 1 summed no // declared accessor (a positional record property) — nothing was measured. - if (m.CyclomaticComplexity < 1) break; + if (m.CyclomaticComplexity < 1 || m.ExecutableLines is 0) break; index.AccessorFallback.TryAdd($"{m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); index.AccessorFallback.TryAdd($"{m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); break; diff --git a/src/DotCov/MethodCoverageReport.cs b/src/DotCov/MethodCoverageReport.cs index 578e26d..ef61f1f 100644 --- a/src/DotCov/MethodCoverageReport.cs +++ b/src/DotCov/MethodCoverageReport.cs @@ -45,6 +45,34 @@ public MethodCoverageReport( // They must never contribute lines, complexity, or unscored rows to the selected scope. internal IReadOnlyList ExcludedMethods { get; init; } = []; + // Preserve each operation: a keep in a later call cannot undo an earlier exclusion. + private IReadOnlyList<(List Rules, List Keep)> ExclusionSteps { get; init; } = []; + + internal (IReadOnlyList? Members, FileExclusion Exclusion) SelectMetrics( + IReadOnlyList? members) + { + if (members is null || ExclusionSteps.Count is 0) return (members, Exclusion); + var selected = new List(); + var paths = new Dictionary(StringComparer.Ordinal); + foreach (var member in members) + { + if (string.IsNullOrWhiteSpace(member.File) || member.File is "UNKNOWN") + { + selected.Add(member); + continue; + } + var path = PathIdentity.NormalizeRoot(member.File); + if (!paths.TryGetValue(path, out var excluded)) + { + excluded = ExclusionSteps.Any(step => ExclusionRules.Excluded(path, step.Rules, step.Keep)); + paths.Add(path, excluded); + } + if (!excluded) selected.Add(member); + } + return (selected, Exclusion.Union(new FileExclusion([], [], + paths.Where(static p => p.Value).Select(static p => p.Key).ToArray()))); + } + /// /// Remove every method whose file matches , unless it matches /// — exactly the substring-match semantics of @@ -65,6 +93,7 @@ public MethodCoverageReport Exclude(IEnumerable patterns, IEnumerable excludedPaths.Contains(m.File))).ToArray() }; diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index aced072..547ec4b 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -38,7 +38,7 @@ await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(missingSetter } [Test] - [Arguments(0, 0, GateOutcome.Pass)] + [Arguments(0, 0, GateOutcome.NoData)] [Arguments(0, 1, GateOutcome.Pass)] [Arguments(1, 0, GateOutcome.Pass)] [Arguments(1, 1, GateOutcome.NoData)] @@ -53,9 +53,11 @@ public async Task UnknownAndExactArityBuckets_CombineExecutableIdentityAndComple var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); - await Assert.That(report.Methods.Single().Complexity).IsEqualTo(9); + await Assert.That(report.Methods.Count).IsEqualTo(unknownLines + exactLines == 0 ? 0 : 1); + if (report.Methods.Count > 0) + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(exactLines > 0 ? 9 : 7); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(expected); - await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(expected is GateOutcome.NoData ? 2 : 0); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(unknownLines + exactLines == 2 ? 2 : 0); } [Test] @@ -134,7 +136,7 @@ public async Task DifferentKnownArities_CannotEstablishCoverage(bool embeddedCom [Arguments(false, true)] [Arguments(true, false)] [Arguments(true, true)] - public async Task ExtensionReceiver_IsCompatibleForMeasuredAndExcludedMembers(bool exclude, bool generic) + public async Task ExtensionReceiver_WithoutInstanceMetadata_CannotProveAMatch(bool exclude, bool generic) { using var input = new MemoryStream(Cobertura.NewDoc() .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) @@ -148,9 +150,9 @@ public async Task ExtensionReceiver_IsCompatibleForMeasuredAndExcludedMembers(bo var report = CrapAnalysis.Analyze(coverage, metrics); - await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(1); await Assert.That(report.Methods.Count).IsEqualTo(exclude ? 1 : 2); - await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } [Test] @@ -379,6 +381,122 @@ public async Task Cli_ExclusionsAndDeclarations_RespectSelectedScope( await Assert.That(stderr.ToString()).Contains("1 metrics members without coverage data"); } + [Test] + [Arguments("()", GateOutcome.Pass)] + [Arguments("(System.String)", GateOutcome.NoData)] + public async Task StaticExtension_DoesNotInventAReceiver(string signature, GateOutcome expected) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/Ext.cs", "App.Ext", c => c.Method("Make", signature, null, m => m.Line(1, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "Ext.extension(string)", + m => m.Method("string extension(string).Make()", 1, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(expected); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(expected is GateOutcome.Pass ? 0 : 1); + } + + [Test] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task ZeroBodyMetrics_CannotSupplyMeasuredComplexity(bool accessor, bool embedded) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method(accessor ? "get_Value" : "M", "()", + embedded ? "2" : null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => + { + if (accessor) m.Property("int A.Value", 1, a => a.Method("int A.Value.get", 1, 0)); + else m.Method("void A.M()", 1, 0); + }); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Methods.Count).IsEqualTo(embedded ? 1 : 0); + await Assert.That(report.Unscored.Count).IsEqualTo(embedded ? 0 : 1); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(embedded ? GateOutcome.Pass : GateOutcome.NoData); + } + + [Test] + public async Task ZeroBodyPropertyAggregate_CannotSupplyMeasuredComplexity() + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("get_Value", "()", null, m => m.Line(1, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Property("int A.Value", 1)) + .Select(m => m with { ExecutableLines = 0 }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Unscored.Count).IsEqualTo(1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + [Arguments("Program.cs", false, false, false)] + [Arguments("src/A.g.cs", false, false, false)] + [Arguments("c:\\repo\\obj\\Generated.cs", false, false, false)] + [Arguments("src/A.g.cs", true, false, true)] + [Arguments("src/A.g.cs", true, true, false)] + [Arguments("src/A.cs", false, false, true)] + [Arguments("", false, false, true)] + [Arguments("UNKNOWN", false, false, true)] + [Arguments(null, false, false, true)] + public async Task MetricsOnlyFiles_RespectExclusionOperations( + string? file, bool keep, bool excludeAgain, bool missing) + { + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.First()", 1, 1).Method("void A.Second()", 1, 1)) + .Select(m => m with { File = file }).ToArray(); + var original = KeptCoverage(); + var filtered = original.Exclude(ExclusionRules.WellKnown, keep ? ["A.g.cs"] : []); + if (excludeAgain) filtered = filtered.Exclude([".g.cs"], []); + var report = CrapAnalysis.Analyze(filtered, metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(missing ? 2 : 0); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(missing ? GateOutcome.NoData : GateOutcome.Pass); + await Assert.That(report.Exclusion.ExcludedFiles.Count).IsEqualTo(missing ? 0 : 1); + await Assert.That(CrapAnalysis.Analyze(original, metrics).UnmatchedMetricsMembers.Count).IsEqualTo(2); + } + + [Test] + public async Task LaterKeep_CannotUndoEarlierMetricsOnlyExclusion() + { + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.Missing()", 1, 1)) + .Select(m => m with { File = "src/A.g.cs" }).ToArray(); + var report = CrapAnalysis.Analyze(KeptCoverage().Exclude([".g.cs"], []) + .Exclude([".cs"], [".g.cs", "A.cs"]), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Cli_MetricsOnlySourcePath_IsReadAndExcluded(bool keep) + { + using var ws = TempWorkspace.Create("dotcov-crap-metrics-only-"); + var coverage = ws.Write("coverage.cobertura.xml", Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))).ToBytes()); + var xml = """ + + + + + + + """; + var metrics = ws.Write("metrics.xml", Encoding.UTF8.GetBytes(xml)); + await Assert.That(CodeMetricsReader.ParseFile(metrics).Single().File).IsEqualTo("src/Generated.g.cs"); + var stdout = new StringWriter(); + var stderr = new StringWriter(); + List args = ["crap", coverage, "--metrics", metrics, "--exclude-generated", "--format", "json"]; + if (keep) args.AddRange(["--keep", "Generated.g.cs"]); + var exit = await DotCovCli.RunAsync(args.ToArray(), stdout, stderr); + await Assert.That(exit).IsEqualTo(keep ? DotCovCli.NoDataExitCode : 0); + using var json = JsonDocument.Parse(stdout.ToString()); + await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").GetString()) + .IsEqualTo(keep ? "nodata" : "pass"); + } + private static MethodCoverageReport KeptCoverage() { using var input = new MemoryStream(Cobertura.NewDoc() From 911a493f0260c302da7be8286f9f9158a978c241 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:36:30 +0200 Subject: [PATCH 14/42] Keep source-aware exclusions from consuming retained metrics siblings --- src/DotCov/CrapAnalysis.cs | 8 ++++++-- tests/DotCov.Tests/CodeMetricsReportBuilder.cs | 1 - tests/DotCov.Tests/CrapMetricsScopeTests.cs | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index a99e063..87d6f94 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -244,13 +244,16 @@ private static CrapReport AnalyzeCore( // Exclusions remove methods from the measurement scope, not from the metrics file. // Match their identities without scoring them, even when they have no line data. var metricsIndex = IndexMetrics(metricsMembers); + // Source-bearing exclusions were removed by SelectMetrics. A retained member's + // known source must not be consumed by an excluded same-name sibling. + var excludedMetricsIndex = IndexMetrics(metricsMembers, onlyWithoutSource: true); var excluded = FoldRawEntries(excludedMethods); FoldSyntheticGroups(excluded); // Different raw signatures/files can have the same available metrics identity. // Account for that identity once, even when every candidate is a zero-body declaration. foreach (var logical in excluded.Where(static l => !l.FoldedAway) .DistinctBy(static l => (l.TypeKey, MethodIdentity.MetricsMemberName(l.Name.Method), l.Arity))) - MatchExcludedMetrics(logical, metricsIndex, consumed); + MatchExcludedMetrics(logical, excludedMetricsIndex, consumed); var (scored, unscored) = ScoreMethods(order, metricsMembers, metricsIndex, consumed); // 3. resolve comp, score var unmatched = CollectUnmatched(metricsMembers, consumed); // 4. honesty channel @@ -477,7 +480,7 @@ private static void AddCandidates(Dictionary inde MetricsCandidates candidate) where TKey : notnull => index[key] = MergeCandidates(index.GetValueOrDefault(key), candidate); - private static MetricsIndex IndexMetrics(IReadOnlyList? members) + private static MetricsIndex IndexMetrics(IReadOnlyList? members, bool onlyWithoutSource = false) { var index = new MetricsIndex(); if (members is null) return index; @@ -485,6 +488,7 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe for (var i = 0; i < members.Count; i++) { var m = members[i]; + if (onlyWithoutSource && !string.IsNullOrWhiteSpace(m.File) && m.File is not "UNKNOWN") continue; switch (m.Kind) { case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: diff --git a/tests/DotCov.Tests/CodeMetricsReportBuilder.cs b/tests/DotCov.Tests/CodeMetricsReportBuilder.cs index cf922c9..d24886e 100644 --- a/tests/DotCov.Tests/CodeMetricsReportBuilder.cs +++ b/tests/DotCov.Tests/CodeMetricsReportBuilder.cs @@ -78,7 +78,6 @@ private MemberListBuilder Member(string element, string display, int complexity, { var member = new XElement(element, new XAttribute("Name", display), - new XAttribute("File", "/src/Probe.cs"), new XAttribute("Line", "1"), Metrics(complexity)); if (executableLines is { } lines) diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 547ec4b..5d6fbbd 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -497,6 +497,20 @@ await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").Ge .IsEqualTo(keep ? "nodata" : "pass"); } + [Test] + public async Task ExcludedCoverage_CannotConsumeMetricsFromAKnownKeptSource() + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.A", c => c.Method("M", "(System.Int32)", "1", m => m.Line(2, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M(string x)", 1, 1)) + .Select(m => m with { File = "src/A.cs" }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input).Exclude([".g.cs"], []), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.M(string x)"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + private static MethodCoverageReport KeptCoverage() { using var input = new MemoryStream(Cobertura.NewDoc() From 8fb50afe58c5d7ab846513ebdddb4a95ef5cae0e Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:37:53 +0200 Subject: [PATCH 15/42] Record the 3.0.1 corrective patch in the rolling changelog --- CHANGELOG.md | 49 +++++++++++++++---------------------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3f59d..8fb7f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,40 +2,6 @@ Rolling task-session log, oldest → newest, capped at 20 entries. -## Task 1 — 2026-05-15 — Coverlet-cased `branch="True"`, branch dedup, 100% self-coverage - -Built on top of `b306779` (which switched `ParseClass` to a full `` subtree walk -with per-source-line Math.Max aggregation across multiple IL-type class blocks). - -Changed: -- Case-insensitive match for `branch="true"` / `branch="True"` in `CoberturaParser`. The - previous literal-pattern compare rejected Coverlet's Pascal-cased value (`XmlConvert - .ToString(bool)`), silently dropping every branch line in Coverlet output — branch counts - rendered as a fake 100% because `TotalBranches` was 0. -- New `BranchLinesSeen` set on `LineAccumulator` so branches are counted once per source - line. Without this, lines that appear both under `//` and - `/` would double-count their branch totals. -- `CoverageDiff.Compare` switch reordered to remove the unreachable `_ => throw` default. -- `Ansi.EnableOnWindows` marked `[ExcludeFromCodeCoverage]` — its two branches can only be - exercised on different OSes; the "never throws" contract test still covers it. -- Added `coverlet.runsettings` excluding `[CompilerGenerated]` and `[GeneratedCode]` types - and `obj/` source-generator output, so async-state-machine phantom branches don't cap us. - -Verified: -- `dotnet test --collect:"XPlat Code Coverage" --settings coverlet.runsettings` followed by - `dotcov report TestResults/ --exclude-generated` reports **Lines 100%** / - **Branches 100%** across 165 tests. -- Regression tests cover the methods+class-level duplication (with `branch="True"` - branches that previously double-counted), case-insensitive `branch` attr matching across - `true`/`True`/`TRUE`, missing/malformed `hits`, and Removed/Added file rendering across - all three formatters (Table, Markdown, JSON). - -Notes: -- Numbering starts at 1; no prior CHANGELOG existed in repo metadata. -- The pre-existing `97.9% line / 100% branch` figure in commit `ee3bf32` was an artefact - of the casing bug — `branch="True"` lines were ignored, so total branches was 0 and the - rate trivially rendered as 100%. - ## Task 2 — 2026-05-15 — Codecov-pattern alignment: branch dedup, strict line state, indirect-change diff Lifted three semantically-valuable patterns from Codecov's coverage model and grafted them @@ -839,3 +805,18 @@ Verified: fails on `5a50ddf` and passes here; each policy has a contract test per command through the CLI entry point and through the Fallout target (`FalloutBuildTests`, `RepositoryBuildTests`); `dotcov test` contracts run against a stand-in dotnet (`FakeDotnet`). + +## Task 21 — 2026-10-14 — 3.0.1: CRAP completeness and exclusion corrections + +- Missing executable metrics members prevent PASS; ambiguous overloads and known arity + mismatches remain unmatched. Indexer accessors retain index and setter-value counts. +- Metrics preserve executable-line counts and source paths. Non-executable declarations + neither require coverage nor provide executable complexity. Exclusions apply to + metrics-only files, preserve ordered keep rules, and never consume retained siblings. +- Async bodies retain their own identity in either input order. Merged exclusion metadata + lists only absent files. Indexed metrics matching avoids repeated candidate scans. +- Patched Fallout cryptography dependencies to 10.0.13 without suppressing NuGet audit. +- Aligned package/action/README versions and added `docs/releases/3.0.1.md`. + +Validation: parser, analysis, CLI, and scale regressions are included. PR #79's final CI +and review results are the authoritative commit-specific validation record. From fe1c30ba438f8fb88157999f9dbdc1700e2eb226 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:39:09 +0200 Subject: [PATCH 16/42] Assert conservative gate outcome for receiver-ambiguous extension metrics --- tests/DotCov.Tests/CrapAnalysisEdgeTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs b/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs index 13914f6..39b1141 100644 --- a/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs +++ b/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs @@ -141,10 +141,11 @@ public async Task StaticConstructor_MatchesItsMetricsMember() } [Test] - public async Task ExtensionBlockMembers_MatchTheStaticClassImplementations() + public async Task ExtensionBlockMembers_ScoreConservativelyWithoutInventingReceiverIdentity() { // IL: P.Ext.Twice(string) and P.Ext.get_IsBig(string) — the receiver becomes the first - // parameter, so matching accounts for that receiver in the extension-block display. + // parameter. The metrics display omits whether a member is static or instance, so + // a receiver-inferred method match cannot prove completeness. var metrics = CodeMetricsReportBuilder.ParseType("P", "Ext.extension(string)", m => m .Method("int extension(string).Twice()", 3) .Property("bool extension(string).IsBig", 2, a => a.Method("bool extension(string).IsBig.get", 2))); @@ -154,7 +155,8 @@ public async Task ExtensionBlockMembers_MatchTheStaticClassImplementations() var report = CrapAnalysis.Analyze([twice, isBig], metrics); await Assert.That(report.Unscored).IsEmpty(); - await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["int extension(string).Twice()"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); await Assert.That(report.Methods).Contains(m => m is { Method: "P.Ext.Twice", Complexity: 3, Score: 12.0 }); await Assert.That(report.Methods).Contains(m => m is { Method: "P.Ext.get_IsBig", Complexity: 2 }); } From c1c5b0ad73a57990254f3da958d56a09ad6ffd1f Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:45:46 +0200 Subject: [PATCH 17/42] Honor legacy Roslyn executable-line metrics without overriding modern values --- docs/releases/3.0.1.md | 3 +- src/DotCov/CodeMetrics.cs | 18 +++++++---- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 34 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index b65f51c..adf8b2c 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -18,7 +18,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. ambiguity, while ambiguous executable overloads remain unmatched. Metrics source paths apply these rules even when a file is absent from coverage. `--keep` exemptions remain measured. Roslyn metrics with `ExecutableLines=0` do not - require coverage or supply executable complexity; missing or invalid values do not waive + require coverage or supply executable complexity. Legacy `LinesOfCode` is used only when + `ExecutableLines` is absent; missing or invalid values do not waive the completeness check. - **Async overloads keep their own bodies in either input order.** A lambda appearing before its async state-machine body no longer lets that body merge into a synchronous diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 5249d4c..6166f5a 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -117,6 +117,8 @@ private sealed class PendingMember(string display, string element, string? file) public readonly string? File = file; public int? Complexity; public int? ExecutableLines; + public int? LegacyExecutableLines; + public bool HasExecutableLines; } /// The document position ParseCore threads through its element handlers. @@ -213,12 +215,13 @@ private static void RecordMetric(XmlReader reader, ParserState state) { // Assign to the innermost OPEN member: a Property's own Metrics arrive before its // , so accessor metrics can never leak upward. - if (state.MemberStack.Count is 0 || - !int.TryParse(reader.GetAttribute("Value"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - return; - + if (state.MemberStack.Count is 0) return; var pending = state.MemberStack.Peek(); - switch (reader.GetAttribute("Name")) + var name = reader.GetAttribute("Name"); + if (name is "ExecutableLines") pending.HasExecutableLines = true; + if (!int.TryParse(reader.GetAttribute("Value"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return; + switch (name) { case "CyclomaticComplexity": pending.Complexity = value; @@ -226,6 +229,9 @@ private static void RecordMetric(XmlReader reader, ParserState state) case "ExecutableLines": pending.ExecutableLines = value >= 0 ? value : null; break; + case "LinesOfCode": + pending.LegacyExecutableLines = value >= 0 ? value : null; + break; } } @@ -240,7 +246,7 @@ private static CodeMetricsMember Materialize( return new CodeMetricsMember(typeName, memberName, kind, arity, complexity, pending.Display) { - ExecutableLines = pending.ExecutableLines, + ExecutableLines = pending.HasExecutableLines ? pending.ExecutableLines : pending.LegacyExecutableLines, File = pending.File }; } diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 5d6fbbd..4f0d24b 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -511,6 +511,40 @@ public async Task ExcludedCoverage_CannotConsumeMetricsFromAKnownKeptSource() await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + [Test] + [Arguments("0", null, false, 0)] + [Arguments("2", null, false, 2)] + [Arguments("-1", null, false, null)] + [Arguments("invalid", null, false, null)] + [Arguments("0", "2", false, 2)] + [Arguments("0", "2", true, 2)] + [Arguments("2", "0", false, 0)] + [Arguments("2", "0", true, 0)] + [Arguments("0", "invalid", false, null)] + [Arguments("0", "invalid", true, null)] + [Arguments("0", "-1", false, null)] + public async Task LegacyExecutableLines_AreUsedOnlyWhenModernMetricIsAbsent( + string legacy, string? modern, bool modernFirst, int? expected) + { + var legacyMetric = $""; + var modernMetric = modern is null ? "" : $""; + var ordered = modernFirst ? modernMetric + legacyMetric : legacyMetric + modernMetric; + var xml = $""" + + + + {ordered} + + + """; + using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + var metrics = CodeMetricsReader.Parse(input); + await Assert.That(metrics.Single().ExecutableLines).IsEqualTo(expected); + var report = CrapAnalysis.Analyze(KeptCoverage(), metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(expected is 0 ? 0 : 1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(expected is 0 ? GateOutcome.Pass : GateOutcome.NoData); + } + private static MethodCoverageReport KeptCoverage() { using var input = new MemoryStream(Cobertura.NewDoc() From a0b38bc0c8eeede24b08c49029c13c5ead3283c2 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:52:35 +0200 Subject: [PATCH 18/42] Match complete overload sets by indexed parameter signatures --- docs/releases/3.0.1.md | 1 + src/DotCov/BalancedText.cs | 18 ++++ src/DotCov/CrapAnalysis.cs | 30 ++++--- src/DotCov/MethodSignatures.cs | 55 ++++++++++++ tests/DotCov.Tests/CrapMetricsScopeTests.cs | 10 +-- .../CrapSignatureMatchingTests.cs | 84 +++++++++++++++++++ 6 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 src/DotCov/MethodSignatures.cs create mode 100644 tests/DotCov.Tests/CrapSignatureMatchingTests.cs diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index adf8b2c..6085f1a 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -6,6 +6,7 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. - **Missing executable metrics members prevent a CRAP PASS.** A method or accessor in the metrics file without a coverage counterpart makes the gate `NODATA` (exit `3`). Measured scores and offender counts remain available in the report. + Complete overload sets match by parameter types when those identities are available. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are not accepted as coverage matches. Extension-block displays do not identify static versus diff --git a/src/DotCov/BalancedText.cs b/src/DotCov/BalancedText.cs index 68b4da4..315f2f4 100644 --- a/src/DotCov/BalancedText.cs +++ b/src/DotCov/BalancedText.cs @@ -69,4 +69,22 @@ internal static int CountTopLevelItems(ReadOnlySpan list) } return count; } + + internal static IEnumerable SplitTopLevel(string list) + { + if (list.Trim().Length is 0) yield break; + var depth = 0; + var start = 0; + for (var i = 0; i < list.Length; i++) + { + if (depth is 0 && list[i] is ',') + { + yield return list[start..i].Trim(); + start = i + 1; + } + if (list[i] is '"' or '\'') i = LiteralEnd(list, i); + else depth += DepthDelta(list[i]); + } + yield return list[start..].Trim(); + } } diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 87d6f94..90ef6f4 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -252,7 +252,7 @@ private static CrapReport AnalyzeCore( // Different raw signatures/files can have the same available metrics identity. // Account for that identity once, even when every candidate is a zero-body declaration. foreach (var logical in excluded.Where(static l => !l.FoldedAway) - .DistinctBy(static l => (l.TypeKey, MethodIdentity.MetricsMemberName(l.Name.Method), l.Arity))) + .DistinctBy(static l => (l.TypeKey, MethodIdentity.MetricsMemberName(l.Name.Method), l.Arity, l.Signature))) MatchExcludedMetrics(logical, excludedMetricsIndex, consumed); var (scored, unscored) = ScoreMethods(order, metricsMembers, metricsIndex, consumed); // 3. resolve comp, score @@ -270,15 +270,18 @@ private static void MatchExcludedMetrics( HashSet consumed) { var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; - var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity); + var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature); if (compatible.UniqueMember is { } index) consumed.Add(index); } - private static MetricsCandidates CompatibleMetrics(MetricsIndex index, string key, int? arity) + private static MetricsCandidates CompatibleMetrics(MetricsIndex index, string key, int? arity, string? signature) { if (arity is null) return index.All.GetValueOrDefault(key); - return MergeCandidates(index.ByArity.GetValueOrDefault((key, arity)), + var compatible = MergeCandidates(index.ByArity.GetValueOrDefault((key, arity)), index.ByArity.GetValueOrDefault((key, null))); + if (compatible.ExecutableCount > 1 && !compatible.HasUnknownSignature && + index.BySignature.TryGetValue((key, signature!), out var exact)) return exact; + return compatible; } /// @@ -313,7 +316,8 @@ private static List FoldRawEntries(IReadOnlyList if (!byKey.TryGetValue(key, out var logical)) { int? arity = origin is null ? MethodIdentity.SignatureArity(mc.Signature) : null; - logical = new LogicalMethod(name, typeKey, arity, origin); + logical = new LogicalMethod(name, typeKey, arity, origin, + origin is null ? MethodSignatures.Coverage(mc.Signature) : null); byKey[key] = logical; order.Add(logical); } @@ -415,11 +419,12 @@ metricsMembers[i].ExecutableLines is not 0 && /// A logical method's name: file identity key, arity-preserving type identity, method name. private readonly record struct MethodName(string File, string TypeIdentity, string Method); - private sealed class LogicalMethod(MethodName name, string typeKey, int? arity, SyntheticOrigin? origin) + private sealed class LogicalMethod(MethodName name, string typeKey, int? arity, SyntheticOrigin? origin, string? signature) { public readonly MethodName Name = name; public readonly string TypeKey = typeKey; public readonly int? Arity = arity; + public readonly string? Signature = signature; /// Set for compiler-synthesized material; null for a method the source names directly. public SyntheticOrigin? Origin = origin; public bool Folded => Origin is not null; @@ -459,12 +464,14 @@ public void MergeEmbeddedComplexity(int? complexity) } } - private readonly record struct MetricsCandidates(int? Complexity, int? UniqueMember, int ExecutableCount); + private readonly record struct MetricsCandidates(int? Complexity, int? UniqueMember, int ExecutableCount, + bool HasUnknownSignature); private sealed class MetricsIndex { public readonly Dictionary All = new(StringComparer.Ordinal); public readonly Dictionary<(string Key, int? Arity), MetricsCandidates> ByArity = new(); + public readonly Dictionary<(string Key, string Signature), MetricsCandidates> BySignature = new(); public readonly Dictionary AccessorFallback = new(StringComparer.Ordinal); } @@ -473,7 +480,8 @@ private static MetricsCandidates MergeCandidates(MetricsCandidates left, Metrics var complexity = left.Complexity is { } l && right.Complexity is { } r ? Math.Max(l, r) : left.Complexity ?? right.Complexity; var count = Math.Min(2, left.ExecutableCount + right.ExecutableCount); - return new MetricsCandidates(complexity, count is 1 ? left.UniqueMember ?? right.UniqueMember : null, count); + return new MetricsCandidates(complexity, count is 1 ? left.UniqueMember ?? right.UniqueMember : null, count, + left.HasUnknownSignature || right.HasUnknownSignature); } private static void AddCandidates(Dictionary index, TKey key, @@ -495,12 +503,14 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe var key = $"{m.TypeName}|{m.MemberName}"; // Summarize each executable bucket once. var complexity = Math.Max(0, m.CyclomaticComplexity); + var signature = MethodSignatures.Metrics(m.DisplayName); // Retain an empty bucket for explicit zero-body accessors so an aggregate // cannot restore the declaration's non-executable complexity as a fallback. var candidate = m.ExecutableLines is 0 - ? default : new MetricsCandidates(complexity, i, 1); + ? default : new MetricsCandidates(complexity, i, 1, signature is null); AddCandidates(index.All, key, candidate); AddCandidates(index.ByArity, (key, m.Arity), candidate); + if (signature is not null) AddCandidates(index.BySignature, (key, signature), candidate); // The metrics display omits static/instance information for extension // blocks. An inferred receiver must not prove a match for a static member. break; @@ -554,7 +564,7 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( { // Keep the conservative complexity fallback when no arity matches, but never // treat a provably incompatible candidate as evidence of complete coverage. - var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity); + var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature); if (compatible.UniqueMember is { } index) consumed.Add(index); return compatible.Complexity ?? candidates.Complexity; } diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs new file mode 100644 index 0000000..032d7ae --- /dev/null +++ b/src/DotCov/MethodSignatures.cs @@ -0,0 +1,55 @@ +using System.Text.RegularExpressions; + +namespace DotCov; + +// Exact type spellings and C# built-in aliases can disambiguate overloads. Do not +// erase namespaces, generic arguments, array ranks, or by-ref markers to force a match. +internal static partial class MethodSignatures +{ + private static readonly Dictionary Aliases = new(StringComparer.Ordinal) + { + ["bool"] = "System.Boolean", ["byte"] = "System.Byte", ["sbyte"] = "System.SByte", + ["short"] = "System.Int16", ["ushort"] = "System.UInt16", ["int"] = "System.Int32", + ["uint"] = "System.UInt32", ["long"] = "System.Int64", ["ulong"] = "System.UInt64", + ["char"] = "System.Char", ["float"] = "System.Single", ["double"] = "System.Double", + ["decimal"] = "System.Decimal", ["string"] = "System.String", ["object"] = "System.Object", + ["nint"] = "System.IntPtr", ["nuint"] = "System.UIntPtr" + }; + [GeneratedRegex(@"(?(); + foreach (var parameter in BalancedText.SplitTopLevel(display[(open + 1)..^1])) + { + var equals = BalancedText.LastIndexOfTopLevel(parameter, '='); + var declaration = equals < 0 ? parameter : parameter[..equals].TrimEnd(); + var space = BalancedText.LastIndexOfTopLevel(declaration, ' '); + if (space < 0) return null; + types.Add(NormalizeType(declaration[..space])); + } + return string.Join(",", types); + } + + private static string NormalizeType(string type) + { + type = type.Trim().Replace("global::", "", StringComparison.Ordinal); + if (type.StartsWith("params ", StringComparison.Ordinal)) type = type[7..]; + var byRef = type.StartsWith("ref ", StringComparison.Ordinal) || + type.StartsWith("out ", StringComparison.Ordinal) || + type.StartsWith("in ", StringComparison.Ordinal); + if (byRef) type = type[(type.IndexOf(' ') + 1)..]; + type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); + return type.Replace(" ", "", StringComparison.Ordinal) + (byRef ? "&" : ""); + } +} diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 4f0d24b..655e724 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -168,9 +168,9 @@ public async Task MeasuredOverload_CannotConsumeItsUnmeasuredSibling(bool embedd var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); - await Assert.That(report.Methods.Single().Complexity).IsEqualTo(embeddedComplexity ? 1 : 9); + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(1); await Assert.That(report.UnmatchedMetricsMembers) - .IsEquivalentTo(["void A.M(int x)", "void A.M(string x)"]); + .IsEquivalentTo(["void A.M(string x)"]); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } @@ -213,11 +213,11 @@ public async Task Cli_MeasuredSameArityOverload_CannotPassWithAnUninstrumentedSi await Assert.That(exit).IsEqualTo(DotCovCli.NoDataExitCode); using var json = JsonDocument.Parse(stdout.ToString()); await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").GetString()).IsEqualTo("nodata"); - await Assert.That(stderr.ToString()).Contains("2 metrics members without coverage data"); + await Assert.That(stderr.ToString()).Contains("1 metrics members without coverage data"); } [Test] - [Arguments("(System.Int32)", "void A.M(int x)", "void A.M(string x)", 2)] + [Arguments("(System.Int32)", "void A.M(int x)", "void A.M(string x)", 1)] [Arguments("", "void A.M(int x)", "void A.M(string x)", 2)] [Arguments("(System.Int32)", "void A.M(int x)", "void A.M(int x, int y)", 1)] [Arguments("(System.Int32)", "void A.M(int x, int y)", "void A.Other()", 2)] @@ -274,7 +274,7 @@ public async Task Cli_ExcludedSameArityOverload_CannotHideAnUninstrumentedSiblin await Assert.That(exit).IsEqualTo(DotCovCli.NoDataExitCode); using var json = JsonDocument.Parse(stdout.ToString()); await Assert.That(json.RootElement.GetProperty("gate").GetProperty("outcome").GetString()).IsEqualTo("nodata"); - await Assert.That(stderr.ToString()).Contains("2 metrics members without coverage data"); + await Assert.That(stderr.ToString()).Contains("1 metrics members without coverage data"); } [Test] diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs new file mode 100644 index 0000000..3b3991f --- /dev/null +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -0,0 +1,84 @@ +namespace DotCov.Tests; + +public sealed class CrapSignatureMatchingTests +{ + [Test] + [Arguments("int x", "System.Int32")] + [Arguments("int x = 1", "System.Int32")] + [Arguments("char x = ','", "System.Char")] + [Arguments("string x = \"a,(b)=c\"", "System.String")] + [Arguments("List x", "List")] + [Arguments("(int,string) x", "(System.Int32,System.String)")] + [Arguments("int[,] x", "System.Int32[,]")] + [Arguments("params int[] x", "System.Int32[]")] + [Arguments("ref int x", "System.Int32&")] + [Arguments("out int x", "System.Int32&")] + [Arguments("in int x", "System.Int32&")] + [Arguments("global::App.Widget x", "App.Widget")] + public async Task CompleteSameArityOverloads_MatchTypesAndKeepTheirOwnComplexity( + string parameter, string ilType) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", $"({ilType})", null, m => m.Line(1, 1)) + .Method("M", "(System.Boolean)", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method($"void A.M({parameter})", 2, 1).Method("void A.M(bool value)", 7, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([2, 7]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task MultipleParameters_AndExcludedOverloads_KeepSeparateIdentities(bool exclude) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.A", c => c + .Method("M", "(System.Int32,System.String)", null, m => m.Line(2, 1)) + .Method("M", "(System.String,System.Int32)", null, m => m.Line(3, 1))).ToBytes()); + var coverage = CoberturaParser.ParseMethods(input); + if (exclude) coverage = coverage.Exclude([".g.cs"], []); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(int x, string y)", 2, 1).Method("void A.M(string x, int y)", 7, 1)); + var report = CrapAnalysis.Analyze(coverage, metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + await Assert.That(report.Methods.Count).IsEqualTo(exclude ? 1 : 3); + } + + [Test] + [Arguments("void A.M(int)", "(System.Int32)")] + [Arguments("void A.M)", "(System.Int32)")] + [Arguments("void A.M(One.Widget x)", "(Other.Widget)")] + [Arguments("void A.M(int x)", "invalid)")] + [Arguments("void A.M(int x)", "(invalid")] + public async Task UnknownOrDifferentSignatures_CannotProveCompleteCoverage(string display, string signature) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", signature, "1", m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method(display, 2, 1).Method("void A.M(bool value)", 7, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsNotEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + public async Task RepeatedSameSignatureAcrossFiles_CannotReplaceAMissingOverload() + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("M", "(System.Int32)", "1", m => m.Line(1, 1))) + .AddClass("other/A.cs", "App.A", c => c.Method("M", "(System.Int32)", "1", m => m.Line(1, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(int x)", 2, 1).Method("void A.M(string value)", 7, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.M(string value)"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } +} From 151ef17a783034cde137c598aaf94ee76fa5f33d Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 12:53:47 +0200 Subject: [PATCH 19/42] Import coverage fixture helpers for signature regressions --- tests/DotCov.Tests/CrapSignatureMatchingTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs index 3b3991f..26ed6cb 100644 --- a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -1,3 +1,5 @@ +using DotCov.Tests.Infrastructure; + namespace DotCov.Tests; public sealed class CrapSignatureMatchingTests From 22126897cf90cf413eb65090975c3d02d9eccb33 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:07:15 +0200 Subject: [PATCH 20/42] fix: reconcile indexer, nullable, generic type and constructor identities --- docs/releases/3.0.1.md | 8 +- src/DotCov/CodeMetrics.cs | 34 +++++--- src/DotCov/CrapAnalysis.cs | 40 ++++++++-- src/DotCov/MethodSignatures.cs | 34 +++++++- .../CrapSignatureMatchingTests.cs | 43 ++++++++++ .../CrapTypeAndConstructorTests.cs | 80 +++++++++++++++++++ 6 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 tests/DotCov.Tests/CrapTypeAndConstructorTests.cs diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 6085f1a..b186788 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -12,7 +12,11 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. not accepted as coverage matches. Extension-block displays do not identify static versus instance members, so an inferred receiver parameter cannot establish a coverage match. Indexer accessors retain their parameter counts, including the setter value, so overloads - with different numbers of indices can be matched independently. + with different numbers or types of indices can be matched independently. Nullable + built-in value types match their runtime `System.Nullable` identities. Generic type + arity is preserved, so `Result` and `Result` do not share a metrics bucket. + Complete static/instance constructor pairs in one file reconcile their indistinguishable + Roslyn displays while retaining the conservative maximum complexity for both. - **Exclusions and declarations are accounted for.** Methods intentionally removed by file exclusions are matched for completeness without scoring them. A match must be unique among executable candidates: declarations without bodies do not create false @@ -36,7 +40,7 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies to 10.0.13, addressing the security advisories published on October 13, 2026. -The library adds nullable `CodeMetricsMember.ExecutableLines` and `File`; existing constructor and +The library adds nullable `CodeMetricsMember.ExecutableLines`, `File`, and `TypeIdentity`; existing constructor and analysis signatures remain available. CLI flags, exit codes, and threshold rules are unchanged from 3.0.0. diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 6166f5a..161e13d 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Globalization; using System.Xml; +using System.Text.RegularExpressions; namespace DotCov; @@ -58,6 +59,9 @@ public readonly record struct CodeMetricsMember( /// Source path reported by the metrics producer, when available. public string? File { get; init; } + + /// IL type identity with generic arity retained; null for callers using the legacy type name. + public string? TypeIdentity { get; init; } } /// @@ -73,7 +77,7 @@ public readonly record struct CodeMetricsMember( /// Type.Type(int seed) for constructors, and string Type.Prop.get for accessors. /// Same XmlReader streaming + hardening ethos as . /// -public static class CodeMetricsReader +public static partial class CodeMetricsReader { private const long DefaultMaxChars = 50_000_000; private const string GlobalNamespaceDisplay = ""; @@ -126,7 +130,7 @@ private sealed class ParserState { public readonly List Members = []; public string Namespace = ""; - public readonly List TypeChain = []; // generic-stripped dotted segments, e.g. "Outer.Inner" + public readonly List TypeChain = []; // raw named-type displays, including generic parameters public readonly Stack MemberStack = new(); // Property → its Accessor methods nest } @@ -157,7 +161,7 @@ private static void OnElementStart(XmlReader reader, ParserState state) case "NamedType": // Empty elements raise no EndElement, so only a real subtree pushes. if (!reader.IsEmptyElement) - state.TypeChain.Add(TypeSegment(reader.GetAttribute("Name") ?? "")); + state.TypeChain.Add(reader.GetAttribute("Name") ?? ""); break; case "Metric": @@ -238,16 +242,22 @@ private static void RecordMetric(XmlReader reader, ParserState state) private static CodeMetricsMember Materialize( PendingMember pending, string ns, List typeChain, int complexity) { - var typeName = string.Join(".", typeChain); - if (ns.Length > 0) typeName = $"{ns}.{typeName}"; + var typeName = string.Join(".", typeChain.Select(static name => TypeSegment(name))); + var typeIdentity = string.Join(".", typeChain.Select(static name => TypeSegment(name, retainArity: true))); + if (ns.Length > 0) + { + typeName = $"{ns}.{typeName}"; + typeIdentity = $"{ns}.{typeIdentity}"; + } - var enclosingSimpleName = SimpleName(typeChain[^1]); + var enclosingSimpleName = SimpleName(TypeSegment(typeChain[^1])); var (memberName, arity, kind) = ParseDisplay(pending.Display, pending.Element, enclosingSimpleName); return new CodeMetricsMember(typeName, memberName, kind, arity, complexity, pending.Display) { ExecutableLines = pending.HasExecutableLines ? pending.ExecutableLines : pending.LegacyExecutableLines, - File = pending.File + File = pending.File, + TypeIdentity = typeIdentity }; } @@ -262,9 +272,15 @@ private static string SimpleName(string dottedType) /// trailing C# 14 extension block (Ext.extension(string)) dropped — its members are /// emitted as static methods of the enclosing static class. /// - private static string TypeSegment(string name) + [GeneratedRegex(@"<([^<>]*)>", RegexOptions.CultureInvariant)] + private static partial Regex TypeParameterLists(); + + private static string TypeSegment(string name, bool retainArity = false) { - var stripped = StripGenericsPerSegment(name); + var stripped = retainArity + ? TypeParameterLists().Replace(name, static match => + $"`{BalancedText.CountTopLevelItems(match.Groups[1].Value)}") + : StripGenericsPerSegment(name); var dot = BalancedText.LastIndexOfTopLevel(stripped, '.'); return dot >= 0 && stripped.AsSpan(dot + 1).StartsWith("extension(", StringComparison.Ordinal) ? stripped[..dot] diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 90ef6f4..db15a77 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -252,15 +252,43 @@ private static CrapReport AnalyzeCore( // Different raw signatures/files can have the same available metrics identity. // Account for that identity once, even when every candidate is a zero-body declaration. foreach (var logical in excluded.Where(static l => !l.FoldedAway) - .DistinctBy(static l => (l.TypeKey, MethodIdentity.MetricsMemberName(l.Name.Method), l.Arity, l.Signature))) + .DistinctBy(static l => (l.Name.TypeIdentity, MethodIdentity.MetricsMemberName(l.Name.Method), l.Arity, l.Signature))) MatchExcludedMetrics(logical, excludedMetricsIndex, consumed); var (scored, unscored) = ScoreMethods(order, metricsMembers, metricsIndex, consumed); // 3. resolve comp, score + ReconcileConstructorPairs(order, metricsMembers, consumed); var unmatched = CollectUnmatched(metricsMembers, consumed); // 4. honesty channel return new CrapReport(scored, unscored, unmatched); } + // Roslyn displays static and parameterless instance constructors identically. Only a + // complete, measured pair in the same type and file proves both indistinguishable rows. + private static void ReconcileConstructorPairs(List order, + IReadOnlyList? members, HashSet consumed) + { + if (members is null) return; + var pairs = order.Where(static method => !method.FoldedAway && method.Lines.Count > 0 && + method.Signature is "" && method.Name.Method is ".ctor" or ".cctor") + .GroupBy(static method => (method.Name.TypeIdentity, method.File)) + .Where(static group => group.Select(method => method.Name.Method).Distinct().Count() == 2) + .ToLookup(static group => group.Key.TypeIdentity, static group => group.Key.File); + foreach (var group in members.Select(static (member, index) => (Member: member, Index: index)) + .Where(static entry => entry.Member.Kind is CodeMetricsMemberKind.Method && + entry.Member.MemberName is ".ctor" && entry.Member.ExecutableLines is not 0 && + entry.Member.Arity is null or 0) + .GroupBy(static entry => entry.Member.TypeIdentity ?? entry.Member.TypeName)) + { + var candidates = group.ToArray(); + if (candidates.Length != 2 || candidates.Any(static entry => + MethodSignatures.Metrics(entry.Member.DisplayName) is not "")) continue; + if (!pairs[group.Key].Any(file => candidates.All(entry => + string.IsNullOrWhiteSpace(entry.Member.File) || entry.Member.File is "UNKNOWN" || + PathIdentity.NormalizeRoot(entry.Member.File) == file))) continue; + foreach (var entry in candidates) consumed.Add(entry.Index); + } + } + // Scoring may conservatively take the maximum complexity of several candidates. // Exclusions cannot use that fallback: consuming siblings would hide missing coverage. // Without source-file/signature identity, only a unique compatible member is proven. @@ -269,7 +297,7 @@ private static void MatchExcludedMetrics( MetricsIndex metricsIndex, HashSet consumed) { - var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; + var key = $"{logical.Name.TypeIdentity}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature); if (compatible.UniqueMember is { } index) consumed.Add(index); } @@ -500,7 +528,7 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe switch (m.Kind) { case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: - var key = $"{m.TypeName}|{m.MemberName}"; + var key = $"{m.TypeIdentity ?? m.TypeName}|{m.MemberName}"; // Summarize each executable bucket once. var complexity = Math.Max(0, m.CyclomaticComplexity); var signature = MethodSignatures.Metrics(m.DisplayName); @@ -521,8 +549,8 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe // used only when no accessor entry matched. An aggregate below 1 summed no // declared accessor (a positional record property) — nothing was measured. if (m.CyclomaticComplexity < 1 || m.ExecutableLines is 0) break; - index.AccessorFallback.TryAdd($"{m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); - index.AccessorFallback.TryAdd($"{m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); + index.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); + index.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); break; } } @@ -559,7 +587,7 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( MetricsIndex metricsIndex, HashSet consumed) { - var key = $"{logical.TypeKey}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; + var key = $"{logical.Name.TypeIdentity}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; if (metricsIndex.All.TryGetValue(key, out var candidates)) { // Keep the conservative complexity fallback when no arity matches, but never diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 032d7ae..a376752 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -18,6 +18,9 @@ internal static partial class MethodSignatures [GeneratedRegex(@"(?(); - foreach (var parameter in BalancedText.SplitTopLevel(display[(open + 1)..^1])) + foreach (var parameter in BalancedText.SplitTopLevel(parameters)) { var equals = BalancedText.LastIndexOfTopLevel(parameter, '='); var declaration = equals < 0 ? parameter : parameter[..equals].TrimEnd(); @@ -38,6 +62,7 @@ internal static partial class MethodSignatures if (space < 0) return null; types.Add(NormalizeType(declaration[..space])); } + if (valueType is not null) types.Add(NormalizeType(valueType)); return string.Join(",", types); } @@ -50,6 +75,7 @@ private static string NormalizeType(string type) type.StartsWith("in ", StringComparison.Ordinal); if (byRef) type = type[(type.IndexOf(' ') + 1)..]; type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); + type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value[..^1]}>"); return type.Replace(" ", "", StringComparison.Ordinal) + (byRef ? "&" : ""); } } diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs index 26ed6cb..f3fa515 100644 --- a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -6,6 +6,10 @@ public sealed class CrapSignatureMatchingTests { [Test] [Arguments("int x", "System.Int32")] + [Arguments("int? x", "System.Nullable`1")] + [Arguments("decimal?[] x", "System.Nullable`1[]")] + [Arguments("ref long? x", "System.Nullable`1&")] + [Arguments("List x", "List>")] [Arguments("int x = 1", "System.Int32")] [Arguments("char x = ','", "System.Char")] [Arguments("string x = \"a,(b)=c\"", "System.String")] @@ -83,4 +87,43 @@ public async Task RepeatedSameSignatureAcrossFiles_CannotReplaceAMissingOverload await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.M(string value)"]); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + [Test] + [Arguments("get", false)] + [Arguments("set", false)] + [Arguments("init", false)] + [Arguments("get", true)] + [Arguments("set", true)] + public async Task SameArityIndexers_MatchParametersAndSetterValue(string accessor, bool missing) + { + var method = accessor == "get" ? "get_Item" : "set_Item"; + var value = accessor == "get" ? "" : ",System.String"; + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => + { + c.Method(method, $"(System.Int32{value})", null, m => m.Line(1, 1)); + if (!missing) c.Method(method, $"(System.String{value})", null, m => m.Line(2, 1)); + }).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method($"string A.this[int x].{accessor}", 2, 1) + .Method($"string A.this[string x].{accessor}", 7, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(missing ? 1 : 0); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo(missing ? [2] : new[] { 2, 7 }); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(missing ? GateOutcome.NoData : GateOutcome.Pass); + } + + [Test] + [Arguments("string A.this[int x.get")] + [Arguments("string A.this[int x].unknown")] + [Arguments("A.this[int x].set")] + public async Task MalformedIndexerSignature_DoesNotProveAnOverloadMatch(string display) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => + c.Method("get_Item", "(System.Int32)", "1", m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method(display, 2, 1).Method("string A.this[bool x].get", 7, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsNotEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + } diff --git a/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs new file mode 100644 index 0000000..9f42eda --- /dev/null +++ b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs @@ -0,0 +1,80 @@ +using DotCov.Tests.Infrastructure; + +namespace DotCov.Tests; + +public sealed class CrapTypeAndConstructorTests +{ + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task GenericAndNonGenericTypes_KeepSeparateMetrics(bool exclude) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/Kept.cs", "App.Kept", c => c.Method("M", "()", "1", m => m.Line(1, 1))) + .AddClass("src/A.g.cs", "App.Result", c => c.Method("M", "()", null, m => m.Line(2, 1))) + .AddClass("src/A.g.cs", "App.Result`1", c => c.Method("M", "()", null, m => m.Line(3, 1))) + .AddClass("src/A.g.cs", "App.Outer`1/Inner`2", c => c.Method("M", "()", null, m => m.Line(4, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.Create().Namespace("App", types => types + .Type("Result", m => m.Method("void Result.M()", 2, 1)) + .Type("Result", m => m.Method("void Result.M()", 7, 1)) + .Type("Outer.Inner", m => m.Method("void Inner.M()", 9, 1))).Parse(); + var coverage = CoberturaParser.ParseMethods(input); + if (exclude) coverage = coverage.Exclude([".g.cs"], []); + var report = CrapAnalysis.Analyze(coverage, metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo(exclude ? [1] : new[] { 1, 2, 7, 9 }); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + await Assert.That(metrics[1].TypeName).IsEqualTo("App.Result"); + await Assert.That(metrics[1].TypeIdentity).IsEqualTo("App.Result`1"); + } + + [Test] + [Arguments(null)] + [Arguments("UNKNOWN")] + [Arguments("src/./A.cs")] + public async Task CompleteConstructorPair_IsMatchedWithConservativeComplexity(string? source) + { + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("A.A()", 2, 1).Method("A.A()", 7, 1).Method("A.A(int x)", 3, 1)) + .Select(m => m with { File = source }).ToArray(); + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method(".ctor", "()", null, m => m.Line(1, 1)) + .Method(".cctor", "()", null, m => m.Line(2, 1)) + .Method(".ctor", "(System.Int32)", null, m => m.Line(3, 1))).ToBytes()); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([7, 7, 3]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments("missing")] + [Arguments("no-lines")] + [Arguments("other-file")] + [Arguments("duplicate-instance")] + [Arguments("wrong-source")] + [Arguments("extra-metrics")] + [Arguments("unknown-signature")] + public async Task IncompleteOrAmbiguousConstructorPair_CannotClearMetrics(string scenario) + { + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => + { + m.Method("A.A()", 2, 1).Method(scenario == "unknown-signature" ? "A.A" : "A.A()", 7, 1); + if (scenario == "extra-metrics") m.Method("A.A()", 4, 1); + }).Select(m => m with { File = scenario == "wrong-source" ? "other/A.cs" : null }).ToArray(); + var doc = Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method(".ctor", "()", "1", m => m.Line(1, 1))); + if (scenario != "missing") + doc.AddClass(scenario is "other-file" or "duplicate-instance" ? "other/A.cs" : "src/A.cs", "App.A", c => c + .Method(scenario == "duplicate-instance" ? ".ctor" : ".cctor", "()", "1", m => + { + if (scenario != "no-lines") m.Line(2, 1); + })); + using var input = new MemoryStream(doc.ToBytes()); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsNotEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } +} From 87d1f52d0b9a083ad8d162f60479537fd68a01ae Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:17:52 +0200 Subject: [PATCH 21/42] fix: bound metrics identity work and prevent cross-method complexity reuse --- docs/releases/3.0.1.md | 11 +++- src/DotCov/CodeMetrics.cs | 51 ++++++++++++----- src/DotCov/CrapAnalysis.cs | 6 +- src/DotCov/MethodSignatures.cs | 3 +- .../CodeMetricsReaderEdgeTests.cs | 32 +++++++++++ .../CrapSignatureMatchingTests.cs | 56 +++++++++++++++++++ .../CrapTypeAndConstructorTests.cs | 16 ++++++ 7 files changed, 156 insertions(+), 19 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index b186788..38e037d 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -6,7 +6,9 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. - **Missing executable metrics members prevent a CRAP PASS.** A method or accessor in the metrics file without a coverage counterpart makes the gate `NODATA` (exit `3`). Measured scores and offender counts remain available in the report. - Complete overload sets match by parameter types when those identities are available. + Complete overload sets match by parameter types when those identities are available, + including dynamic/object equivalence and relational/shift operator displays. + One metrics row cannot supply complexity to distinct methods in different files. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are not accepted as coverage matches. Extension-block displays do not identify static versus @@ -14,7 +16,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. Indexer accessors retain their parameter counts, including the setter value, so overloads with different numbers or types of indices can be matched independently. Nullable built-in value types match their runtime `System.Nullable` identities. Generic type - arity is preserved, so `Result` and `Result` do not share a metrics bucket. + arity is preserved, so `Result` and `Result` do not share a metrics bucket. Generic + extension blocks retain their enclosing emitted type identity. Complete static/instance constructor pairs in one file reconcile their indistinguishable Roslyn displays while retaining the conservative maximum complexity for both. - **Exclusions and declarations are accounted for.** Methods intentionally removed by @@ -35,7 +38,9 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. quadratic scans on reports with many excluded files. Repeated excluded metrics identities are matched once instead of rescanning their candidates for every raw signature. Metrics candidates are summarized by parameter count, so repeated measured methods - also avoid candidate scans and repeated string parsing. + also avoid candidate scans and repeated string parsing. Enclosing type identities are + cached across members, and metrics named-type nesting is bounded to 256 levels to limit + resource amplification from deeply nested input. - **Patched Fallout dependencies.** The Fallout integration constrains its transitive `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies to 10.0.13, addressing the security advisories published on October 13, 2026. diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 161e13d..b394a27 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -75,11 +75,13 @@ public readonly record struct CodeMetricsMember( /// SymbolDisplayFormat.MinimallyQualifiedFormat strings such as /// Task<string> Type.LoadAsync(string fileName, int lineCount), /// Type.Type(int seed) for constructors, and string Type.Prop.get for accessors. +/// Named-type nesting is limited to 256 levels to bound identity expansion. /// Same XmlReader streaming + hardening ethos as . /// public static partial class CodeMetricsReader { private const long DefaultMaxChars = 50_000_000; + private const int MaxTypeDepth = 256; private const string GlobalNamespaceDisplay = ""; public static IReadOnlyList Parse(Stream stream, long maxChars = DefaultMaxChars) @@ -131,6 +133,7 @@ private sealed class ParserState public readonly List Members = []; public string Namespace = ""; public readonly List TypeChain = []; // raw named-type displays, including generic parameters + public TypeContext? CachedType; public readonly Stack MemberStack = new(); // Property → its Accessor methods nest } @@ -156,12 +159,18 @@ private static void OnElementStart(XmlReader reader, ParserState state) // namespace ""; IL names its types with no prefix at all. var ns = reader.GetAttribute("Name") ?? ""; state.Namespace = ns is GlobalNamespaceDisplay ? "" : ns; + state.CachedType = null; break; case "NamedType": // Empty elements raise no EndElement, so only a real subtree pushes. if (!reader.IsEmptyElement) + { + if (state.TypeChain.Count >= MaxTypeDepth) + throw new XmlException($"Metrics named-type nesting exceeds {MaxTypeDepth} levels."); state.TypeChain.Add(reader.GetAttribute("Name") ?? ""); + state.CachedType = null; + } break; case "Metric": @@ -180,11 +189,13 @@ private static void OnElementEnd(XmlReader reader, ParserState state) { case "Namespace": state.Namespace = ""; + state.CachedType = null; break; case "NamedType": // Every EndElement pairs with a non-empty start element, which pushed. state.TypeChain.RemoveAt(state.TypeChain.Count - 1); + state.CachedType = null; break; default: @@ -212,7 +223,8 @@ private static void CloseMember(ParserState state) // A member without a CyclomaticComplexity metric has nothing to contribute to a // complexity table — skipped, not zeroed. if (pending.Complexity is { } complexity) - state.Members.Add(Materialize(pending, state.Namespace, state.TypeChain, complexity)); + state.Members.Add(Materialize(pending, + state.CachedType ??= DescribeType(state.Namespace, state.TypeChain), complexity)); } private static void RecordMetric(XmlReader reader, ParserState state) @@ -239,8 +251,11 @@ private static void RecordMetric(XmlReader reader, ParserState state) } } - private static CodeMetricsMember Materialize( - PendingMember pending, string ns, List typeChain, int complexity) + private sealed record TypeContext(string Name, string Identity, string SimpleName); + + // Share the enclosing identity strings across every member in a type scope. Computing + // the joins per member amplifies deeply nested input into repeated work and allocations. + private static TypeContext DescribeType(string ns, List typeChain) { var typeName = string.Join(".", typeChain.Select(static name => TypeSegment(name))); var typeIdentity = string.Join(".", typeChain.Select(static name => TypeSegment(name, retainArity: true))); @@ -250,14 +265,18 @@ private static CodeMetricsMember Materialize( typeIdentity = $"{ns}.{typeIdentity}"; } - var enclosingSimpleName = SimpleName(TypeSegment(typeChain[^1])); - var (memberName, arity, kind) = ParseDisplay(pending.Display, pending.Element, enclosingSimpleName); + return new TypeContext(typeName, typeIdentity, SimpleName(TypeSegment(typeChain[^1]))); + } - return new CodeMetricsMember(typeName, memberName, kind, arity, complexity, pending.Display) + private static CodeMetricsMember Materialize(PendingMember pending, TypeContext type, int complexity) + { + var (memberName, arity, kind) = ParseDisplay(pending.Display, pending.Element, type.SimpleName); + + return new CodeMetricsMember(type.Name, memberName, kind, arity, complexity, pending.Display) { ExecutableLines = pending.HasExecutableLines ? pending.ExecutableLines : pending.LegacyExecutableLines, File = pending.File, - TypeIdentity = typeIdentity + TypeIdentity = type.Identity }; } @@ -277,14 +296,13 @@ private static string SimpleName(string dottedType) private static string TypeSegment(string name, bool retainArity = false) { - var stripped = retainArity + var dot = BalancedText.LastIndexOfTopLevel(name, '.'); + if (dot >= 0 && StripGenericsPerSegment(name[(dot + 1)..]).StartsWith("extension(", StringComparison.Ordinal)) + name = name[..dot]; + return retainArity ? TypeParameterLists().Replace(name, static match => $"`{BalancedText.CountTopLevelItems(match.Groups[1].Value)}") : StripGenericsPerSegment(name); - var dot = BalancedText.LastIndexOfTopLevel(stripped, '.'); - return dot >= 0 && stripped.AsSpan(dot + 1).StartsWith("extension(", StringComparison.Ordinal) - ? stripped[..dot] - : stripped; } /// @@ -439,8 +457,7 @@ private static bool TryOperatorName(string head, int? arity, out string name) /// private static (string Head, int? Arity) SplitParameterList(string display) { - var opIdx = FindTopLevel(display, ".operator "); - var open = opIdx >= 0 ? display.IndexOf('(', opIdx) : MemberParameterListOpen(display); + var open = ParameterListOpen(display); if (open < 0) return (display, null); var close = display.LastIndexOf(')'); @@ -448,6 +465,12 @@ private static (string Head, int? Arity) SplitParameterList(string display) return (display[..open], BalancedText.CountTopLevelItems(parameters)); } + internal static int ParameterListOpen(string display) + { + var opIdx = FindTopLevel(display, ".operator "); + return opIdx >= 0 ? display.IndexOf('(', opIdx) : MemberParameterListOpen(display); + } + private static int MemberParameterListOpen(string display) { var nameStart = BalancedText.LastIndexOfTopLevel(display, '.') + 1; diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index db15a77..49c9a80 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -501,6 +501,7 @@ private sealed class MetricsIndex public readonly Dictionary<(string Key, int? Arity), MetricsCandidates> ByArity = new(); public readonly Dictionary<(string Key, string Signature), MetricsCandidates> BySignature = new(); public readonly Dictionary AccessorFallback = new(StringComparer.Ordinal); + public readonly Dictionary PropertyFiles = new(); } private static MetricsCandidates MergeCandidates(MetricsCandidates left, MetricsCandidates right) @@ -593,12 +594,15 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( // Keep the conservative complexity fallback when no arity matches, but never // treat a provably incompatible candidate as evidence of complete coverage. var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature); - if (compatible.UniqueMember is { } index) consumed.Add(index); + if (compatible.UniqueMember is { } index && !consumed.Add(index)) return null; return compatible.Complexity ?? candidates.Complexity; } if (metricsIndex.AccessorFallback.TryGetValue(key, out var property)) { + // A property roll-up can serve both accessors, but not a different file's property. + if (metricsIndex.PropertyFiles.TryGetValue(property.Index, out var file) && file != logical.File) return null; + metricsIndex.PropertyFiles[property.Index] = logical.File; consumed.Add(property.Index); return property.Complexity; } diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index a376752..c7decc9 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -13,6 +13,7 @@ internal static partial class MethodSignatures ["uint"] = "System.UInt32", ["long"] = "System.Int64", ["ulong"] = "System.UInt64", ["char"] = "System.Char", ["float"] = "System.Single", ["double"] = "System.Double", ["decimal"] = "System.Decimal", ["string"] = "System.String", ["object"] = "System.Object", + ["dynamic"] = "System.Object", ["nint"] = "System.IntPtr", ["nuint"] = "System.UIntPtr" }; [GeneratedRegex(@"(?"); + for (var i = 0; i < depth; i++) xml.Append(""); + for (var i = 0; i < count; i++) xml.Append($"{Metric3}"); + for (var i = 1; i < depth; i++) xml.Append(""); + xml.Append($"{Metric3}"); + + var members = ParseRaw(xml.ToString()); + + await Assert.That(members.Count).IsEqualTo(count + 1); + await Assert.That(members.Take(count).Select(m => m.TypeName).Distinct()) + .IsEquivalentTo(["App." + string.Join(".", Enumerable.Repeat("A", depth))]); + await Assert.That(members.Take(count).Select(m => m.TypeIdentity).Distinct()) + .IsEquivalentTo(["App." + string.Join(".", Enumerable.Repeat("A`1", depth))]); + await Assert.That(members[^1].TypeIdentity).IsEqualTo("App.A`1"); + await Assert.That(members[^1].MemberName).IsEqualTo("Parent"); + } + + [Test] + public async Task ExcessiveNamedTypeNesting_IsRejectedBeforeIdentityExpansion() + { + var xml = string.Concat(Enumerable.Repeat("", 257)) + + string.Concat(Enumerable.Repeat("", 257)); + var error = Assert.ThrowsExactly(() => ParseRaw(xml)); + await Assert.That(error.Message).Contains("256 levels"); + } + } diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs index f3fa515..da02a01 100644 --- a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -6,6 +6,7 @@ public sealed class CrapSignatureMatchingTests { [Test] [Arguments("int x", "System.Int32")] + [Arguments("dynamic x", "System.Object")] [Arguments("int? x", "System.Nullable`1")] [Arguments("decimal?[] x", "System.Nullable`1[]")] [Arguments("ref long? x", "System.Nullable`1&")] @@ -126,4 +127,59 @@ public async Task MalformedIndexerSignature_DoesNotProveAnOverloadMatch(string d await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + [Test] + [Arguments("<", "op_LessThan")] + [Arguments(">", "op_GreaterThan")] + [Arguments("<<", "op_LeftShift")] + [Arguments(">>", "op_RightShift")] + [Arguments(">>>", "op_UnsignedRightShift")] + [Arguments("<=", "op_LessThanOrEqual")] + [Arguments(">=", "op_GreaterThanOrEqual")] + public async Task CompleteOperatorOverloads_IgnoreAngleTokensWhenLocatingParameters(string token, string name) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method(name, "(App.A,System.Int32)", null, m => m.Line(1, 1)) + .Method(name, "(App.A,System.String)", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method($"bool A.operator {token}(App.A left, int right)", 2, 1) + .Method($"bool A.operator {token}(App.A left, string right)", 7, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([2, 7]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DistinctMethods_CannotReuseOneMetricsRow_UnlessBothHaveEmbeddedMeasurements(bool embedded) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("M", "()", embedded ? "2" : null, m => m.Line(1, 1))) + .AddClass("other/A.cs", "App.A", c => c.Method("M", "()", embedded ? "7" : null, m => m.Line(2, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M()", 2, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Methods.Count).IsEqualTo(embedded ? 2 : 1); + await Assert.That(report.Unscored.Count).IsEqualTo(embedded ? 0 : 1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(embedded ? GateOutcome.Pass : GateOutcome.NoData); + } + + [Test] + public async Task PropertyFallback_CanServeBothAccessors_ButCannotCrossFileIdentity() + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c + .Method("get_Value", "()", null, m => m.Line(1, 1)) + .Method("set_Value", "(System.Int32)", null, m => m.Line(2, 1))) + .AddClass("other/A.cs", "App.A", c => c.Method("get_Value", "()", null, m => m.Line(3, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Property("int A.Value", 2)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Methods.Count).IsEqualTo(2); + await Assert.That(report.Unscored.Single().File).IsEqualTo("other/A.cs"); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + } diff --git a/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs index 9f42eda..49556ac 100644 --- a/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs +++ b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs @@ -77,4 +77,20 @@ public async Task IncompleteOrAmbiguousConstructorPair_CannotClearMetrics(string await Assert.That(report.UnmatchedMetricsMembers).IsNotEmpty(); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + [Test] + [Arguments("Ext.extension(T)", "App.Ext")] + [Arguments("Outer.Ext.extension(U)", "App.Outer`1.Ext")] + public async Task GenericExtensionBlock_StaticMemberBelongsToItsEnclosingType(string owner, string identity) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/Ext.cs", identity, c => c + .Method("Make", "()", null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", owner, m => m + .Method("int extension(T).Make()", 2, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(metrics.Single().TypeIdentity).IsEqualTo(identity); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + } From 73ad08883f406b21a5abbc0c953df5d3ea25331a Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:19:34 +0200 Subject: [PATCH 22/42] test: align nullable type identity assertion with TUnit constraints --- tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs index f001ba0..99898a9 100644 --- a/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs +++ b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs @@ -454,7 +454,7 @@ public async Task DeepTypeScope_WithManyMembers_PreservesNamesWhenReturningToPar await Assert.That(members.Count).IsEqualTo(count + 1); await Assert.That(members.Take(count).Select(m => m.TypeName).Distinct()) .IsEquivalentTo(["App." + string.Join(".", Enumerable.Repeat("A", depth))]); - await Assert.That(members.Take(count).Select(m => m.TypeIdentity).Distinct()) + await Assert.That(members.Take(count).Select(m => m.TypeIdentity!).Distinct()) .IsEquivalentTo(["App." + string.Join(".", Enumerable.Repeat("A`1", depth))]); await Assert.That(members[^1].TypeIdentity).IsEqualTo("App.A`1"); await Assert.That(members[^1].MemberName).IsEqualTo("Parent"); From f4ca7ca44bb8863b3c7d2719a0bd0bb2a7ed8bf9 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:22:21 +0200 Subject: [PATCH 23/42] fix: fold top-level entry point and async body into one measured method --- src/DotCov/MethodIdentity.cs | 8 ++++++++ tests/DotCov.Tests/CrapAnalysisTests.cs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/DotCov/MethodIdentity.cs b/src/DotCov/MethodIdentity.cs index 93e949e..062041d 100644 --- a/src/DotCov/MethodIdentity.cs +++ b/src/DotCov/MethodIdentity.cs @@ -81,6 +81,14 @@ internal static bool TryNormalize( return true; } + // The top-level entry point is a source method, not a synthetic group with an + // unknown ordinal. Its state machine can then attach to this direct entry. + if (methodName is "
$") + { + methodKey = "Main"; + return true; + } + // On the source type itself: this-capturing lambdas (b__…) and local functions // (g__Name|…) are emitted directly on the type with mangled names. if (TryMangledOrigin(methodName, out var mangled, out var mangledOrdinal)) diff --git a/tests/DotCov.Tests/CrapAnalysisTests.cs b/tests/DotCov.Tests/CrapAnalysisTests.cs index f600098..d486a05 100644 --- a/tests/DotCov.Tests/CrapAnalysisTests.cs +++ b/tests/DotCov.Tests/CrapAnalysisTests.cs @@ -244,6 +244,27 @@ public async Task TopLevelStatements_AsyncEntryPoint_NormalizesToProgramMain() await Assert.That(m.Complexity).IsEqualTo(3); } + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task TopLevelStatements_EntryAndStateMachine_ShareOneMeasuredRow(bool bodyFirst) + { + var entry = Method("Program", "
$", "(System.String[])", complexity: null, + lines: [(1, 1)]); + var body = Method("Program/<
$>d__0", "MoveNext", "()", complexity: null, + lines: [(2, 0)]); + var metrics = CodeMetricsReportBuilder.ParseType("", "Program", + m => m.Method("", 8)); + + var report = CrapAnalysis.Analyze(bodyFirst ? [body, entry] : [entry, body], metrics); + + var measured = await Assert.That(report.Methods).HasSingleItem(); + await Assert.That(measured.Score).IsEqualTo(16.0); + await Assert.That(report.Unscored).IsEmpty(); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + [Test] public async Task TopLevelStatements_SyncEntryPoint_NormalizesToProgramMain() { From 177badbecbfd101ca691fb184b89b38017c6049a Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:31:46 +0200 Subject: [PATCH 24/42] fix: require one unique measured constructor pair per type --- src/DotCov/CrapAnalysis.cs | 12 ++++---- .../CrapTypeAndConstructorTests.cs | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 49c9a80..556ece8 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -270,9 +270,11 @@ private static void ReconcileConstructorPairs(List order, if (members is null) return; var pairs = order.Where(static method => !method.FoldedAway && method.Lines.Count > 0 && method.Signature is "" && method.Name.Method is ".ctor" or ".cctor") - .GroupBy(static method => (method.Name.TypeIdentity, method.File)) - .Where(static group => group.Select(method => method.Name.Method).Distinct().Count() == 2) - .ToLookup(static group => group.Key.TypeIdentity, static group => group.Key.File); + .GroupBy(static method => method.Name.TypeIdentity) + .Where(static group => group.Count() == 2 && + group.Select(method => method.Name.Method).Distinct().Count() == 2 && + group.Select(method => method.File).Distinct().Count() == 1) + .ToDictionary(static group => group.Key, static group => group.First().File); foreach (var group in members.Select(static (member, index) => (Member: member, Index: index)) .Where(static entry => entry.Member.Kind is CodeMetricsMemberKind.Method && entry.Member.MemberName is ".ctor" && entry.Member.ExecutableLines is not 0 && @@ -282,9 +284,9 @@ entry.Member.MemberName is ".ctor" && entry.Member.ExecutableLines is not 0 && var candidates = group.ToArray(); if (candidates.Length != 2 || candidates.Any(static entry => MethodSignatures.Metrics(entry.Member.DisplayName) is not "")) continue; - if (!pairs[group.Key].Any(file => candidates.All(entry => + if (!pairs.TryGetValue(group.Key, out var file) || !candidates.All(entry => string.IsNullOrWhiteSpace(entry.Member.File) || entry.Member.File is "UNKNOWN" || - PathIdentity.NormalizeRoot(entry.Member.File) == file))) continue; + PathIdentity.NormalizeRoot(entry.Member.File) == file)) continue; foreach (var entry in candidates) consumed.Add(entry.Index); } } diff --git a/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs index 49556ac..b58ac46 100644 --- a/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs +++ b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs @@ -49,6 +49,34 @@ public async Task CompleteConstructorPair_IsMatchedWithConservativeComplexity(st await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); } + [Test] + [Arguments(null, false)] + [Arguments(null, true)] + [Arguments("UNKNOWN", false)] + [Arguments("UNKNOWN", true)] + [Arguments("src/A.cs", false)] + [Arguments("src/A.cs", true)] + public async Task ConstructorPair_CannotBeReusedAcrossFiles(string? source, bool secondPair) + { + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("A.A()", 2, 1).Method("A.A()", 7, 1)) + .Select(m => m with { File = source }).ToArray(); + var doc = Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method(".ctor", "()", null, m => m.Line(1, 1)) + .Method(".cctor", "()", null, m => m.Line(2, 1))) + .AddClass("other/A.cs", "App.A", c => + { + c.Method(".ctor", "()", null, m => m.Line(3, 1)); + if (secondPair) c.Method(".cctor", "()", null, m => m.Line(4, 1)); + }); + using var input = new MemoryStream(doc.ToBytes()); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + [Test] [Arguments("missing")] [Arguments("no-lines")] From e3aed78a764317629d89278b8835255e044e4e85 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:42:09 +0200 Subject: [PATCH 25/42] fix: enforce metrics source and signature identity while preserving generic compatibility --- docs/releases/3.0.1.md | 3 + src/DotCov/CrapAnalysis.cs | 114 +++++++++++---- src/DotCov/MethodSignatures.cs | 39 +++++- .../DotCov.Tests/CrapMetricsIdentityTests.cs | 130 ++++++++++++++++++ .../CrapSignatureMatchingTests.cs | 4 +- 5 files changed, 260 insertions(+), 30 deletions(-) create mode 100644 tests/DotCov.Tests/CrapMetricsIdentityTests.cs diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 38e037d..4cda8b0 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -9,6 +9,9 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. Complete overload sets match by parameter types when those identities are available, including dynamic/object equivalence and relational/shift operator displays. One metrics row cannot supply complexity to distinct methods in different files. + Known source files and conflicting parameter signatures are respected even for a single + candidate. Relative metrics paths use a single declared coverage source root; legacy + hand-built metrics recover generic type identities only when the coverage scope is unambiguous. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are not accepted as coverage matches. Extension-block displays do not identify static versus diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 556ece8..78ee4c7 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -207,7 +207,7 @@ public static CrapReport Analyze(MethodCoverageReport report, IReadOnlyList methods, IReadOnlyList? metricsMembers = null) => - AnalyzeCore(methods, metricsMembers, []); + AnalyzeCore(methods, metricsMembers, [], []); private static CrapReport AnalyzeCore( IReadOnlyList methods, IReadOnlyList? metricsMembers, - IReadOnlyList excludedMethods) + IReadOnlyList excludedMethods, IReadOnlyList sourceRoots) { var order = FoldRawEntries(methods); // 1. raw coverage → logical source methods FoldSyntheticGroups(order); // 2. state machines/lambdas → their origin + var excluded = FoldRawEntries(excludedMethods); + FoldSyntheticGroups(excluded); + metricsMembers = PrepareMetrics(metricsMembers, order.Concat(excluded), sourceRoots); + var metricSignatures = metricsMembers?.Select(static m => MethodSignatures.Metrics(m.DisplayName)).ToArray() ?? []; + var qualifications = MethodSignatures.GenericQualifications(order.Concat(excluded).Select(static m => m.Signature), + metricSignatures); + foreach (var logical in order.Concat(excluded)) + logical.Signature = MethodSignatures.QualifyGenerics(logical.Signature, qualifications); + var signatures = metricSignatures.Select(s => MethodSignatures.QualifyGenerics(s, qualifications, fromMetrics: true)).ToArray(); var consumed = new HashSet(); // Exclusions remove methods from the measurement scope, not from the metrics file. // Match their identities without scoring them, even when they have no line data. - var metricsIndex = IndexMetrics(metricsMembers); + var metricsIndex = IndexMetrics(metricsMembers, signatures); // Source-bearing exclusions were removed by SelectMetrics. A retained member's // known source must not be consumed by an excluded same-name sibling. - var excludedMetricsIndex = IndexMetrics(metricsMembers, onlyWithoutSource: true); - var excluded = FoldRawEntries(excludedMethods); - FoldSyntheticGroups(excluded); + var excludedMetricsIndex = IndexMetrics(metricsMembers, signatures, onlyWithoutSource: true); // Different raw signatures/files can have the same available metrics identity. // Account for that identity once, even when every candidate is a zero-body declaration. foreach (var logical in excluded.Where(static l => !l.FoldedAway) @@ -300,17 +307,54 @@ private static void MatchExcludedMetrics( HashSet consumed) { var key = $"{logical.Name.TypeIdentity}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; - var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature); + var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature, logical.File); if (compatible.UniqueMember is { } index) consumed.Add(index); } - private static MetricsCandidates CompatibleMetrics(MetricsIndex index, string key, int? arity, string? signature) + private static IReadOnlyList? PrepareMetrics( + IReadOnlyList? members, IEnumerable methods, + IReadOnlyList roots) { - if (arity is null) return index.All.GetValueOrDefault(key); - var compatible = MergeCandidates(index.ByArity.GetValueOrDefault((key, arity)), - index.ByArity.GetValueOrDefault((key, null))); - if (compatible.ExecutableCount > 1 && !compatible.HasUnknownSignature && - index.BySignature.TryGetValue((key, signature!), out var exact)) return exact; + if (members is null) return null; + // Legacy callers supplied the generic-stripped TypeName. Recover arity only when + // the coverage scope contains exactly one possible declaring-type identity. + var types = methods.GroupBy(static m => m.TypeKey).ToDictionary(static g => g.Key, + static g => g.Select(m => m.Name.TypeIdentity).Distinct().Take(2).ToArray()); + return members.Select(m => m with + { + TypeIdentity = m.TypeIdentity ?? (types.TryGetValue(m.TypeName, out var identities) && + identities.Length == 1 ? identities[0] : null), + File = MetricsFile(m.File, roots) + }).ToArray(); + } + + private static string? MetricsFile(string? file, IReadOnlyList roots) + { + if (string.IsNullOrWhiteSpace(file) || file is "UNKNOWN") return file; + var path = PathIdentity.NormalizeRoot(file); + if (roots.Count == 1 && roots[0].Length > 0 && !path.StartsWith('/') && + !(path.Length >= 2 && path[1] == ':')) + path = PathIdentity.NormalizeRoot($"{roots[0].TrimEnd('/')}/{path}"); + return path; + } + + private static MetricsCandidates CompatibleMetrics(MetricsIndex index, string key, int? arity, + string? signature, string file) + { + var scoped = index.ByFile.GetValueOrDefault(PathIdentity.NormalizeRoot(file)); + if (arity is null) return MergeCandidates(index.All.GetValueOrDefault(key), + scoped?.All.GetValueOrDefault(key) ?? default); + var compatible = MergeCandidates( + MergeCandidates(index.ByArity.GetValueOrDefault((key, arity)), + index.ByArity.GetValueOrDefault((key, null))), + MergeCandidates(scoped?.ByArity.GetValueOrDefault((key, arity)) ?? default, + scoped?.ByArity.GetValueOrDefault((key, null)) ?? default)); + if (signature is not null && compatible.ExecutableCount > 0 && !compatible.HasUnknownSignature) + { + var exact = MergeCandidates(index.BySignature.GetValueOrDefault((key, signature)), + scoped?.BySignature.GetValueOrDefault((key, signature)) ?? default); + return exact.ExecutableCount > 0 ? exact : exact with { SignatureMismatch = true }; + } return compatible; } @@ -454,7 +498,7 @@ private sealed class LogicalMethod(MethodName name, string typeKey, int? arity, public readonly MethodName Name = name; public readonly string TypeKey = typeKey; public readonly int? Arity = arity; - public readonly string? Signature = signature; + public string? Signature = signature; /// Set for compiler-synthesized material; null for a method the source names directly. public SyntheticOrigin? Origin = origin; public bool Folded => Origin is not null; @@ -495,7 +539,10 @@ public void MergeEmbeddedComplexity(int? complexity) } private readonly record struct MetricsCandidates(int? Complexity, int? UniqueMember, int ExecutableCount, - bool HasUnknownSignature); + bool HasUnknownSignature) + { + public bool SignatureMismatch { get; init; } + } private sealed class MetricsIndex { @@ -504,6 +551,7 @@ private sealed class MetricsIndex public readonly Dictionary<(string Key, string Signature), MetricsCandidates> BySignature = new(); public readonly Dictionary AccessorFallback = new(StringComparer.Ordinal); public readonly Dictionary PropertyFiles = new(); + public readonly Dictionary ByFile = new(StringComparer.Ordinal); } private static MetricsCandidates MergeCandidates(MetricsCandidates left, MetricsCandidates right) @@ -519,7 +567,8 @@ private static void AddCandidates(Dictionary inde MetricsCandidates candidate) where TKey : notnull => index[key] = MergeCandidates(index.GetValueOrDefault(key), candidate); - private static MetricsIndex IndexMetrics(IReadOnlyList? members, bool onlyWithoutSource = false) + private static MetricsIndex IndexMetrics(IReadOnlyList? members, string?[] signatures, + bool onlyWithoutSource = false) { var index = new MetricsIndex(); if (members is null) return index; @@ -528,20 +577,26 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe { var m = members[i]; if (onlyWithoutSource && !string.IsNullOrWhiteSpace(m.File) && m.File is not "UNKNOWN") continue; + var target = index; + if (!string.IsNullOrWhiteSpace(m.File) && m.File is not "UNKNOWN") + { + if (!index.ByFile.TryGetValue(m.File, out target)) + index.ByFile.Add(m.File, target = new MetricsIndex()); + } switch (m.Kind) { case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: var key = $"{m.TypeIdentity ?? m.TypeName}|{m.MemberName}"; // Summarize each executable bucket once. var complexity = Math.Max(0, m.CyclomaticComplexity); - var signature = MethodSignatures.Metrics(m.DisplayName); + var signature = signatures[i]; // Retain an empty bucket for explicit zero-body accessors so an aggregate // cannot restore the declaration's non-executable complexity as a fallback. var candidate = m.ExecutableLines is 0 ? default : new MetricsCandidates(complexity, i, 1, signature is null); - AddCandidates(index.All, key, candidate); - AddCandidates(index.ByArity, (key, m.Arity), candidate); - if (signature is not null) AddCandidates(index.BySignature, (key, signature), candidate); + AddCandidates(target.All, key, candidate); + AddCandidates(target.ByArity, (key, m.Arity), candidate); + if (signature is not null) AddCandidates(target.BySignature, (key, signature), candidate); // The metrics display omits static/instance information for extension // blocks. An inferred receiver must not prove a match for a static member. break; @@ -552,8 +607,8 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe // used only when no accessor entry matched. An aggregate below 1 summed no // declared accessor (a positional record property) — nothing was measured. if (m.CyclomaticComplexity < 1 || m.ExecutableLines is 0) break; - index.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); - index.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); + target.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); + target.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); break; } } @@ -591,16 +646,21 @@ private static (int? Complexity, CrapComplexitySource Source) ResolveComplexity( HashSet consumed) { var key = $"{logical.Name.TypeIdentity}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; - if (metricsIndex.All.TryGetValue(key, out var candidates)) + var scoped = metricsIndex.ByFile.GetValueOrDefault(PathIdentity.NormalizeRoot(logical.File)); + if (metricsIndex.All.ContainsKey(key) || (scoped?.All.ContainsKey(key) ?? false)) { // Keep the conservative complexity fallback when no arity matches, but never // treat a provably incompatible candidate as evidence of complete coverage. - var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature); + var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature, logical.File); + if (compatible.SignatureMismatch) return null; if (compatible.UniqueMember is { } index && !consumed.Add(index)) return null; - return compatible.Complexity ?? candidates.Complexity; + return compatible.Complexity ?? MergeCandidates(metricsIndex.All.GetValueOrDefault(key), + scoped?.All.GetValueOrDefault(key) ?? default).Complexity; } - if (metricsIndex.AccessorFallback.TryGetValue(key, out var property)) + var propertyFound = scoped is not null && scoped.AccessorFallback.TryGetValue(key, out _); + var propertyIndex = propertyFound ? scoped! : metricsIndex; + if (propertyIndex.AccessorFallback.TryGetValue(key, out var property)) { // A property roll-up can serve both accessors, but not a different file's property. if (metricsIndex.PropertyFiles.TryGetValue(property.Index, out var file) && file != logical.File) return null; diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index c7decc9..3c537c4 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -22,6 +22,43 @@ internal static partial class MethodSignatures [GeneratedRegex(@"\bSystem\.(Boolean|Byte|SByte|Int16|UInt16|Int32|UInt32|Int64|UInt64|Char|Single|Double|Decimal|IntPtr|UIntPtr)\?", RegexOptions.CultureInvariant)] private static partial Regex NullableValueTypes(); + [GeneratedRegex(@"`[0-9]+(?=<)", RegexOptions.CultureInvariant)] + private static partial Regex ConstructedArity(); + + [GeneratedRegex(@"[\p{L}_][\w.]*(?=<)", RegexOptions.CultureInvariant)] + private static partial Regex GenericNames(); + + internal static Dictionary GenericQualifications(IEnumerable coverage, + IEnumerable metrics) + { + var names = new Dictionary(StringComparer.Ordinal); + foreach (var signature in coverage) AddNames(signature, includeUnqualified: true); + foreach (var signature in metrics) AddNames(signature, includeUnqualified: false); + return names; + + void AddNames(string? signature, bool includeUnqualified) + { + if (signature is null) return; + foreach (Match match in GenericNames().Matches(signature)) + { + var name = match.Value; + if (!includeUnqualified && !name.Contains('.')) continue; + var simple = name[(name.LastIndexOf('.') + 1)..]; + if (!names.TryGetValue(simple, out var prior)) names.Add(simple, name); + else if (prior != name) names[simple] = null; + } + } + } + + internal static string? QualifyGenerics(string? signature, Dictionary names, + bool fromMetrics = false) + { + if (signature is null) return null; + if (fromMetrics && GenericNames().Matches(signature).Any(match => + names.TryGetValue(match.Value, out var name) && name is null)) return null; + return GenericNames().Replace(signature, match => names.GetValueOrDefault(match.Value) ?? match.Value); + } + internal static string? Coverage(string signature) { if (signature.Length < 2 || signature[0] is not '(' || signature[^1] is not ')') return null; @@ -77,6 +114,6 @@ private static string NormalizeType(string type) if (byRef) type = type[(type.IndexOf(' ') + 1)..]; type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value[..^1]}>"); - return type.Replace(" ", "", StringComparison.Ordinal) + (byRef ? "&" : ""); + return ConstructedArity().Replace(type.Replace(" ", "", StringComparison.Ordinal), "") + (byRef ? "&" : ""); } } diff --git a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs new file mode 100644 index 0000000..d5f448c --- /dev/null +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -0,0 +1,130 @@ +using DotCov.Tests.Infrastructure; + +namespace DotCov.Tests; + +public sealed class CrapMetricsIdentityTests +{ + [Test] + [Arguments("One.List`1", "Two.List`1")] + [Arguments("List`1", "Two.List`1")] + public async Task GenericQualificationCollisions_CannotProveCompleteCoverage(string first, string second) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", $"({first})", null, m => m.Line(1, 1)) + .Method("M", $"({second})", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(List x)", 2, 1).Method("void A.M(Two.List x)", 3, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + public async Task ConstructedGenericArguments_KeepNestedTypesArraysAndReferences() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(System.Collections.Generic.Dictionary`2>&)", null, m => m.Line(1, 1))) + .ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(ref Dictionary> x)", 2, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ConflictingSingleSignature_CannotBorrowAnotherMethodsMeasurement(bool includeMatch) + { + var doc = Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => + { + c.Method("M", "(System.String)", null, m => m.Line(1, 1)); + if (includeMatch) c.Method("M", "(System.Int32)", null, m => m.Line(2, 1)); + }); + using var input = new MemoryStream(doc.ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M(int x)", 2, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Unscored.Count).IsEqualTo(1); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(includeMatch ? 0 : 1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task SourceIdentity_MatchesSeparateFilesWithoutReusingRows(bool includeSecondMetrics) + { + using var input = new MemoryStream(Cobertura.NewDoc() + .AddClass("other/A.cs", "App.A", c => c.Method("M", "()", null, m => m.Line(1, 1))) + .AddClass("src/A.cs", "App.A", c => c.Method("M", "()", null, m => m.Line(2, 1))).ToBytes()); + var first = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M()", 2, 1)) + .Select(m => m with { File = "src/A.cs" }); + var second = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M()", 7, 1)) + .Select(m => m with { File = "other/A.cs" }); + var metrics = (includeSecondMetrics ? first.Concat(second) : first).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Unscored.Count).IsEqualTo(includeSecondMetrics ? 0 : 1); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo(includeSecondMetrics ? [7, 2] : new[] { 2 }); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(includeSecondMetrics ? GateOutcome.Pass : GateOutcome.NoData); + } + + [Test] + [Arguments(null, "src/./A.cs", "src/A.cs")] + [Arguments(".", "src/A.cs", "src/A.cs")] + [Arguments("/", "src/A.cs", "src/A.cs")] + [Arguments("/repo", "src/A.cs", "src/A.cs")] + [Arguments("/repo", "/repo/src/./A.cs", "src/A.cs")] + [Arguments("c:/repo", "c:\\repo\\src\\A.cs", "src/A.cs")] + [Arguments("c:/repo", "src/A.cs", "src/A.cs")] + [Arguments("/repo", "A", "A")] + public async Task EquivalentSourceConventions_Match(string? root, string metricsFile, string coverageFile) + { + var doc = Cobertura.NewDoc().AddClass(coverageFile, "App.A", c => c.Method("M", "()", null, m => m.Line(1, 1))); + if (root is not null) doc.WithSource(root); + using var input = new MemoryStream(doc.ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M()", 2, 1)) + .Select(m => m with { File = metricsFile }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task SourceBearingPropertyFallback_StaysWithinItsFile(bool rightFile) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass(rightFile ? "src/A.cs" : "other/A.cs", "App.A", c => c + .Method("get_P", "()", null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Property("int A.P", 2)) + .Select(m => m with { File = "src/A.cs" }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(rightFile ? GateOutcome.Pass : GateOutcome.NoData); + } + + [Test] + public async Task KnownSourceWithDifferentMember_CannotProvidePropertyFallback() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("get_Missing", "()", null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M()", 2, 1)) + .Select(m => m with { File = "src/A.cs" }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task LegacyGenericMetrics_OnlyRecoverUnambiguousTypeIdentity(bool ambiguous) + { + var doc = Cobertura.NewDoc().AddClass("src/A.cs", "App.Result`1", c => c.Method("M", "()", null, m => m.Line(1, 1))); + if (ambiguous) doc.AddClass("src/B.cs", "App.Result", c => c.Method("M", "()", null, m => m.Line(2, 1))); + using var input = new MemoryStream(doc.ToBytes()); + CodeMetricsMember[] metrics = [new("App.Result", "M", CodeMetricsMemberKind.Method, 0, 2, "void Result.M()")]; + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.Unscored.Count).IsEqualTo(ambiguous ? 1 : 0); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(ambiguous ? GateOutcome.NoData : GateOutcome.Pass); + } +} diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs index da02a01..4200906 100644 --- a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -10,11 +10,11 @@ public sealed class CrapSignatureMatchingTests [Arguments("int? x", "System.Nullable`1")] [Arguments("decimal?[] x", "System.Nullable`1[]")] [Arguments("ref long? x", "System.Nullable`1&")] - [Arguments("List x", "List>")] + [Arguments("List x", "System.Collections.Generic.List`1>")] [Arguments("int x = 1", "System.Int32")] [Arguments("char x = ','", "System.Char")] [Arguments("string x = \"a,(b)=c\"", "System.String")] - [Arguments("List x", "List")] + [Arguments("List x", "System.Collections.Generic.List`1")] [Arguments("(int,string) x", "(System.Int32,System.String)")] [Arguments("int[,] x", "System.Int32[,]")] [Arguments("params int[] x", "System.Int32[]")] From fa5fb353162ae071d3fd0e8d630325d1506f1f55 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:44:43 +0200 Subject: [PATCH 26/42] test: cover scoped metrics fallback and legacy orphan identities --- .../DotCov.Tests/CrapMetricsIdentityTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs index d5f448c..6804958 100644 --- a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -4,6 +4,46 @@ namespace DotCov.Tests; public sealed class CrapMetricsIdentityTests { + [Test] + [Arguments("App.A/d__0", "MoveNext", "()", true)] + [Arguments("App.A", "M", "()", false)] + [Arguments("App.A", "M", "unknown", true)] + public async Task SourceScopedCandidates_HandleMissingOriginArity(string type, string method, + string signature, bool matched) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", type, c => c + .Method(method, signature, null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M(int x)", 2, 1)) + .Select(m => m with { File = "src/A.cs" }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(matched ? 0 : 1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(matched ? GateOutcome.Pass : GateOutcome.NoData); + } + + [Test] + public async Task LegacyMetricsWithoutADeclaringType_StayUnmatched() + { + CodeMetricsMember[] metrics = [new("App.Absent", "M", CodeMetricsMemberKind.Method, 0, 2, "void Absent.M()")]; + var report = CrapAnalysis.Analyze(Array.Empty(), metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [Test] + public async Task SourceScopedAndSourceLessOverloads_CombineWithoutLosingIdentity() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(System.Int32)", null, m => m.Line(1, 1)) + .Method("M", "(System.Boolean)", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(int x)", 2, 1).Method("void A.M(bool x)", 3, 1)) + .Select((m, i) => m with { File = i == 0 ? "src/A.cs" : null }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([2, 3]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + [Test] [Arguments("One.List`1", "Two.List`1")] [Arguments("List`1", "Two.List`1")] From 4b2472cf4dc6b889f06fcc6c0db0d6cd6a567a85 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:47:37 +0200 Subject: [PATCH 27/42] ci: print uncovered branch locations when the coverage gate fails --- .github/workflows/nuget-publish.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index d6fd8d1..c1aa359 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -103,6 +103,20 @@ jobs: run: | dotnet tool restore dotnet fallout Coverage --reports TestResults + - name: Locate uncovered branches after a failed coverage gate + if: failure() && matrix.os == 'ubuntu-latest' + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + for report in Path('TestResults').glob('**/*cobertura*.xml'): + for source in ET.parse(report).findall('.//class'): + for line in source.findall('./lines/line'): + coverage = line.get('condition-coverage', '') + if line.get('branch') == 'true' and not coverage.startswith('100%'): + print(f"{source.get('filename')}:{line.get('number')}: {coverage}") + PY # Exercise the composite action and its default installer against this checkout's portable # package. A release candidate is not on NuGet yet. Native packages are packed separately; From 8fcb4de597d43cf32802b26c8d49a622064be3df Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:50:36 +0200 Subject: [PATCH 28/42] ci: recognize Coverlet branch diagnostics independently of boolean casing --- .github/workflows/nuget-publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index c1aa359..23b82f5 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -111,10 +111,11 @@ jobs: import xml.etree.ElementTree as ET for report in Path('TestResults').glob('**/*cobertura*.xml'): + print(f"Coverage diagnostics: {report}") for source in ET.parse(report).findall('.//class'): for line in source.findall('./lines/line'): coverage = line.get('condition-coverage', '') - if line.get('branch') == 'true' and not coverage.startswith('100%'): + if coverage and not coverage.startswith('100%'): print(f"{source.get('filename')}:{line.get('number')}: {coverage}") PY From 5c33f2099797728a94fbbf62f9e59ae25502770b Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 13:53:21 +0200 Subject: [PATCH 29/42] test: cover explicit empty roots and orphan legacy properties --- tests/DotCov.Tests/CrapMetricsIdentityTests.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs index 6804958..787d83d 100644 --- a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -23,12 +23,28 @@ public async Task SourceScopedCandidates_HandleMissingOriginArity(string type, s [Test] public async Task LegacyMetricsWithoutADeclaringType_StayUnmatched() { - CodeMetricsMember[] metrics = [new("App.Absent", "M", CodeMetricsMemberKind.Method, 0, 2, "void Absent.M()")]; + CodeMetricsMember[] metrics = + [ + new("App.Absent", "M", CodeMetricsMemberKind.Method, 0, 2, "void Absent.M()"), + new("App.Absent", "P", CodeMetricsMemberKind.Property, null, 2, "int Absent.P") + ]; var report = CrapAnalysis.Analyze(Array.Empty(), metrics); await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(1); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + [Test] + public async Task ExplicitEmptySourceRoot_LeavesRelativeMetricsPathsUnchanged() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "()", null, m => m.Line(1, 1))).ToBytes()); + var parsed = CoberturaParser.ParseMethods(input); + var coverage = new MethodCoverageReport(parsed.Methods, [], [""]); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M()", 2, 1)) + .Select(m => m with { File = "src/A.cs" }).ToArray(); + await Assert.That(CrapAnalysis.Analyze(coverage, metrics).Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + [Test] public async Task SourceScopedAndSourceLessOverloads_CombineWithoutLosingIdentity() { From d17e239667baebe2d4970b1dce83f1af5af08814 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:08:24 +0200 Subject: [PATCH 30/42] fix: normalize real metrics type signatures and rooted exclusions --- docs/releases/3.0.1.md | 5 + src/DotCov.Tool/DotCovCli.cs | 3 +- src/DotCov/CrapAnalysis.cs | 22 ++-- src/DotCov/MethodSignatures.cs | 118 ++++++++++++++++-- .../DotCov.Tests/CrapMetricsIdentityTests.cs | 29 +++++ .../CrapSignatureMatchingTests.cs | 3 +- tests/DotCov.Tests/CrapTupleAndRootTests.cs | 92 ++++++++++++++ 7 files changed, 251 insertions(+), 21 deletions(-) create mode 100644 tests/DotCov.Tests/CrapTupleAndRootTests.cs diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 4cda8b0..08ff6f0 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -12,6 +12,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. Known source files and conflicting parameter signatures are respected even for a single candidate. Relative metrics paths use a single declared coverage source root; legacy hand-built metrics recover generic type identities only when the coverage scope is unambiguous. + Unqualified custom parameter types are resolved only when unambiguous. Tuple parameters + match their runtime `ValueTuple` forms, including names, nesting, nullable tuples, and long tuples. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are not accepted as coverage matches. Extension-block displays do not identify static versus @@ -44,6 +46,9 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. also avoid candidate scans and repeated string parsing. Enclosing type identities are cached across members, and metrics named-type nesting is bounded to 256 levels to limit resource amplification from deeply nested input. + Type-name matching runs in linear time; coverage signatures and metrics member + displays longer than 16,384 characters are rejected before signature analysis. + Tuple nesting is limited to 64 levels. - **Patched Fallout dependencies.** The Fallout integration constrains its transitive `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies to 10.0.13, addressing the security advisories published on October 13, 2026. diff --git a/src/DotCov.Tool/DotCovCli.cs b/src/DotCov.Tool/DotCovCli.cs index 195cd76..7a7a1ae 100644 --- a/src/DotCov.Tool/DotCovCli.cs +++ b/src/DotCov.Tool/DotCovCli.cs @@ -62,7 +62,8 @@ public static async Task RunAsync(string[] args, TextWriter stdout, TextWri } static bool IsExpectedFailure(Exception ex) => - ex is UsageError or CliError or UndeclaredSourceRootException or XmlException or IOException or UnauthorizedAccessException; + ex is UsageError or CliError or UndeclaredSourceRootException or XmlException or IOException or + InvalidDataException or UnauthorizedAccessException; // Not async: each arm hands back its command's Task, so a synchronous command's exception // still surfaces inside RunAsync's try, and the switch stays one branch per command. diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 78ee4c7..1a0d761 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -206,8 +206,9 @@ internal static double DisplayScore(double score, double maxCrap) => public static CrapReport Analyze(MethodCoverageReport report, IReadOnlyList? metricsMembers = null) { ArgumentNullException.ThrowIfNull(report); - var (selectedMetrics, exclusion) = report.SelectMetrics(metricsMembers); - var analysis = AnalyzeCore(report.Methods, selectedMetrics, report.ExcludedMethods, report.SourceRoots); + var normalizedMetrics = metricsMembers?.Select(m => m with { File = MetricsFile(m.File, report.SourceRoots) }).ToArray(); + var (selectedMetrics, exclusion) = report.SelectMetrics(normalizedMetrics); + var analysis = AnalyzeCore(report.Methods, selectedMetrics, report.ExcludedMethods); return new CrapReport(analysis.Methods, analysis.Unscored, analysis.UnmatchedMetricsMembers) { AmbiguousFiles = report.AmbiguousFiles, @@ -230,25 +231,25 @@ public static CrapReport Analyze(MethodCoverageReport report, IReadOnlyList methods, IReadOnlyList? metricsMembers = null) => - AnalyzeCore(methods, metricsMembers, [], []); + AnalyzeCore(methods, metricsMembers, []); private static CrapReport AnalyzeCore( IReadOnlyList methods, IReadOnlyList? metricsMembers, - IReadOnlyList excludedMethods, IReadOnlyList sourceRoots) + IReadOnlyList excludedMethods) { var order = FoldRawEntries(methods); // 1. raw coverage → logical source methods FoldSyntheticGroups(order); // 2. state machines/lambdas → their origin var excluded = FoldRawEntries(excludedMethods); FoldSyntheticGroups(excluded); - metricsMembers = PrepareMetrics(metricsMembers, order.Concat(excluded), sourceRoots); + metricsMembers = PrepareMetrics(metricsMembers, order.Concat(excluded)); var metricSignatures = metricsMembers?.Select(static m => MethodSignatures.Metrics(m.DisplayName)).ToArray() ?? []; - var qualifications = MethodSignatures.GenericQualifications(order.Concat(excluded).Select(static m => m.Signature), + var qualifications = MethodSignatures.TypeQualifications(order.Concat(excluded).Select(static m => m.Signature), metricSignatures); foreach (var logical in order.Concat(excluded)) - logical.Signature = MethodSignatures.QualifyGenerics(logical.Signature, qualifications); - var signatures = metricSignatures.Select(s => MethodSignatures.QualifyGenerics(s, qualifications, fromMetrics: true)).ToArray(); + logical.Signature = MethodSignatures.QualifyTypes(logical.Signature, qualifications); + var signatures = metricSignatures.Select(s => MethodSignatures.QualifyTypes(s, qualifications, fromMetrics: true)).ToArray(); var consumed = new HashSet(); // Exclusions remove methods from the measurement scope, not from the metrics file. // Match their identities without scoring them, even when they have no line data. @@ -312,8 +313,7 @@ private static void MatchExcludedMetrics( } private static IReadOnlyList? PrepareMetrics( - IReadOnlyList? members, IEnumerable methods, - IReadOnlyList roots) + IReadOnlyList? members, IEnumerable methods) { if (members is null) return null; // Legacy callers supplied the generic-stripped TypeName. Recover arity only when @@ -324,7 +324,7 @@ private static void MatchExcludedMetrics( { TypeIdentity = m.TypeIdentity ?? (types.TryGetValue(m.TypeName, out var identities) && identities.Length == 1 ? identities[0] : null), - File = MetricsFile(m.File, roots) + File = MetricsFile(m.File, []) }).ToArray(); } diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 3c537c4..36e82a5 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Text; namespace DotCov; @@ -25,10 +26,13 @@ internal static partial class MethodSignatures [GeneratedRegex(@"`[0-9]+(?=<)", RegexOptions.CultureInvariant)] private static partial Regex ConstructedArity(); - [GeneratedRegex(@"[\p{L}_][\w.]*(?=<)", RegexOptions.CultureInvariant)] - private static partial Regex GenericNames(); + private const int MaxSignatureLength = 16_384; + // Tokenize with the linear engine; no delimiter lookahead can force retries + // over every suffix of a long, untrusted identifier. + private static readonly Regex TypeNamePattern = new(@"[\p{L}_][\w.]*", + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking); - internal static Dictionary GenericQualifications(IEnumerable coverage, + internal static Dictionary TypeQualifications(IEnumerable coverage, IEnumerable metrics) { var names = new Dictionary(StringComparer.Ordinal); @@ -39,7 +43,7 @@ internal static partial class MethodSignatures void AddNames(string? signature, bool includeUnqualified) { if (signature is null) return; - foreach (Match match in GenericNames().Matches(signature)) + foreach (Match match in TypeNamePattern.Matches(signature)) { var name = match.Value; if (!includeUnqualified && !name.Contains('.')) continue; @@ -50,23 +54,27 @@ void AddNames(string? signature, bool includeUnqualified) } } - internal static string? QualifyGenerics(string? signature, Dictionary names, + internal static string? QualifyTypes(string? signature, Dictionary names, bool fromMetrics = false) { if (signature is null) return null; - if (fromMetrics && GenericNames().Matches(signature).Any(match => + if (fromMetrics && TypeNamePattern.Matches(signature).Any(match => names.TryGetValue(match.Value, out var name) && name is null)) return null; - return GenericNames().Replace(signature, match => names.GetValueOrDefault(match.Value) ?? match.Value); + return TypeNamePattern.Replace(signature, match => (names.GetValueOrDefault(match.Value) ?? match.Value)); } internal static string? Coverage(string signature) { + if (signature.Length > MaxSignatureLength) + throw new InvalidDataException("Coverage signature exceeds 16384 characters."); if (signature.Length < 2 || signature[0] is not '(' || signature[^1] is not ')') return null; return string.Join(",", BalancedText.SplitTopLevel(signature[1..^1]).Select(NormalizeType)); } internal static string? Metrics(string display) { + if (display.Length > MaxSignatureLength) + throw new InvalidDataException("Metrics member display exceeds 16384 characters."); string parameters; string? valueType = null; if (display.EndsWith(')')) @@ -104,6 +112,99 @@ void AddNames(string? signature, bool includeUnqualified) return string.Join(",", types); } + private sealed class TupleFrame(int angle, int array, int start, bool nullable) + { + public readonly int Angle = angle; + public readonly int Array = array; + public readonly bool Nullable = nullable; + public int ElementStart = start; + public int Items = 1; + public int RestDepth; + } + + private static string LowerTuples(string type) + { + var closes = new Dictionary(); + var opens = new Stack(); + for (var i = 0; i < type.Length; i++) + { + if (type[i] == '(') + { + if (opens.Count == 64) throw new InvalidDataException("Tuple nesting exceeds 64 levels."); + opens.Push(i); + } + else if (type[i] == ')') + { + if (!opens.TryPop(out var open)) throw new InvalidDataException("Unbalanced tuple type."); + closes.Add(open, i); + } + } + if (opens.Count != 0) throw new InvalidDataException("Unbalanced tuple type."); + var output = new StringBuilder(type.Length); + var frames = new Stack(); + var angle = 0; + var array = 0; + for (var i = 0; i < type.Length; i++) + { + var c = type[i]; + if (c == '(') + { + var close = closes[i]; + var nullable = close + 1 < type.Length && type[close + 1] == '?'; + if (nullable) output.Append("System.Nullable<"); + output.Append("System.ValueTuple<"); + frames.Push(new TupleFrame(angle, array, output.Length, nullable)); + } + else if (c == ')') + { + var frame = frames.Pop(); + TrimTupleName(output, frame.ElementStart); + output.Append('>', frame.RestDepth + 1); + if (frame.Nullable) + { + output.Append('>'); + i++; + } + } + else if (c == ',' && frames.TryPeek(out var frame) && frame.Angle == angle && frame.Array == array) + { + TrimTupleName(output, frame.ElementStart); + output.Append(','); + if (frame.Items++ % 7 == 0) + { + output.Append("System.ValueTuple<"); + frame.RestDepth++; + } + frame.ElementStart = output.Length; + } + else + { + output.Append(c); + switch (c) + { + case '<': angle++; break; + case '>': angle--; break; + case '[': array++; break; + case ']': array--; break; + } + } + } + return output.ToString(); + } + + private static void TrimTupleName(StringBuilder output, int start) + { + var end = output.Length; + while (end > start && char.IsWhiteSpace(output[end - 1])) end--; + if (end == start) throw new InvalidDataException("Tuple element is empty."); + var word = end; + while (word > start && (char.IsLetterOrDigit(output[word - 1]) || output[word - 1] is '_' or '@')) word--; + if (word == end || word == start || !char.IsWhiteSpace(output[word - 1])) return; + var prefix = word; + while (prefix > start && char.IsWhiteSpace(output[prefix - 1])) prefix--; + if (prefix > start) output.Length = prefix; + } + private static string NormalizeType(string type) { type = type.Trim().Replace("global::", "", StringComparison.Ordinal); @@ -112,8 +213,9 @@ private static string NormalizeType(string type) type.StartsWith("out ", StringComparison.Ordinal) || type.StartsWith("in ", StringComparison.Ordinal); if (byRef) type = type[(type.IndexOf(' ') + 1)..]; + type = LowerTuples(type); type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); - type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value[..^1]}>"); + type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value}>"); return ConstructedArity().Replace(type.Replace(" ", "", StringComparison.Ordinal), "") + (byRef ? "&" : ""); } } diff --git a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs index 787d83d..e09d72c 100644 --- a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -4,6 +4,35 @@ namespace DotCov.Tests; public sealed class CrapMetricsIdentityTests { + [Test] + public async Task LongNonGenericIdentifier_StaysWithinLinearSignatureProcessing() + { + var identifier = new string('A', 16_000); + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", $"({identifier})", "1", m => m.Line(1, 1))).ToBytes()); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input)); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task OversizedSignature_IsRejectedThroughCli(bool metricsTooLong) + { + using var ws = TempWorkspace.Create("dotcov-signature-limit-"); + var identifier = new string('A', 16_385); + var coverage = ws.Write("coverage.xml", Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", metricsTooLong ? "()" : $"({identifier})", "1", m => m.Line(1, 1))).ToBytes()); + var metrics = ws.Write("metrics.xml", CodeMetricsReportBuilder.Create() + .Namespace("App", t => t.Type("A", m => m.Method($"void A.M({identifier} value)", 2, 1))).ToBytes()); + var stdout = new StringWriter(); + var stderr = new StringWriter(); + string[] args = metricsTooLong ? ["crap", coverage, "--metrics", metrics] : ["crap", coverage]; + var exitCode = await DotCovCli.RunAsync(args, stdout, stderr); + await Assert.That(exitCode).IsEqualTo(2); + await Assert.That(stderr.ToString()).Contains("exceeds 16384 characters"); + } + [Test] [Arguments("App.A/d__0", "MoveNext", "()", true)] [Arguments("App.A", "M", "()", false)] diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs index 4200906..efbbeff 100644 --- a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -15,13 +15,14 @@ public sealed class CrapSignatureMatchingTests [Arguments("char x = ','", "System.Char")] [Arguments("string x = \"a,(b)=c\"", "System.String")] [Arguments("List x", "System.Collections.Generic.List`1")] - [Arguments("(int,string) x", "(System.Int32,System.String)")] + [Arguments("(int,string) x", "System.ValueTuple`2")] [Arguments("int[,] x", "System.Int32[,]")] [Arguments("params int[] x", "System.Int32[]")] [Arguments("ref int x", "System.Int32&")] [Arguments("out int x", "System.Int32&")] [Arguments("in int x", "System.Int32&")] [Arguments("global::App.Widget x", "App.Widget")] + [Arguments("Widget x", "App.Widget")] public async Task CompleteSameArityOverloads_MatchTypesAndKeepTheirOwnComplexity( string parameter, string ilType) { diff --git a/tests/DotCov.Tests/CrapTupleAndRootTests.cs b/tests/DotCov.Tests/CrapTupleAndRootTests.cs new file mode 100644 index 0000000..2609e10 --- /dev/null +++ b/tests/DotCov.Tests/CrapTupleAndRootTests.cs @@ -0,0 +1,92 @@ +using DotCov.Tests.Infrastructure; + +namespace DotCov.Tests; + +public sealed class CrapTupleAndRootTests +{ + [Test] + [Arguments("(int x, string y)", "System.ValueTuple`2")] + [Arguments("( int , string )", "System.ValueTuple`2")] + [Arguments("(int @number, string snake_case)", "System.ValueTuple`2")] + [Arguments("(App.Widget, string)", "System.ValueTuple`2")] + [Arguments("((int a,string b) inner,bool ok)", "System.ValueTuple`2,System.Boolean>")] + [Arguments("(int[,] grid,List names)[]", "System.ValueTuple`2>[]")] + [Arguments("(int x,string y)?", "System.Nullable`1>")] + [Arguments("Dictionary", "System.Collections.Generic.Dictionary`2>")] + [Arguments("(int,int,int,int,int,int,int,int)", "System.ValueTuple`8>")] + public async Task TupleParameters_MatchRuntimeMetadata(string source, string runtime) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", $"({runtime})", null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method($"void A.M({source} value)", 2, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments("left")] + [Arguments("right")] + [Arguments("deep")] + [Arguments("empty")] + public async Task InvalidTupleStructure_IsRejected(string shape) + { + var type = shape switch + { + "left" => "(int", + "right" => "int)", + "empty" => "( , int)", + _ => new string('(', 65) + "int" + string.Concat(Enumerable.Repeat(",int)", 65)) + }; + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", $"({type})", "1", m => m.Line(1, 1))).ToBytes()); + var parsed = CoberturaParser.ParseMethods(input); + var error = Assert.Throws(() => CrapAnalysis.Analyze(parsed)); + await Assert.That(error.Message).IsNotEmpty(); + } + + [Test] + public async Task TupleDepthBoundary_RemainsMeasurable() + { + var source = "int"; + var runtime = "System.Int32"; + for (var i = 0; i < 64; i++) + { + source = $"({source},int)"; + runtime = $"System.ValueTuple`2<{runtime},System.Int32>"; + } + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", $"({runtime})", null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method($"void A.M({source} value)", 2, 1)); + await Assert.That(CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics).Evaluate(30).Outcome) + .IsEqualTo(GateOutcome.Pass); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task RootedExclusion_AppliesToRelativeMetricsOnlyFiles(bool keep) + { + using var input = new MemoryStream(Cobertura.NewDoc().WithSource("/repo") + .AddClass("src/Kept.cs", "App.Kept", c => c.Method("M", "()", "1", m => m.Line(1, 1))).ToBytes()); + var report = CoberturaParser.ParseMethods(input).Exclude(["/repo/src/Generated.g.cs"], + keep ? ["/repo/src/Generated.g.cs"] : []); + var metrics = CodeMetricsReportBuilder.ParseType("App", "Generated", m => m.Method("void Generated.M()", 2, 1)) + .Select(m => m with { File = "src/Generated.g.cs" }).ToArray(); + var analysis = CrapAnalysis.Analyze(report, metrics); + await Assert.That(analysis.UnmatchedMetricsMembers.Count).IsEqualTo(keep ? 1 : 0); + await Assert.That(analysis.Evaluate(30).Outcome).IsEqualTo(keep ? GateOutcome.NoData : GateOutcome.Pass); + } + + [Test] + public async Task AmbiguousCustomTypeNames_StayUnresolved() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(One.Widget)", null, m => m.Line(1, 1)) + .Method("M", "(Two.Widget)", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(Widget value)", 2, 1).Method("void A.M(Two.Widget value)", 3, 1)); + await Assert.That(CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics).Evaluate(30).Outcome) + .IsEqualTo(GateOutcome.NoData); + } +} From 136e28510a792f3af3c588212791aac0f59a9387 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:09:52 +0200 Subject: [PATCH 31/42] style: use required constant patterns in tuple normalization --- src/DotCov/MethodSignatures.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 36e82a5..4da7865 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -139,7 +139,7 @@ private static string LowerTuples(string type) closes.Add(open, i); } } - if (opens.Count != 0) throw new InvalidDataException("Unbalanced tuple type."); + if (opens.Count is not 0) throw new InvalidDataException("Unbalanced tuple type."); var output = new StringBuilder(type.Length); var frames = new Stack(); var angle = 0; @@ -170,7 +170,7 @@ private static string LowerTuples(string type) { TrimTupleName(output, frame.ElementStart); output.Append(','); - if (frame.Items++ % 7 == 0) + if (frame.Items++ % 7 is 0) { output.Append("System.ValueTuple<"); frame.RestDepth++; From 831c764687e2f9e8a1a5afd68d3ee46ae8ceacaf Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:11:48 +0200 Subject: [PATCH 32/42] test: use CLI error contract for oversized signatures --- tests/DotCov.Tests/CrapMetricsIdentityTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs index e09d72c..ef04fb7 100644 --- a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -1,4 +1,5 @@ using DotCov.Tests.Infrastructure; +using DotCov.Tool; namespace DotCov.Tests; @@ -29,7 +30,7 @@ public async Task OversizedSignature_IsRejectedThroughCli(bool metricsTooLong) var stderr = new StringWriter(); string[] args = metricsTooLong ? ["crap", coverage, "--metrics", metrics] : ["crap", coverage]; var exitCode = await DotCovCli.RunAsync(args, stdout, stderr); - await Assert.That(exitCode).IsEqualTo(2); + await Assert.That(exitCode).IsEqualTo(DotCovCli.ErrorExitCode); await Assert.That(stderr.ToString()).Contains("exceeds 16384 characters"); } From 22cc490acd5ecea634c7c45b91e6c082079e5ac1 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:13:48 +0200 Subject: [PATCH 33/42] fix: preserve nullable suffix removal during signature normalization --- src/DotCov/MethodSignatures.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 4da7865..b951ce7 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -215,7 +215,7 @@ private static string NormalizeType(string type) if (byRef) type = type[(type.IndexOf(' ') + 1)..]; type = LowerTuples(type); type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); - type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value}>"); + type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value[..^1]}>"); return ConstructedArity().Replace(type.Replace(" ", "", StringComparison.Ordinal), "") + (byRef ? "&" : ""); } } From a47762c352a99433a00e698aadca631f5c8b63d4 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:24:49 +0200 Subject: [PATCH 34/42] fix: preserve reference identities and input context in metrics matching --- docs/releases/3.0.1.md | 3 ++ src/DotCov/CoberturaParser.Methods.cs | 4 +++ src/DotCov/CoberturaParser.cs | 3 ++ src/DotCov/CodeMetrics.cs | 12 ++++++-- src/DotCov/MethodSignatures.cs | 28 +++++++++++++++---- .../DotCov.Tests/CrapMetricsIdentityTests.cs | 2 ++ .../CrapSignatureMatchingTests.cs | 11 ++++++++ 7 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 08ff6f0..7d1acbc 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -14,6 +14,8 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. hand-built metrics recover generic type identities only when the coverage scope is unambiguous. Unqualified custom parameter types are resolved only when unambiguous. Tuple parameters match their runtime `ValueTuple` forms, including names, nesting, nullable tuples, and long tuples. + Nested-type separators and partially qualified nested names are reconciled. Nullable + annotations on strings, objects, and arrays are erased without erasing nullable value types. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are not accepted as coverage matches. Extension-block displays do not identify static versus @@ -48,6 +50,7 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. resource amplification from deeply nested input. Type-name matching runs in linear time; coverage signatures and metrics member displays longer than 16,384 characters are rejected before signature analysis. + Oversized XML members report the input path and member context. Tuple nesting is limited to 64 levels. - **Patched Fallout dependencies.** The Fallout integration constrains its transitive `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies diff --git a/src/DotCov/CoberturaParser.Methods.cs b/src/DotCov/CoberturaParser.Methods.cs index d484b7d..b9f1ea5 100644 --- a/src/DotCov/CoberturaParser.Methods.cs +++ b/src/DotCov/CoberturaParser.Methods.cs @@ -61,6 +61,10 @@ public static MethodCoverageReport ParseMethods( { throw new ReportParseException(input.SourceName, ex); } + catch (InvalidDataException ex) + { + throw new InvalidDataException($"{input.SourceName}: {ex.Message}", ex); + } } scope.RequireDeclaredRoot(); diff --git a/src/DotCov/CoberturaParser.cs b/src/DotCov/CoberturaParser.cs index e78de13..259017e 100644 --- a/src/DotCov/CoberturaParser.cs +++ b/src/DotCov/CoberturaParser.cs @@ -382,6 +382,9 @@ public void Visit(XmlReader sub) private void BeginMethod(XmlReader sub) { + if (methods is not null) + MethodSignatures.ValidateLength(sub.GetAttribute("signature") ?? "", + $"Coverage signature for {className}.{sub.GetAttribute("name")} in {file}"); _methodAcc = methods?.For( new MethodKey(file, className, sub.GetAttribute("name") ?? "", sub.GetAttribute("signature") ?? ""), DecodeComplexity(sub.GetAttribute("complexity"))); diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index b394a27..2466d9f 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -114,6 +114,10 @@ public static IReadOnlyList ParseFile(string path, long maxCh { throw new XmlException($"{path}: {ex.Message}", ex, ex.LineNumber, ex.LinePosition); } + catch (InvalidDataException ex) + { + throw new InvalidDataException($"{path}: {ex.Message}", ex); + } } private sealed class PendingMember(string display, string element, string? file) @@ -212,8 +216,12 @@ private static void OpenMember(XmlReader reader, ParserState state) // A member outside a NamedType is not ours; an empty member element carries no // Metrics — nothing to record. if (state.TypeChain.Count > 0 && !reader.IsEmptyElement) - state.MemberStack.Push(new PendingMember(reader.GetAttribute("Name") ?? "", reader.LocalName, - reader.GetAttribute("File"))); + { + var display = reader.GetAttribute("Name") ?? ""; + MethodSignatures.ValidateLength(display, + $"Metrics member display '{display[..Math.Min(display.Length, 200)]}'"); + state.MemberStack.Push(new PendingMember(display, reader.LocalName, reader.GetAttribute("File"))); + } } private static void CloseMember(ParserState state) diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index b951ce7..59340e3 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -23,6 +23,9 @@ internal static partial class MethodSignatures [GeneratedRegex(@"\bSystem\.(Boolean|Byte|SByte|Int16|UInt16|Int32|UInt32|Int64|UInt64|Char|Single|Double|Decimal|IntPtr|UIntPtr)\?", RegexOptions.CultureInvariant)] private static partial Regex NullableValueTypes(); + [GeneratedRegex(@"(System\.(?:String|Object)|\])\?", RegexOptions.CultureInvariant)] + private static partial Regex NullableReferences(); + [GeneratedRegex(@"`[0-9]+(?=<)", RegexOptions.CultureInvariant)] private static partial Regex ConstructedArity(); @@ -49,7 +52,8 @@ void AddNames(string? signature, bool includeUnqualified) if (!includeUnqualified && !name.Contains('.')) continue; var simple = name[(name.LastIndexOf('.') + 1)..]; if (!names.TryGetValue(simple, out var prior)) names.Add(simple, name); - else if (prior != name) names[simple] = null; + else if (prior != name && (includeUnqualified || prior is null || + !prior.EndsWith("." + name, StringComparison.Ordinal))) names[simple] = null; } } } @@ -60,21 +64,31 @@ void AddNames(string? signature, bool includeUnqualified) if (signature is null) return null; if (fromMetrics && TypeNamePattern.Matches(signature).Any(match => names.TryGetValue(match.Value, out var name) && name is null)) return null; - return TypeNamePattern.Replace(signature, match => (names.GetValueOrDefault(match.Value) ?? match.Value)); + return TypeNamePattern.Replace(signature, match => + { + var value = match.Value; + var full = names.GetValueOrDefault(value[(value.LastIndexOf('.') + 1)..]); + return full is not null && (full == value || full.EndsWith("." + value, StringComparison.Ordinal)) + ? full : value; + }); + } + + internal static void ValidateLength(string text, string identity) + { + if (text.Length > MaxSignatureLength) + throw new InvalidDataException($"{identity} exceeds 16384 characters."); } internal static string? Coverage(string signature) { - if (signature.Length > MaxSignatureLength) - throw new InvalidDataException("Coverage signature exceeds 16384 characters."); + ValidateLength(signature, "Coverage signature"); if (signature.Length < 2 || signature[0] is not '(' || signature[^1] is not ')') return null; return string.Join(",", BalancedText.SplitTopLevel(signature[1..^1]).Select(NormalizeType)); } internal static string? Metrics(string display) { - if (display.Length > MaxSignatureLength) - throw new InvalidDataException("Metrics member display exceeds 16384 characters."); + ValidateLength(display, "Metrics member display"); string parameters; string? valueType = null; if (display.EndsWith(')')) @@ -208,6 +222,7 @@ private static void TrimTupleName(StringBuilder output, int start) private static string NormalizeType(string type) { type = type.Trim().Replace("global::", "", StringComparison.Ordinal); + type = type.Replace('/', '.').Replace('+', '.'); if (type.StartsWith("params ", StringComparison.Ordinal)) type = type[7..]; var byRef = type.StartsWith("ref ", StringComparison.Ordinal) || type.StartsWith("out ", StringComparison.Ordinal) || @@ -216,6 +231,7 @@ private static string NormalizeType(string type) type = LowerTuples(type); type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value[..^1]}>"); + type = NullableReferences().Replace(type, "$1"); return ConstructedArity().Replace(type.Replace(" ", "", StringComparison.Ordinal), "") + (byRef ? "&" : ""); } } diff --git a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs index ef04fb7..4d07900 100644 --- a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -32,6 +32,8 @@ public async Task OversizedSignature_IsRejectedThroughCli(bool metricsTooLong) var exitCode = await DotCovCli.RunAsync(args, stdout, stderr); await Assert.That(exitCode).IsEqualTo(DotCovCli.ErrorExitCode); await Assert.That(stderr.ToString()).Contains("exceeds 16384 characters"); + await Assert.That(stderr.ToString()).Contains(metricsTooLong ? metrics : coverage); + await Assert.That(stderr.ToString()).Contains(metricsTooLong ? "A.M(" : "App.A.M"); } [Test] diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs index efbbeff..27b3612 100644 --- a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -23,6 +23,17 @@ public sealed class CrapSignatureMatchingTests [Arguments("in int x", "System.Int32&")] [Arguments("global::App.Widget x", "App.Widget")] [Arguments("Widget x", "App.Widget")] + [Arguments("string? x", "System.String")] + [Arguments("object? x", "System.Object")] + [Arguments("dynamic? x", "System.Object")] + [Arguments("string?[]? x", "System.String[]")] + [Arguments("int?[]? x", "System.Nullable`1[]")] + [Arguments("List x", "System.Collections.Generic.List`1")] + [Arguments("Widget[]? x", "App.Widget[]")] + [Arguments("Inner x", "App.Outer/Inner")] + [Arguments("Outer.Inner x", "App.Outer/Inner")] + [Arguments("App.Outer.Inner x", "App.Outer+Inner")] + [Arguments("Outer.Inner[] x", "App.Outer/Inner[]")] public async Task CompleteSameArityOverloads_MatchTypesAndKeepTheirOwnComplexity( string parameter, string ilType) { From ae837dfb214eb35219d6dbc4c8124aee443eb5bc Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:41:13 +0200 Subject: [PATCH 35/42] fix: reconcile nullable and file-local identities conservatively --- docs/releases/3.0.1.md | 6 ++ src/DotCov/CrapAnalysis.cs | 4 +- src/DotCov/MethodIdentity.cs | 11 ++- src/DotCov/MethodSignatures.cs | 55 +++++++++++++- .../CrapFileLocalAndConversionTests.cs | 71 +++++++++++++++++++ .../CrapSignatureMatchingTests.cs | 9 +++ 6 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 7d1acbc..9223ea5 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -16,6 +16,9 @@ Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. match their runtime `ValueTuple` forms, including names, nesting, nullable tuples, and long tuples. Nested-type separators and partially qualified nested names are reconciled. Nullable annotations on strings, objects, and arrays are erased without erasing nullable value types. + Other nullable annotations use observed runtime type identities, including nullable generic + references and custom nullable value types. `ref readonly` parameters preserve by-reference + identity, and file-local compiler names reconcile with metrics while retaining source-file scope. Ambiguous measured overload matches retain conservative complexity scores but cannot silently mark an unmeasured sibling as covered. Known parameter-count mismatches are not accepted as coverage matches. Extension-block displays do not identify static versus @@ -60,6 +63,9 @@ The library adds nullable `CodeMetricsMember.ExecutableLines`, `File`, and `Type analysis signatures remain available. CLI flags, exit codes, and threshold rules are unchanged from 3.0.0. +Conversion operators that differ only by return type remain `NODATA` when Cobertura +omits that target identity. Repeated entries cannot prove that both conversions were measured. + ## Upgrade ```bash diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 1a0d761..8a15536 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -249,7 +249,9 @@ private static CrapReport AnalyzeCore( metricSignatures); foreach (var logical in order.Concat(excluded)) logical.Signature = MethodSignatures.QualifyTypes(logical.Signature, qualifications); - var signatures = metricSignatures.Select(s => MethodSignatures.QualifyTypes(s, qualifications, fromMetrics: true)).ToArray(); + var nullableIdentities = new MethodSignatures.NullableIdentities(order.Concat(excluded).Select(m => m.Signature)); + var signatures = metricSignatures.Select(s => MethodSignatures.ResolveNullable( + MethodSignatures.QualifyTypes(s, qualifications, fromMetrics: true), nullableIdentities)).ToArray(); var consumed = new HashSet(); // Exclusions remove methods from the measurement scope, not from the metrics file. // Match their identities without scoring them, even when they have no line data. diff --git a/src/DotCov/MethodIdentity.cs b/src/DotCov/MethodIdentity.cs index 062041d..c3e5be6 100644 --- a/src/DotCov/MethodIdentity.cs +++ b/src/DotCov/MethodIdentity.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace DotCov; /// @@ -30,6 +32,13 @@ namespace DotCov; /// internal static class MethodIdentity { + private static readonly Regex FileTypePrefix = new(@"<[A-Za-z0-9_]*>F[0-9A-F]{64}__", + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking); + + // The source file remains part of MethodName, so two file-local Worker types + // never become one logical method when their compiler prefixes are removed. + internal static string SourceTypeName(string name) => FileTypePrefix.Replace(name, ""); + /// /// Normalize an IL-level identity to its source-method identity. Returns false for /// pure compiler infrastructure with no source method to fold into — display-class @@ -55,7 +64,7 @@ internal static bool TryNormalize( methodKey = ""; origin = null; - var chain = ScanClassChain(className); + var chain = ScanClassChain(SourceTypeName(className)); if (chain.TypeSegments.Count is 0) return false; typeIdentity = string.Join(".", chain.TypeSegments); diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 59340e3..4facd03 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -26,6 +26,9 @@ internal static partial class MethodSignatures [GeneratedRegex(@"(System\.(?:String|Object)|\])\?", RegexOptions.CultureInvariant)] private static partial Regex NullableReferences(); + [GeneratedRegex(@"System\.Nullable<([\p{L}_][\w.]*)", RegexOptions.CultureInvariant)] + private static partial Regex NullableTypeNames(); + [GeneratedRegex(@"`[0-9]+(?=<)", RegexOptions.CultureInvariant)] private static partial Regex ConstructedArity(); @@ -79,6 +82,55 @@ internal static void ValidateLength(string text, string identity) throw new InvalidDataException($"{identity} exceeds 16384 characters."); } + internal sealed class NullableIdentities(IEnumerable coverage) + { + public readonly HashSet Observed = coverage.Where(s => s is not null) + .SelectMany(s => TypeNamePattern.Matches(s!).Select(m => m.Value)).ToHashSet(StringComparer.Ordinal); + public readonly HashSet Values = coverage.Where(s => s is not null) + .SelectMany(s => NullableTypeNames().Matches(s!).Select(m => m.Groups[1].Value)).ToHashSet(StringComparer.Ordinal); + } + + internal static string? ResolveNullable(string? signature, NullableIdentities identities) + { + if (signature is null || !signature.Contains('?')) return signature; + var parents = new Stack(); + var nullableStarts = new HashSet(); + var replacements = new Dictionary(); + var start = 0; + for (var i = 0; i < signature.Length; i++) + { + var c = signature[i]; + if (c == '?') + { + var name = start; + while (name < i && (char.IsLetterOrDigit(signature[name]) || signature[name] is '_' or '.')) name++; + var type = signature[start..name]; + if (identities.Values.Contains(type)) + { + if (!nullableStarts.Add(start)) return null; + replacements.Add(i, '>'); + } + else if (identities.Observed.Contains(type)) replacements.Add(i, '\0'); + continue; + } + if (c == '<') + { + parents.Push(start); + start = i + 1; + } + else if (c == '>' && !parents.TryPop(out start)) return null; + else if (c == ',') start = i + 1; + } + var output = new StringBuilder(signature.Length); + for (var i = 0; i < signature.Length; i++) + { + if (nullableStarts.Contains(i)) output.Append("System.Nullable<"); + var c = replacements.GetValueOrDefault(i, signature[i]); + if (c is not '\0') output.Append(c); + } + return output.ToString(); + } + internal static string? Coverage(string signature) { ValidateLength(signature, "Coverage signature"); @@ -221,13 +273,14 @@ private static void TrimTupleName(StringBuilder output, int start) private static string NormalizeType(string type) { - type = type.Trim().Replace("global::", "", StringComparison.Ordinal); + type = MethodIdentity.SourceTypeName(type.Trim().Replace("global::", "", StringComparison.Ordinal)); type = type.Replace('/', '.').Replace('+', '.'); if (type.StartsWith("params ", StringComparison.Ordinal)) type = type[7..]; var byRef = type.StartsWith("ref ", StringComparison.Ordinal) || type.StartsWith("out ", StringComparison.Ordinal) || type.StartsWith("in ", StringComparison.Ordinal); if (byRef) type = type[(type.IndexOf(' ') + 1)..]; + if (byRef && type.StartsWith("readonly ", StringComparison.Ordinal)) type = type[9..]; type = LowerTuples(type); type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value[..^1]}>"); diff --git a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs new file mode 100644 index 0000000..7b8bb6c --- /dev/null +++ b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs @@ -0,0 +1,71 @@ +using DotCov.Tests.Infrastructure; + +namespace DotCov.Tests; + +public sealed class CrapFileLocalAndConversionTests +{ + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task FileLocalTypes_StayScopedToTheirSourceFiles(bool missing) + { + var first = "App.F" + new string('A', 64) + "__Worker"; + var second = "App.F" + new string('B', 64) + "__Worker"; + var doc = Cobertura.NewDoc().AddClass("src/First.cs", first, c => c + .Method("M", $"({first})", null, m => m.Line(1, 1))); + if (!missing) doc.AddClass("src/Second.cs", second, c => c + .Method("M", $"({second})", null, m => m.Line(2, 1))); + using var input = new MemoryStream(doc.ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "Worker", m => m + .Method("void Worker.M(Worker value)", 2, 1).Method("void Worker.M(Worker value)", 3, 1)) + .Select((m, i) => m with { File = i is 0 ? "src/First.cs" : "src/Second.cs" }).ToArray(); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(missing ? 1 : 0); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(missing ? GateOutcome.NoData : GateOutcome.Pass); + } + + [Test] + [Arguments("Mystery? value")] + [Arguments("List>? value")] + [Arguments("? value")] + [Arguments("Point?? value")] + public async Task UnresolvableNullableSignatures_DoNotProveCoverage(string parameter) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(System.Nullable`1)", "1", m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method($"void A.M({parameter})", 2, 1)); + await Assert.That(CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics).Evaluate(30).Outcome) + .IsEqualTo(GateOutcome.NoData); + } + + [Test] + public async Task MissingNullableStructOverload_CannotPassUsingNonNullableCoverage() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(App.Point)", "1", m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(Point value)", 2, 1).Method("void A.M(Point? value)", 3, 1)); + await Assert.That(CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics).Evaluate(30).Outcome) + .IsEqualTo(GateOutcome.NoData); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ConversionTargetsAbsentFromCoverage_RemainAmbiguous(bool duplicateEntry) + { + var doc = Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("op_Implicit", "(App.A)", "2", m => m.Line(1, 1))); + // The same shape can be a second target conversion or a repeated/partitioned + // report of the first. Without return types, cardinality cannot distinguish them. + if (duplicateEntry) doc.AddClass("src/A.cs", "App.A", c => c + .Method("op_Implicit", "(App.A)", "3", m => m.Line(2, 1))); + using var input = new MemoryStream(doc.ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("A.implicit operator int(A value)", 2, 1) + .Method("A.implicit operator string(A value)", 3, 1)); + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } +} diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs index 27b3612..7879641 100644 --- a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -34,6 +34,15 @@ public sealed class CrapSignatureMatchingTests [Arguments("Outer.Inner x", "App.Outer/Inner")] [Arguments("App.Outer.Inner x", "App.Outer+Inner")] [Arguments("Outer.Inner[] x", "App.Outer/Inner[]")] + [Arguments("List? x", "System.Collections.Generic.List`1")] + [Arguments("List? x", "System.Collections.Generic.List`1")] + [Arguments("Widget? x", "App.Widget")] + [Arguments("Point? x", "System.Nullable`1")] + [Arguments("Box? x", "System.Nullable`1>")] + [Arguments("Dictionary?>? x", "System.Collections.Generic.Dictionary`2>")] + [Arguments("_Widget2? x", "App._Widget2")] + [Arguments("ref readonly int x", "System.Int32&")] + [Arguments("ref readonly Point? x", "System.Nullable`1&")] public async Task CompleteSameArityOverloads_MatchTypesAndKeepTheirOwnComplexity( string parameter, string ilType) { From 2f5f4f46ba927ccf890855b8791cdc72dc99595f Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:43:15 +0200 Subject: [PATCH 36/42] style: mark nullable identity selectors static --- src/DotCov/MethodSignatures.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 4facd03..1b25714 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -84,10 +84,10 @@ internal static void ValidateLength(string text, string identity) internal sealed class NullableIdentities(IEnumerable coverage) { - public readonly HashSet Observed = coverage.Where(s => s is not null) - .SelectMany(s => TypeNamePattern.Matches(s!).Select(m => m.Value)).ToHashSet(StringComparer.Ordinal); - public readonly HashSet Values = coverage.Where(s => s is not null) - .SelectMany(s => NullableTypeNames().Matches(s!).Select(m => m.Groups[1].Value)).ToHashSet(StringComparer.Ordinal); + public readonly HashSet Observed = coverage.Where(static s => s is not null) + .SelectMany(static s => TypeNamePattern.Matches(s!).Select(static m => m.Value)).ToHashSet(StringComparer.Ordinal); + public readonly HashSet Values = coverage.Where(static s => s is not null) + .SelectMany(static s => NullableTypeNames().Matches(s!).Select(static m => m.Groups[1].Value)).ToHashSet(StringComparer.Ordinal); } internal static string? ResolveNullable(string? signature, NullableIdentities identities) From 242aed90c0f4e8a7a99de051904f0ae44178a0eb Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 14:45:59 +0200 Subject: [PATCH 37/42] fix: keep malformed nullable signatures conflicting --- src/DotCov/MethodSignatures.cs | 4 ++-- tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 1b25714..2141043 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -107,7 +107,7 @@ internal sealed class NullableIdentities(IEnumerable coverage) var type = signature[start..name]; if (identities.Values.Contains(type)) { - if (!nullableStarts.Add(start)) return null; + if (!nullableStarts.Add(start)) return signature; replacements.Add(i, '>'); } else if (identities.Observed.Contains(type)) replacements.Add(i, '\0'); @@ -118,7 +118,7 @@ internal sealed class NullableIdentities(IEnumerable coverage) parents.Push(start); start = i + 1; } - else if (c == '>' && !parents.TryPop(out start)) return null; + else if (c == '>' && !parents.TryPop(out start)) return signature; else if (c == ',') start = i + 1; } var output = new StringBuilder(signature.Length); diff --git a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs index 7b8bb6c..36e2d2b 100644 --- a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs +++ b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs @@ -26,7 +26,7 @@ public async Task FileLocalTypes_StayScopedToTheirSourceFiles(bool missing) [Test] [Arguments("Mystery? value")] - [Arguments("List>? value")] + [Arguments("List>int Date: Wed, 14 Oct 2026 15:08:28 +0200 Subject: [PATCH 38/42] Close the last CRAP findings and run the dotcov test gate cases on Windows - A metrics complexity below 1 on an executable member is no measurement: the reader skips it like a non-integer value, so the method is unscored (NODATA) instead of scored 0 and passing. - Coverlet truncates a function-pointer parameter's signature at its last '(' and `crap` threw "Unbalanced tuple type". Signatures with an inner parenthesis and metrics `delegate*` parameters are now unknown, so such methods match by name and arity. - Escaped identifiers (`@int`) match their metadata type; a generic `T?` that is a value type in one method and a reference type in another is no longer forced to Nullable for the whole report. - GateAwareTableWiringTests use the shared FakeDotnet, so both `dotcov test` gate cases run on Windows. - 3.0.1 release notes rewritten around user-visible behaviour. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++- Directory.Build.props | 8 +- docs/releases/3.0.1.md | 103 +++++++----------- src/DotCov/CodeMetrics.cs | 10 +- src/DotCov/MethodSignatures.cs | 20 ++++ .../CrapFileLocalAndConversionTests.cs | 51 +++++++++ tests/DotCov.Tests/CrapMetricsScopeTests.cs | 30 +++++ .../DotCov.Tests/GateAwareTableWiringTests.cs | 35 +----- 8 files changed, 168 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb7f15..384bc8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -816,7 +816,17 @@ Verified: - Async bodies retain their own identity in either input order. Merged exclusion metadata lists only absent files. Indexed metrics matching avoids repeated candidate scans. - Patched Fallout cryptography dependencies to 10.0.13 without suppressing NuGet audit. -- Aligned package/action/README versions and added `docs/releases/3.0.1.md`. - -Validation: parser, analysis, CLI, and scale regressions are included. PR #79's final CI -and review results are the authoritative commit-specific validation record. +- Metrics complexity below 1 on an executable member is skipped like a non-integer value, + so its method is unscored (NODATA) instead of scored 0. +- Coverage signatures with a function pointer (Coverlet truncates them to `(System.Int32))`) + and metrics `delegate*` parameters are unknown signatures; `crap` no longer throws + `Unbalanced tuple type`. Escaped identifiers (`@int`) drop the escape after aliasing; + a generic `T?` that is a value type in one method and a reference type in another stays + unresolved instead of being forced to `Nullable` report-wide. +- `GateAwareTableWiringTests` use the shared `FakeDotnet`, so their two `dotcov test` cases + run on Windows too; one Linux/macOS-only test remains (Unix file modes). +- Aligned package/action/README versions and rewrote `docs/releases/3.0.1.md` around + user-visible behaviour. + +Validation: each fix has a regression test that fails before it; PR #79's CI is the +full-suite and 100/100 coverage record. diff --git a/Directory.Build.props b/Directory.Build.props index 2a65ad3..54695ec 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,10 +29,10 @@ -DotCov 3.0.1 restores the 3.0 complete-scoring and exclusion contracts. -CRAP returns NODATA when executable metrics members lack coverage. Explicitly non-executable declarations and uniquely matched excluded methods do not block a measured pass; ambiguous excluded overloads remain NODATA. -Async methods preserve their own body regardless of lambda input order, preventing score dilution into synchronous overloads. -Merged reports no longer list files as excluded when another input contributes their coverage. Exclusion identity retention uses a set lookup. +DotCov 3.0.1 fixes CRAP passes that 3.0.0 could not prove; line and branch gates are unchanged. +CRAP: an unmeasured metrics method, an ambiguous overload match or a complexity below 1 now gives NODATA (exit 3); async overloads keep their own body, so diluted scores rise; function-pointer parameters no longer abort crap; declarations without a body need no coverage. +Exclusions: excluded methods count as measured without a score and never hide a kept sibling; merged reports no longer list a re-added file as excluded. +DotCov.Fallout requires System.Security.Cryptography.Pkcs/Xml 10.0.13. Full notes: https://github.com/ANcpLua/dotcov/blob/main/docs/releases/$(VersionPrefix).md diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 9223ea5..8621fa5 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -1,70 +1,44 @@ # DotCov 3.0.1 -This patch restores the complete-scoring and exclusion contracts introduced in 3.0. -Some previously green CRAP gates now correctly return `NODATA` or `FAIL`. +A patch for the 3.0 CRAP gate and for exclusions. Line and branch gates are unchanged. Some CRAP +gates that passed on 3.0.0 now return `NODATA` (exit 3) or `FAIL` (exit 1); in each case 3.0.0 +reported a pass it could not prove. -- **Missing executable metrics members prevent a CRAP PASS.** A method or accessor in - the metrics file without a coverage counterpart makes the gate `NODATA` (exit `3`). - Measured scores and offender counts remain available in the report. - Complete overload sets match by parameter types when those identities are available, - including dynamic/object equivalence and relational/shift operator displays. - One metrics row cannot supply complexity to distinct methods in different files. - Known source files and conflicting parameter signatures are respected even for a single - candidate. Relative metrics paths use a single declared coverage source root; legacy - hand-built metrics recover generic type identities only when the coverage scope is unambiguous. - Unqualified custom parameter types are resolved only when unambiguous. Tuple parameters - match their runtime `ValueTuple` forms, including names, nesting, nullable tuples, and long tuples. - Nested-type separators and partially qualified nested names are reconciled. Nullable - annotations on strings, objects, and arrays are erased without erasing nullable value types. - Other nullable annotations use observed runtime type identities, including nullable generic - references and custom nullable value types. `ref readonly` parameters preserve by-reference - identity, and file-local compiler names reconcile with metrics while retaining source-file scope. - Ambiguous measured overload matches retain conservative complexity scores but cannot - silently mark an unmeasured sibling as covered. Known parameter-count mismatches are - not accepted as coverage matches. Extension-block displays do not identify static versus - instance members, so an inferred receiver parameter cannot establish a coverage match. - Indexer accessors retain their parameter counts, including the setter value, so overloads - with different numbers or types of indices can be matched independently. Nullable - built-in value types match their runtime `System.Nullable` identities. Generic type - arity is preserved, so `Result` and `Result` do not share a metrics bucket. Generic - extension blocks retain their enclosing emitted type identity. - Complete static/instance constructor pairs in one file reconcile their indistinguishable - Roslyn displays while retaining the conservative maximum complexity for both. -- **Exclusions and declarations are accounted for.** Methods intentionally removed by - file exclusions are matched for completeness without scoring them. A match must be - unique among executable candidates: declarations without bodies do not create false - ambiguity, while ambiguous executable overloads remain unmatched. - Metrics source paths apply these rules even when a file is absent from coverage. - `--keep` exemptions remain measured. Roslyn metrics with `ExecutableLines=0` do not - require coverage or supply executable complexity. Legacy `LinesOfCode` is used only when - `ExecutableLines` is absent; missing or invalid values do not waive - the completeness check. -- **Async overloads keep their own bodies in either input order.** A lambda appearing - before its async state-machine body no longer lets that body merge into a synchronous - overload and dilute its CRAP score. -- **Merged exclusion metadata reflects the resulting files.** A file contributed by - another report is no longer also listed as excluded. Applied rules and keep exemptions - remain available. Retaining excluded method identities uses a set lookup to avoid - quadratic scans on reports with many excluded files. Repeated excluded metrics identities - are matched once instead of rescanning their candidates for every raw signature. - Metrics candidates are summarized by parameter count, so repeated measured methods - also avoid candidate scans and repeated string parsing. Enclosing type identities are - cached across members, and metrics named-type nesting is bounded to 256 levels to limit - resource amplification from deeply nested input. - Type-name matching runs in linear time; coverage signatures and metrics member - displays longer than 16,384 characters are rejected before signature analysis. - Oversized XML members report the input path and member context. - Tuple nesting is limited to 64 levels. -- **Patched Fallout dependencies.** The Fallout integration constrains its transitive - `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` dependencies - to 10.0.13, addressing the security advisories published on October 13, 2026. +## CRAP gate -The library adds nullable `CodeMetricsMember.ExecutableLines`, `File`, and `TypeIdentity`; existing constructor and -analysis signatures remain available. CLI flags, exit codes, and threshold rules are -unchanged from 3.0.0. +- **Every metrics method must be measured.** A method or accessor in the `--metrics` file with no + coverage counterpart makes the gate `NODATA`. 3.0.0 listed it and still passed. +- **Each metrics row matches exactly one method.** Overloads match by parameter types, including + tuples, nullable value and reference types, generics, indexers, `ref readonly`, escaped + identifiers such as `@int`, and file-local types. A row that matches several methods stays + unmatched, so the gate says `NODATA` instead of lending one method's complexity to a sibling. +- **Declarations without a body need no coverage.** Members with `ExecutableLines=0` (interface + and abstract members) neither block a pass nor supply complexity. +- **A complexity below 1 is not a measurement.** A metrics row with `CyclomaticComplexity` 0 or + negative leaves its method unscored (`NODATA`); 3.0.0 scored it 0 and could pass. +- **Async overloads keep their own body.** When a lambda preceded its state machine in the + report, 3.0.0 could fold an async overload into a covered synchronous one and pass. CRAP + scores for such methods rise to their own values. +- **Function-pointer parameters no longer abort `crap`.** Coverlet truncates their signature; + 3.0.0 failed with `Unbalanced tuple type`. Such methods now match by name and parameter count. +- **Excluded files stay accounted for.** Methods removed by `--exclude-*` count as measured + without being scored, and an excluded overload never hides a kept sibling. +- Conversion operators that differ only in their return type stay `NODATA`: Cobertura omits the + return type, so their coverage cannot be told apart. -Conversion operators that differ only by return type remain `NODATA` when Cobertura -omits that target identity. Repeated entries cannot prove that both conversions were measured. +## Reports + +- A merged report no longer lists a file as excluded when another input brings it back. +- Large reports with many excluded files or large metrics files no longer scan quadratically. + Coverage signatures and metrics names longer than 16,384 characters are rejected as input + errors (exit 5); tuple nesting is limited to 64 levels and metrics type nesting to 256. + +## Packages + +- `DotCov.Fallout` requires `System.Security.Cryptography.Pkcs` and `System.Security.Cryptography.Xml` + 10.0.13, which address the advisories of October 13, 2026. +- `CodeMetricsMember` gains nullable `ExecutableLines`, `File` and `TypeIdentity`. CLI flags, exit + codes and threshold rules are unchanged from 3.0.0. ## Upgrade @@ -72,6 +46,5 @@ omits that target identity. Repeated entries cannot prove that both conversions dotnet tool update -g DotCov.Tool --version 3.0.1 ``` -Update the badge action to `ANcpLua/dotcov/.github/actions/badge@v3.0.1` and the `DotCov` -and `DotCov.Fallout` package references to `3.0.1`. The explicit thresholds and file -identity requirements from the [3.0 release notes](3.0.0.md) still apply. +Use `ANcpLua/dotcov/.github/actions/badge@v3.0.1` and `DotCov`/`DotCov.Fallout` 3.0.1. The 3.0 +threshold and file-identity rules in the [3.0.0 notes](3.0.0.md) still apply. diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 2466d9f..2f0ccba 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -229,10 +229,12 @@ private static void CloseMember(ParserState state) if (state.MemberStack.Count is 0) return; var pending = state.MemberStack.Pop(); // A member without a CyclomaticComplexity metric has nothing to contribute to a - // complexity table — skipped, not zeroed. - if (pending.Complexity is { } complexity) - state.Members.Add(Materialize(pending, - state.CachedType ??= DescribeType(state.Namespace, state.TypeChain), complexity)); + // complexity table — skipped, not zeroed. Cyclomatic complexity is at least 1 for + // anything executable, so a lower value is no measurement either; only an explicit + // zero-body declaration keeps it, to stay out of the completeness accounting. + if (pending.Complexity is not { } complexity) return; + var member = Materialize(pending, state.CachedType ??= DescribeType(state.Namespace, state.TypeChain), complexity); + if (complexity >= 1 || member.ExecutableLines is 0) state.Members.Add(member); } private static void RecordMetric(XmlReader reader, ParserState state) diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 2141043..6b2ccb1 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -88,6 +88,16 @@ internal sealed class NullableIdentities(IEnumerable coverage) .SelectMany(static s => TypeNamePattern.Matches(s!).Select(static m => m.Value)).ToHashSet(StringComparer.Ordinal); public readonly HashSet Values = coverage.Where(static s => s is not null) .SelectMany(static s => NullableTypeNames().Matches(s!).Select(static m => m.Groups[1].Value)).ToHashSet(StringComparer.Ordinal); + // Names some coverage signature carries without a Nullable wrapper. + public readonly HashSet Bare = coverage.Where(static s => s is not null) + .SelectMany(static s => BareNames(s!)).ToHashSet(StringComparer.Ordinal); + + private static IEnumerable BareNames(string signature) + { + var wrapped = NullableTypeNames().Matches(signature).Select(static m => m.Groups[1].Value).ToList(); + foreach (Match match in TypeNamePattern.Matches(signature)) + if (!wrapped.Remove(match.Value)) yield return match.Value; + } } internal static string? ResolveNullable(string? signature, NullableIdentities identities) @@ -107,6 +117,9 @@ internal sealed class NullableIdentities(IEnumerable coverage) var type = signature[start..name]; if (identities.Values.Contains(type)) { + // A generic parameter is a value type in one method and a reference type in + // another: the report-wide sets cannot tell which T this is. + if (!type.Contains('.') && identities.Bare.Contains(type)) return null; if (!nullableStarts.Add(start)) return signature; replacements.Add(i, '>'); } @@ -135,6 +148,9 @@ internal sealed class NullableIdentities(IEnumerable coverage) { ValidateLength(signature, "Coverage signature"); if (signature.Length < 2 || signature[0] is not '(' || signature[^1] is not ')') return null; + // IL spells tuples as ValueTuple`N, so an inner parenthesis is a function pointer, which + // Coverlet truncates at its last '(' ("(System.Int32))"): unknown, not a conflict. + if (signature.AsSpan(1, signature.Length - 2).IndexOfAny('(', ')') >= 0) return null; return string.Join(",", BalancedText.SplitTopLevel(signature[1..^1]).Select(NormalizeType)); } @@ -172,6 +188,8 @@ internal sealed class NullableIdentities(IEnumerable coverage) var declaration = equals < 0 ? parameter : parameter[..equals].TrimEnd(); var space = BalancedText.LastIndexOfTopLevel(declaration, ' '); if (space < 0) return null; + // Coverage never carries a comparable function-pointer spelling (see Coverage). + if (declaration.Contains("delegate*", StringComparison.Ordinal)) return null; types.Add(NormalizeType(declaration[..space])); } if (valueType is not null) types.Add(NormalizeType(valueType)); @@ -283,6 +301,8 @@ private static string NormalizeType(string type) if (byRef && type.StartsWith("readonly ", StringComparison.Ordinal)) type = type[9..]; type = LowerTuples(type); type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); + // @int names a type called int, never the alias: drop the escape once aliases are applied. + type = type.Replace("@", "", StringComparison.Ordinal); type = NullableValueTypes().Replace(type, static match => $"System.Nullable`1<{match.Value[..^1]}>"); type = NullableReferences().Replace(type, "$1"); return ConstructedArity().Replace(type.Replace(" ", "", StringComparison.Ordinal), "") + (byRef ? "&" : ""); diff --git a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs index 36e2d2b..f260732 100644 --- a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs +++ b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs @@ -68,4 +68,55 @@ public async Task ConversionTargetsAbsentFromCoverage_RemainAmbiguous(bool dupli await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(2); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + + [Test] + public async Task EscapedIdentifierTypes_MatchTheirMetadataIdentity() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(App.int)", null, m => m.Line(1, 1)) + .Method("M", "(System.Int32)", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.M(@int value)", 2, 1).Method("void A.M(int value)", 7, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([2, 7]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + // Coverlet cuts the signature at the last '(' of the IL name, so a function-pointer + // parameter arrives as "(System.Int32))"; Cecil's full spelling is the second shape. + [Test] + [Arguments("(System.Int32))", "delegate* callback")] + [Arguments("(method System.Void *(System.Int32))", "delegate* unmanaged[Cdecl] callback")] + public async Task FunctionPointerParameters_AreUnknownSignatures_NotConflicts(string signature, string parameter) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", signature, null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method($"void A.M({parameter})", 2, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + public async Task NullableGenericParameters_ResolvePerMethod_NotAcrossTheReport() + { + // A(T? value) where T : class and B(T? value) where T : struct share the name T. + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("A", "(T)", null, m => m.Line(1, 1)) + .Method("B", "(System.Nullable`1)", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.A(T? value)", 2, 1).Method("void A.B(T? value)", 7, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([2, 7]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } } diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 655e724..8826e7a 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -304,6 +304,36 @@ await Assert.That(report.Evaluate(30).Outcome) .IsEqualTo(expected is 0 ? GateOutcome.Pass : GateOutcome.NoData); } + [Test] + [Arguments("0", null)] + [Arguments("-3", null)] + [Arguments("0", 4)] + [Arguments("0", 0)] + public async Task NonPositiveComplexity_IsNotAMeasurement_AndCannotPass(string complexity, int? executableLines) + { + var lines = executableLines is { } n ? $"""""" : ""; + var xml = $""" + + + + {lines} + + + """; + using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + var metrics = CodeMetricsReader.Parse(input); + using var coverage = new MemoryStream(Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", null, m => m.Line(1, 1))).ToBytes()); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(coverage), metrics); + + // Only an explicit zero-body declaration keeps a complexity below 1. + await Assert.That(metrics.Count).IsEqualTo(executableLines is 0 ? 1 : 0); + await Assert.That(report.Methods).IsEmpty(); + await Assert.That(report.Unscored.Count).IsEqualTo(1); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + [Test] public async Task ExecutableLines_StayOnTheirOwnMember_AndDoNotEraseMeasuredBodies() { diff --git a/tests/DotCov.Tests/GateAwareTableWiringTests.cs b/tests/DotCov.Tests/GateAwareTableWiringTests.cs index 8b11011..6a5b723 100644 --- a/tests/DotCov.Tests/GateAwareTableWiringTests.cs +++ b/tests/DotCov.Tests/GateAwareTableWiringTests.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using DotCov.Tests.Infrastructure; -using TUnit.Core.Enums; namespace DotCov.Tests; @@ -53,13 +52,11 @@ public async Task FalloutTable_PassingGate_KeepsNormalRounding(CancellationToken } [Test] - [RunOn(OS.Linux | OS.MacOs)] public async Task DotcovTest_FailingLineGate_TotalReadsAsTheVerdict(CancellationToken cancellationToken) { - var report = _ws.Write("fixture/nearly-full.xml", NearlyFull()); - var dotnet = FakeDotnet(); + var dotnet = FakeDotnet.Write(_ws, _ws.Write("fixture/nearly-full.xml", NearlyFull())); - var run = await DotcovTest(cancellationToken, dotnet, report, "--min-line", "100"); + var run = await DotcovTest(cancellationToken, dotnet, "--min-line", "100"); await Assert.That(run.ExitCode).IsEqualTo(1).Because(run.Output); await Assert.That(TotalRow(run.StdOut)).Contains("19999/20000 99.9%"); @@ -68,13 +65,11 @@ public async Task DotcovTest_FailingLineGate_TotalReadsAsTheVerdict(Cancellation } [Test] - [RunOn(OS.Linux | OS.MacOs)] public async Task DotcovTest_PassingGate_KeepsNormalRounding(CancellationToken cancellationToken) { - var report = _ws.Write("fixture/nearly-full.xml", NearlyFull()); - var dotnet = FakeDotnet(); + var dotnet = FakeDotnet.Write(_ws, _ws.Write("fixture/nearly-full.xml", NearlyFull())); - var run = await DotcovTest(cancellationToken, dotnet, report, "--min-line", "99"); + var run = await DotcovTest(cancellationToken, dotnet, "--min-line", "99"); await Assert.That(run.ExitCode).IsEqualTo(0).Because(run.Output); await Assert.That(TotalRow(run.StdOut)).Contains("19999/20000 100.0%"); @@ -84,27 +79,8 @@ public async Task DotcovTest_PassingGate_KeepsNormalRounding(CancellationToken c private static string TotalRow(string output) => output.Split('\n').Single(static line => line.TrimStart().StartsWith("TOTAL", StringComparison.Ordinal)); - /// - /// A stand-in for dotnet that "runs the tests" by copying a prepared report into the - /// --results-directory dotcov passes, then exits 0; DOTNET_HOST_PATH makes dotcov start it. - /// - private string FakeDotnet() - { - var script = _ws.Write("fake-sdk/dotnet", """ - #!/bin/sh - while [ $# -gt 0 ]; do - if [ "$1" = "--results-directory" ]; then results="$2"; fi - shift - done - cp "$DOTCOV_FAKE_REPORT" "$results/fake.cobertura.xml" - """); - if (!OperatingSystem.IsWindows()) // the tests that use it run on Linux and macOS only - File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - return script; - } - private async Task DotcovTest( - CancellationToken cancellationToken, string dotnet, string report, params string[] args) + CancellationToken cancellationToken, string dotnet, params string[] args) { var directory = AppContext.BaseDirectory; var start = new ProcessStartInfo("dotnet") @@ -126,7 +102,6 @@ private async Task DotcovTest( start.Environment.Remove("GITHUB_STEP_SUMMARY"); start.Environment["NO_COLOR"] = "1"; start.Environment["DOTNET_HOST_PATH"] = dotnet; - start.Environment["DOTCOV_FAKE_REPORT"] = report; return await ChildProcess.RunAsync(start, cancellationToken); } From 11da5dc7e5e1579002f2795ea730f0b40ec094c8 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 15:16:39 +0200 Subject: [PATCH 39/42] Apply the complexity floor in analysis and treat IL parentheses as unknown The complexity rule lived in the XML reader, so a library caller passing a CodeMetricsMember with complexity 0 could still score 0 and pass. PrepareMetrics now drops a complexity below 1 unless the member is an explicit zero-body declaration, for every input, and the guards it made redundant go. InvalidTupleStructure_IsRejected pinned "(int))", the shape Coverlet emits for a function pointer, as an error. Cobertura signatures are unknown when they hold parentheses; malformed tuple syntax is still rejected in metrics displays. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++- src/DotCov/CodeMetrics.cs | 10 ++--- src/DotCov/CrapAnalysis.cs | 13 ++++--- tests/DotCov.Tests/CrapMetricsScopeTests.cs | 2 - tests/DotCov.Tests/CrapTupleAndRootTests.cs | 43 ++++++++++++++++----- 5 files changed, 49 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 384bc8d..ebbf4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -816,8 +816,11 @@ Verified: - Async bodies retain their own identity in either input order. Merged exclusion metadata lists only absent files. Indexed metrics matching avoids repeated candidate scans. - Patched Fallout cryptography dependencies to 10.0.13 without suppressing NuGet audit. -- Metrics complexity below 1 on an executable member is skipped like a non-integer value, - so its method is unscored (NODATA) instead of scored 0. +- CRAP analysis ignores a metrics complexity below 1 on an executable member (reader input + and library callers alike), so its method is unscored (NODATA) instead of scored 0. +- Parentheses in a Cobertura signature (only a truncated function pointer produces them) + make it unknown instead of an error; malformed tuple syntax stays an input error in + metrics displays, where source tuple syntax really occurs. - Coverage signatures with a function pointer (Coverlet truncates them to `(System.Int32))`) and metrics `delegate*` parameters are unknown signatures; `crap` no longer throws `Unbalanced tuple type`. Escaped identifiers (`@int`) drop the escape after aliasing; diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 2f0ccba..2466d9f 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -229,12 +229,10 @@ private static void CloseMember(ParserState state) if (state.MemberStack.Count is 0) return; var pending = state.MemberStack.Pop(); // A member without a CyclomaticComplexity metric has nothing to contribute to a - // complexity table — skipped, not zeroed. Cyclomatic complexity is at least 1 for - // anything executable, so a lower value is no measurement either; only an explicit - // zero-body declaration keeps it, to stay out of the completeness accounting. - if (pending.Complexity is not { } complexity) return; - var member = Materialize(pending, state.CachedType ??= DescribeType(state.Namespace, state.TypeChain), complexity); - if (complexity >= 1 || member.ExecutableLines is 0) state.Members.Add(member); + // complexity table — skipped, not zeroed. + if (pending.Complexity is { } complexity) + state.Members.Add(Materialize(pending, + state.CachedType ??= DescribeType(state.Namespace, state.TypeChain), complexity)); } private static void RecordMetric(XmlReader reader, ParserState state) diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 8a15536..7aceabe 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -322,7 +322,10 @@ private static void MatchExcludedMetrics( // the coverage scope contains exactly one possible declaring-type identity. var types = methods.GroupBy(static m => m.TypeKey).ToDictionary(static g => g.Key, static g => g.Select(m => m.Name.TypeIdentity).Distinct().Take(2).ToArray()); - return members.Select(m => m with + // Cyclomatic complexity is at least 1 for anything executable: a lower value measured + // nothing and must not score 0. Only an explicit zero-body declaration keeps it, to stay + // out of the completeness accounting. + return members.Where(static m => m.CyclomaticComplexity >= 1 || m.ExecutableLines is 0).Select(m => m with { TypeIdentity = m.TypeIdentity ?? (types.TryGetValue(m.TypeName, out var identities) && identities.Length == 1 ? identities[0] : null), @@ -589,13 +592,11 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe { case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: var key = $"{m.TypeIdentity ?? m.TypeName}|{m.MemberName}"; - // Summarize each executable bucket once. - var complexity = Math.Max(0, m.CyclomaticComplexity); var signature = signatures[i]; // Retain an empty bucket for explicit zero-body accessors so an aggregate // cannot restore the declaration's non-executable complexity as a fallback. var candidate = m.ExecutableLines is 0 - ? default : new MetricsCandidates(complexity, i, 1, signature is null); + ? default : new MetricsCandidates(m.CyclomaticComplexity, i, 1, signature is null); AddCandidates(target.All, key, candidate); AddCandidates(target.ByArity, (key, m.Arity), candidate); if (signature is not null) AddCandidates(target.BySignature, (key, signature), candidate); @@ -607,8 +608,8 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe // Older Metrics versions emit no children; the property // aggregate (sum over its accessors) is the honest-but-coarser fallback, // used only when no accessor entry matched. An aggregate below 1 summed no - // declared accessor (a positional record property) — nothing was measured. - if (m.CyclomaticComplexity < 1 || m.ExecutableLines is 0) break; + // declared accessor (a positional record property) and never gets here. + if (m.ExecutableLines is 0) break; target.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); target.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); break; diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 8826e7a..4e9ce86 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -327,8 +327,6 @@ public async Task NonPositiveComplexity_IsNotAMeasurement_AndCannotPass(string c var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(coverage), metrics); - // Only an explicit zero-body declaration keeps a complexity below 1. - await Assert.That(metrics.Count).IsEqualTo(executableLines is 0 ? 1 : 0); await Assert.That(report.Methods).IsEmpty(); await Assert.That(report.Unscored.Count).IsEqualTo(1); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); diff --git a/tests/DotCov.Tests/CrapTupleAndRootTests.cs b/tests/DotCov.Tests/CrapTupleAndRootTests.cs index 2609e10..78c9679 100644 --- a/tests/DotCov.Tests/CrapTupleAndRootTests.cs +++ b/tests/DotCov.Tests/CrapTupleAndRootTests.cs @@ -24,27 +24,50 @@ public async Task TupleParameters_MatchRuntimeMetadata(string source, string run await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); } + // Cobertura carries IL names, where tuples are ValueTuple`N: parentheses there come from a + // truncated function pointer, so the signature is unknown and the method still analyzed. [Test] [Arguments("left")] [Arguments("right")] [Arguments("deep")] [Arguments("empty")] - public async Task InvalidTupleStructure_IsRejected(string shape) + public async Task ParenthesesInCoverageSignatures_AreUnknown_NotErrors(string shape) { - var type = shape switch - { - "left" => "(int", - "right" => "int)", - "empty" => "( , int)", - _ => new string('(', 65) + "int" + string.Concat(Enumerable.Repeat(",int)", 65)) - }; using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c - .Method("M", $"({type})", "1", m => m.Line(1, 1))).ToBytes()); + .Method("M", $"({TupleShape(shape)})", "1", m => m.Line(1, 1))).ToBytes()); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input)); + + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(1); + } + + // Roslyn metrics displays do spell tuples in source syntax: malformed or too deep is an input error. + [Test] + [Arguments("left")] + [Arguments("right")] + [Arguments("deep")] + [Arguments("empty")] + public async Task InvalidTupleStructure_InMetrics_IsRejected(string shape) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(System.Int32)", null, m => m.Line(1, 1))).ToBytes()); var parsed = CoberturaParser.ParseMethods(input); - var error = Assert.Throws(() => CrapAnalysis.Analyze(parsed)); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method($"void A.M({TupleShape(shape)} x)", 2, 1)); + + var error = Assert.Throws(() => CrapAnalysis.Analyze(parsed, metrics)); await Assert.That(error.Message).IsNotEmpty(); } + // A display parses when its brackets balance as a whole, so the stray tuple parenthesis + // pairs with an angle bracket: "(int>" opens a tuple that never closes. + private static string TupleShape(string shape) => shape switch + { + "left" => "(int>", + "right" => " "( , int)", + _ => new string('(', 65) + "int" + string.Concat(Enumerable.Repeat(",int)", 65)) + }; + [Test] public async Task TupleDepthBoundary_RemainsMeasurable() { From 10a5a6b20cfba06e05422d8b3c3544f472a6640e Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 15:25:43 +0200 Subject: [PATCH 40/42] Match async overload bodies and chosen roots; keep function pointers comparable - Treating a metrics delegate* as unknown let it match a method with other parameter types (a false PASS across builds). It now lowers to one canonical delegate*<...> identity; only Coverlet's truncated coverage spelling stays unknown. - Async overloads measured only through their state machines left every metrics row unmatched. When a file holds only such bodies for a member and the row count agrees, the rows are consumed; each body already carries the larger complexity. - --source-root keyed coverage files under the chosen root but left relative metrics paths unrooted when several roots were declared; the parsed report now keeps the chosen root. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ Directory.Build.props | 2 +- docs/releases/3.0.1.md | 8 ++- src/DotCov/CoberturaParser.Methods.cs | 9 +-- src/DotCov/CrapAnalysis.cs | 25 ++++++- src/DotCov/MethodCoverageReport.cs | 4 ++ src/DotCov/MethodSignatures.cs | 14 +++- .../CrapFileLocalAndConversionTests.cs | 67 +++++++++++++++++++ 8 files changed, 124 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf4d0..f9f4d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -826,6 +826,11 @@ Verified: `Unbalanced tuple type`. Escaped identifiers (`@int`) drop the escape after aliasing; a generic `T?` that is a value type in one method and a reference type in another stays unresolved instead of being forced to `Nullable` report-wide. +- Metrics `delegate*` parameters lower to one canonical identity (calling convention dropped, + `void` aliased to `System.Void`), so they conflict with other known signatures while + Coverlet's truncated spelling stays unknown. +- State-machine-only async overloads consume their metrics rows when the body and row counts + of a member in one file agree; `--source-root` now also roots relative metrics paths. - `GateAwareTableWiringTests` use the shared `FakeDotnet`, so their two `dotcov test` cases run on Windows too; one Linux/macOS-only test remains (Unix file modes). - Aligned package/action/README versions and rewrote `docs/releases/3.0.1.md` around diff --git a/Directory.Build.props b/Directory.Build.props index 54695ec..490b5f4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -30,7 +30,7 @@ docs/releases/$(VersionPrefix).md holds the full notes. --> DotCov 3.0.1 fixes CRAP passes that 3.0.0 could not prove; line and branch gates are unchanged. -CRAP: an unmeasured metrics method, an ambiguous overload match or a complexity below 1 now gives NODATA (exit 3); async overloads keep their own body, so diluted scores rise; function-pointer parameters no longer abort crap; declarations without a body need no coverage. +CRAP: an unmeasured metrics method, an ambiguous overload match or a complexity below 1 now gives NODATA (exit 3); async overloads keep their own body, so diluted scores rise; state-machine-only async overloads match their metrics rows; function-pointer parameters no longer abort crap; declarations without a body need no coverage; --source-root also roots relative metrics paths. Exclusions: excluded methods count as measured without a score and never hide a kept sibling; merged reports no longer list a re-added file as excluded. DotCov.Fallout requires System.Security.Cryptography.Pkcs/Xml 10.0.13. Full notes: https://github.com/ANcpLua/dotcov/blob/main/docs/releases/$(VersionPrefix).md diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 8621fa5..0db387a 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -19,8 +19,14 @@ reported a pass it could not prove. - **Async overloads keep their own body.** When a lambda preceded its state machine in the report, 3.0.0 could fold an async overload into a covered synchronous one and pass. CRAP scores for such methods rise to their own values. +- **Async overloads measured only through their state machines are matched.** When a file holds + nothing but the state machines of a member's overloads and the metrics file has exactly as + many rows for it, the rows pair with the bodies; each body is scored with the larger complexity. - **Function-pointer parameters no longer abort `crap`.** Coverlet truncates their signature; - 3.0.0 failed with `Unbalanced tuple type`. Such methods now match by name and parameter count. + 3.0.0 failed with `Unbalanced tuple type`. Such methods now match by name and parameter count, + and a metrics `delegate*` row never matches a method with other parameter types. +- **`--source-root` also roots relative metrics paths**, so files resolved against the chosen + root find their metrics rows. - **Excluded files stay accounted for.** Methods removed by `--exclude-*` count as measured without being scored, and an excluded overload never hides a kept sibling. - Conversion operators that differ only in their return type stay `NODATA`: Cobertura omits the diff --git a/src/DotCov/CoberturaParser.Methods.cs b/src/DotCov/CoberturaParser.Methods.cs index b9f1ea5..d4a9141 100644 --- a/src/DotCov/CoberturaParser.Methods.cs +++ b/src/DotCov/CoberturaParser.Methods.cs @@ -24,7 +24,7 @@ public static MethodCoverageReport ParseMethods(Stream stream, long maxChars = D using (var reader = CreateReader(stream, maxChars, async: false)) CollectMethods(reader, methods, scope, sourceName: null); scope.RequireDeclaredRoot(); - return methods.Materialize(); + return methods.Materialize(identity?.SourceRoot); } /// @@ -68,7 +68,7 @@ public static MethodCoverageReport ParseMethods( } scope.RequireDeclaredRoot(); - return methods.Materialize(); + return methods.Materialize(identity?.SourceRoot); } /// Walk one document into ; roots are per document, warnings accumulate. @@ -119,14 +119,15 @@ public void Complete(DocumentContext document) _sourceRoots.Add(root); } - public MethodCoverageReport Materialize() + public MethodCoverageReport Materialize(string? sourceRoot) { var result = new List(_order.Count); foreach (var entry in _order) result.Add(entry.Materialize()); return new MethodCoverageReport(result, _warnings, _sourceRoots) { - AmbiguousFiles = AmbiguousFile.Union(_ambiguous) + AmbiguousFiles = AmbiguousFile.Union(_ambiguous), + SelectedSourceRoot = sourceRoot }; } diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 7aceabe..ab415fc 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using static System.FormattableString; namespace DotCov; @@ -206,7 +207,8 @@ internal static double DisplayScore(double score, double maxCrap) => public static CrapReport Analyze(MethodCoverageReport report, IReadOnlyList? metricsMembers = null) { ArgumentNullException.ThrowIfNull(report); - var normalizedMetrics = metricsMembers?.Select(m => m with { File = MetricsFile(m.File, report.SourceRoots) }).ToArray(); + IReadOnlyList roots = report.SelectedSourceRoot is { } root ? [root] : report.SourceRoots; + var normalizedMetrics = metricsMembers?.Select(m => m with { File = MetricsFile(m.File, roots) }).ToArray(); var (selectedMetrics, exclusion) = report.SelectMetrics(normalizedMetrics); var analysis = AnalyzeCore(report.Methods, selectedMetrics, report.ExcludedMethods); return new CrapReport(analysis.Methods, analysis.Unscored, analysis.UnmatchedMetricsMembers) @@ -267,6 +269,7 @@ private static CrapReport AnalyzeCore( var (scored, unscored) = ScoreMethods(order, metricsMembers, metricsIndex, consumed); // 3. resolve comp, score ReconcileConstructorPairs(order, metricsMembers, consumed); + ReconcileSyntheticOverloads(order, metricsIndex, consumed); var unmatched = CollectUnmatched(metricsMembers, consumed); // 4. honesty channel return new CrapReport(scored, unscored, unmatched); @@ -314,6 +317,23 @@ private static void MatchExcludedMetrics( if (compatible.UniqueMember is { } index) consumed.Add(index); } + // Async and iterator overloads often reach coverage only as state machines, which carry no + // signature. When such bodies are all a file holds of one type's member, and exactly as many + // metrics rows exist for it, those rows are these bodies. Each body was already scored with + // the largest candidate complexity, so pairing them cannot lower a score. + private static void ReconcileSyntheticOverloads(List order, MetricsIndex index, HashSet consumed) + { + foreach (var group in order.Where(static m => !m.FoldedAway).GroupBy(static m => + (m.Name.TypeIdentity, Member: MethodIdentity.MetricsMemberName(m.Name.Method), m.File))) + { + var key = $"{group.Key.TypeIdentity}|{group.Key.Member}"; + var scoped = index.ByFile.GetValueOrDefault(PathIdentity.NormalizeRoot(group.Key.File)); + var rows = index.Rows.GetValueOrDefault(key, []).Concat(scoped?.Rows.GetValueOrDefault(key, []) ?? []).ToArray(); + if (group.Count() > 1 && group.All(static m => m.Folded) && rows.Length == group.Count()) + consumed.UnionWith(rows); + } + } + private static IReadOnlyList? PrepareMetrics( IReadOnlyList? members, IEnumerable methods) { @@ -557,6 +577,8 @@ private sealed class MetricsIndex public readonly Dictionary AccessorFallback = new(StringComparer.Ordinal); public readonly Dictionary PropertyFiles = new(); public readonly Dictionary ByFile = new(StringComparer.Ordinal); + /// Every method and accessor row per member key, for cardinality reconciliation. + public readonly Dictionary> Rows = new(StringComparer.Ordinal); } private static MetricsCandidates MergeCandidates(MetricsCandidates left, MetricsCandidates right) @@ -598,6 +620,7 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe var candidate = m.ExecutableLines is 0 ? default : new MetricsCandidates(m.CyclomaticComplexity, i, 1, signature is null); AddCandidates(target.All, key, candidate); + (CollectionsMarshal.GetValueRefOrAddDefault(target.Rows, key, out _) ??= []).Add(i); AddCandidates(target.ByArity, (key, m.Arity), candidate); if (signature is not null) AddCandidates(target.BySignature, (key, signature), candidate); // The metrics display omits static/instance information for extension diff --git a/src/DotCov/MethodCoverageReport.cs b/src/DotCov/MethodCoverageReport.cs index ef61f1f..f885801 100644 --- a/src/DotCov/MethodCoverageReport.cs +++ b/src/DotCov/MethodCoverageReport.cs @@ -45,6 +45,9 @@ public MethodCoverageReport( // They must never contribute lines, complexity, or unscored rows to the selected scope. internal IReadOnlyList ExcludedMethods { get; init; } = []; + // The root --source-root chose; relative metrics paths resolve against it as files did. + internal string? SelectedSourceRoot { get; init; } + // Preserve each operation: a keep in a later call cannot undo an earlier exclusion. private IReadOnlyList<(List Rules, List Keep)> ExclusionSteps { get; init; } = []; @@ -94,6 +97,7 @@ public MethodCoverageReport Exclude(IEnumerable patterns, IEnumerable excludedPaths.Contains(m.File))).ToArray() }; diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index 6b2ccb1..bfc622e 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -14,7 +14,7 @@ internal static partial class MethodSignatures ["uint"] = "System.UInt32", ["long"] = "System.Int64", ["ulong"] = "System.UInt64", ["char"] = "System.Char", ["float"] = "System.Single", ["double"] = "System.Double", ["decimal"] = "System.Decimal", ["string"] = "System.String", ["object"] = "System.Object", - ["dynamic"] = "System.Object", + ["dynamic"] = "System.Object", ["void"] = "System.Void", ["nint"] = "System.IntPtr", ["nuint"] = "System.UIntPtr" }; [GeneratedRegex(@"(?, with or without a calling convention; the last '>' closes it. + [GeneratedRegex(@"^delegate\*\s*(?:managed|unmanaged(?:\[[^\]]*\])?)?\s*<(.*)>([^>]*)$", + RegexOptions.CultureInvariant | RegexOptions.Singleline)] + private static partial Regex FunctionPointerType(); + private const int MaxSignatureLength = 16_384; // Tokenize with the linear engine; no delimiter lookahead can force retries // over every suffix of a long, untrusted identifier. @@ -188,8 +193,6 @@ private static IEnumerable BareNames(string signature) var declaration = equals < 0 ? parameter : parameter[..equals].TrimEnd(); var space = BalancedText.LastIndexOfTopLevel(declaration, ' '); if (space < 0) return null; - // Coverage never carries a comparable function-pointer spelling (see Coverage). - if (declaration.Contains("delegate*", StringComparison.Ordinal)) return null; types.Add(NormalizeType(declaration[..space])); } if (valueType is not null) types.Add(NormalizeType(valueType)); @@ -299,6 +302,11 @@ private static string NormalizeType(string type) type.StartsWith("in ", StringComparison.Ordinal); if (byRef) type = type[(type.IndexOf(' ') + 1)..]; if (byRef && type.StartsWith("readonly ", StringComparison.Ordinal)) type = type[9..]; + // One canonical function-pointer identity without its calling convention: it conflicts + // with every other parameter type, while Coverlet's truncated spelling stays unknown. + var pointer = FunctionPointerType().Match(type); + if (pointer.Success) + type = $"delegate*<{string.Join(",", BalancedText.SplitTopLevel(pointer.Groups[1].Value).Select(NormalizeType))}>{pointer.Groups[2].Value}"; type = LowerTuples(type); type = TypeWords().Replace(type, static word => Aliases.GetValueOrDefault(word.Value, word.Value)); // @int names a type called int, never the alias: drop the escape once aliases are applied. diff --git a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs index f260732..ce155a6 100644 --- a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs +++ b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs @@ -119,4 +119,71 @@ public async Task NullableGenericParameters_ResolvePerMethod_NotAcrossTheReport( await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([2, 7]); await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); } + + [Test] + public async Task FunctionPointerMetrics_DoNotMatchAMethodWithAnotherSignature() + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "(System.String)", null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M(delegate* callback)", 2, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.M(delegate* callback)"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + // Coverlet often emits only the state machines of async overloads, which carry no signature. + // Bodies and rows pair only when their counts agree. + [Test] + [Arguments("complete")] + [Arguments("missing body")] + [Arguments("extra row")] + public async Task AsyncOverloadsMeasuredOnlyThroughStateMachines_MatchTheirMetricsRowsTogether(string shape) + { + var doc = Cobertura.NewDoc() + .AddClass("src/A.cs", "App.A/d__0", c => c.Method("MoveNext", "()", null, m => m.Line(1, 1))); + if (shape is not "missing body") + doc.AddClass("src/A.cs", "App.A/d__1", c => c.Method("MoveNext", "()", null, m => m.Line(5, 1))); + using var input = new MemoryStream(doc.ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => + { + m.Method("Task A.M(int value)", 2, 1).Method("Task A.M(string value)", 3, 1); + if (shape is "extra row") m.Method("Task A.M(bool value)", 1, 1); + }); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + // Which body is which overload is unknown: every body carries the largest complexity. + await Assert.That(report.Methods.Select(m => m.Complexity)).All(static c => c == 3); + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(shape switch + { + "complete" => 0, + "missing body" => 2, + _ => 3 + }); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(shape is "complete" ? GateOutcome.Pass : GateOutcome.NoData); + } + + [Test] + public async Task ChosenSourceRoot_AlsoRootsRelativeMetricsPaths() + { + var xml = """ + /other/repo + + + + + + """; + using var input = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(xml)); + var coverage = CoberturaParser.ParseMethods(input, identity: FileIdentity.FromSourceRoot("/repo")); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("void A.M()", 2, 1)) + .Select(static m => m with { File = "src/A.cs" }).ToArray(); + + var report = CrapAnalysis.Analyze(coverage, metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } } From 9fc0b07922281c209e3fffc71b88adb0914a1304 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Wed, 14 Oct 2026 15:34:13 +0200 Subject: [PATCH 41/42] Keep invalid metrics rows unmatched, name malformed members, read Visual Basic metrics - Dropping rows with a complexity below 1 also removed them from completeness accounting, so an invalid metrics-only row could vanish and the gate pass. Such rows now stay in the table, supply no complexity and count as unmatched. - Tuple structure in metrics displays is validated by the reader, so a malformed member is reported with the metrics file and member name instead of a bare message at analysis time. - Visual Basic displays (Sub/Function ... As T, ByRef, Optional, ParamArray, (Of T), T()) are normalized and match their IL signatures; VB tuples stay unknown. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- Directory.Build.props | 2 +- docs/releases/3.0.1.md | 9 ++- src/DotCov/CodeMetrics.cs | 16 ++++- src/DotCov/CrapAnalysis.cs | 20 +++--- src/DotCov/MethodSignatures.cs | 70 ++++++++++++++++++- .../DotCov.Tests/CrapMetricsIdentityTests.cs | 19 +++++ tests/DotCov.Tests/CrapMetricsScopeTests.cs | 12 ++++ tests/DotCov.Tests/CrapTupleAndRootTests.cs | 11 ++- .../CrapVisualBasicMetricsTests.cs | 65 +++++++++++++++++ 10 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f4d84..4ce5034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -816,8 +816,11 @@ Verified: - Async bodies retain their own identity in either input order. Merged exclusion metadata lists only absent files. Indexed metrics matching avoids repeated candidate scans. - Patched Fallout cryptography dependencies to 10.0.13 without suppressing NuGet audit. -- CRAP analysis ignores a metrics complexity below 1 on an executable member (reader input - and library callers alike), so its method is unscored (NODATA) instead of scored 0. +- A metrics complexity below 1 on an executable member supplies no complexity (reader input + and library callers alike) and the row still counts as unmatched, so the gate is NODATA. +- Visual Basic metrics displays (`Sub`/`Function … As T`, `ByRef`, `Optional`, `ParamArray`, + `(Of T)`, `T()`) are normalized in the reader; tuple syntax is validated there too, so a + malformed member names the metrics file and member. - Parentheses in a Cobertura signature (only a truncated function pointer produces them) make it unknown instead of an error; malformed tuple syntax stays an input error in metrics displays, where source tuple syntax really occurs. diff --git a/Directory.Build.props b/Directory.Build.props index 490b5f4..045677f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -30,7 +30,7 @@ docs/releases/$(VersionPrefix).md holds the full notes. --> DotCov 3.0.1 fixes CRAP passes that 3.0.0 could not prove; line and branch gates are unchanged. -CRAP: an unmeasured metrics method, an ambiguous overload match or a complexity below 1 now gives NODATA (exit 3); async overloads keep their own body, so diluted scores rise; state-machine-only async overloads match their metrics rows; function-pointer parameters no longer abort crap; declarations without a body need no coverage; --source-root also roots relative metrics paths. +CRAP: an unmeasured metrics method, an ambiguous overload match or a complexity below 1 now gives NODATA (exit 3); async overloads keep their own body, so diluted scores rise; state-machine-only async overloads match their metrics rows; function-pointer parameters no longer abort crap; declarations without a body need no coverage; --source-root also roots relative metrics paths; Visual Basic metrics displays match. Exclusions: excluded methods count as measured without a score and never hide a kept sibling; merged reports no longer list a re-added file as excluded. DotCov.Fallout requires System.Security.Cryptography.Pkcs/Xml 10.0.13. Full notes: https://github.com/ANcpLua/dotcov/blob/main/docs/releases/$(VersionPrefix).md diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 0db387a..47b1273 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -15,7 +15,11 @@ reported a pass it could not prove. - **Declarations without a body need no coverage.** Members with `ExecutableLines=0` (interface and abstract members) neither block a pass nor supply complexity. - **A complexity below 1 is not a measurement.** A metrics row with `CyclomaticComplexity` 0 or - negative leaves its method unscored (`NODATA`); 3.0.0 scored it 0 and could pass. + negative supplies no complexity and still counts as unmatched, so the gate says `NODATA`; + 3.0.0 scored it 0 and could pass. +- **Visual Basic metrics match.** `Sub A.M(value As Integer)` and `Function A.M(...) As Long` + displays, including `ByRef`, `Optional`, `ParamArray`, `List(Of T)` and `T()`, match their IL + signatures like C# displays. - **Async overloads keep their own body.** When a lambda preceded its state machine in the report, 3.0.0 could fold an async overload into a covered synchronous one and pass. CRAP scores for such methods rise to their own values. @@ -37,7 +41,8 @@ reported a pass it could not prove. - A merged report no longer lists a file as excluded when another input brings it back. - Large reports with many excluded files or large metrics files no longer scan quadratically. Coverage signatures and metrics names longer than 16,384 characters are rejected as input - errors (exit 5); tuple nesting is limited to 64 levels and metrics type nesting to 256. + errors (exit 5); tuple nesting is limited to 64 levels and metrics type nesting to 256. A + malformed metrics member is reported with the metrics file and the member's name. ## Packages diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 2466d9f..087da59 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -217,9 +217,19 @@ private static void OpenMember(XmlReader reader, ParserState state) // Metrics — nothing to record. if (state.TypeChain.Count > 0 && !reader.IsEmptyElement) { - var display = reader.GetAttribute("Name") ?? ""; - MethodSignatures.ValidateLength(display, - $"Metrics member display '{display[..Math.Min(display.Length, 200)]}'"); + var raw = reader.GetAttribute("Name") ?? ""; + var context = $"Metrics member display '{raw[..Math.Min(raw.Length, 200)]}'"; + MethodSignatures.ValidateLength(raw, context); + var display = MethodSignatures.VisualBasicDisplay(raw); + // Check the parameter syntax here, where the member and file are still known. + try + { + MethodSignatures.Metrics(display); + } + catch (InvalidDataException ex) + { + throw new InvalidDataException($"{context}: {ex.Message}", ex); + } state.MemberStack.Push(new PendingMember(display, reader.LocalName, reader.GetAttribute("File"))); } } diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index ab415fc..513f0c1 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -342,10 +342,7 @@ private static void ReconcileSyntheticOverloads(List order, Metri // the coverage scope contains exactly one possible declaring-type identity. var types = methods.GroupBy(static m => m.TypeKey).ToDictionary(static g => g.Key, static g => g.Select(m => m.Name.TypeIdentity).Distinct().Take(2).ToArray()); - // Cyclomatic complexity is at least 1 for anything executable: a lower value measured - // nothing and must not score 0. Only an explicit zero-body declaration keeps it, to stay - // out of the completeness accounting. - return members.Where(static m => m.CyclomaticComplexity >= 1 || m.ExecutableLines is 0).Select(m => m with + return members.Select(m => m with { TypeIdentity = m.TypeIdentity ?? (types.TryGetValue(m.TypeName, out var identities) && identities.Length == 1 ? identities[0] : null), @@ -615,12 +612,13 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe case CodeMetricsMemberKind.Method or CodeMetricsMemberKind.Accessor: var key = $"{m.TypeIdentity ?? m.TypeName}|{m.MemberName}"; var signature = signatures[i]; - // Retain an empty bucket for explicit zero-body accessors so an aggregate - // cannot restore the declaration's non-executable complexity as a fallback. - var candidate = m.ExecutableLines is 0 - ? default : new MetricsCandidates(m.CyclomaticComplexity, i, 1, signature is null); + // A zero-body declaration needs no coverage, and a complexity below 1 measured + // nothing. Neither supplies complexity: both leave an empty bucket, so an + // aggregate cannot fill in, and only the measured row remains unmatched. + var measured = m.ExecutableLines is not 0 && m.CyclomaticComplexity >= 1; + var candidate = measured ? new MetricsCandidates(m.CyclomaticComplexity, i, 1, signature is null) : default; AddCandidates(target.All, key, candidate); - (CollectionsMarshal.GetValueRefOrAddDefault(target.Rows, key, out _) ??= []).Add(i); + if (measured) (CollectionsMarshal.GetValueRefOrAddDefault(target.Rows, key, out _) ??= []).Add(i); AddCandidates(target.ByArity, (key, m.Arity), candidate); if (signature is not null) AddCandidates(target.BySignature, (key, signature), candidate); // The metrics display omits static/instance information for extension @@ -631,8 +629,8 @@ private static MetricsIndex IndexMetrics(IReadOnlyList? membe // Older Metrics versions emit no children; the property // aggregate (sum over its accessors) is the honest-but-coarser fallback, // used only when no accessor entry matched. An aggregate below 1 summed no - // declared accessor (a positional record property) and never gets here. - if (m.ExecutableLines is 0) break; + // declared accessor (a positional record property) — nothing was measured. + if (m.CyclomaticComplexity < 1 || m.ExecutableLines is 0) break; target.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|get_{m.MemberName}", (i, m.CyclomaticComplexity)); target.AccessorFallback.TryAdd($"{m.TypeIdentity ?? m.TypeName}|set_{m.MemberName}", (i, m.CyclomaticComplexity)); break; diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index bfc622e..da6996f 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -17,9 +17,23 @@ internal static partial class MethodSignatures ["dynamic"] = "System.Object", ["void"] = "System.Void", ["nint"] = "System.IntPtr", ["nuint"] = "System.UIntPtr" }; + private static readonly Dictionary VisualBasicAliases = new(StringComparer.Ordinal) + { + ["Boolean"] = "System.Boolean", ["Byte"] = "System.Byte", ["SByte"] = "System.SByte", + ["Short"] = "System.Int16", ["UShort"] = "System.UInt16", ["Integer"] = "System.Int32", + ["UInteger"] = "System.UInt32", ["Long"] = "System.Int64", ["ULong"] = "System.UInt64", + ["Char"] = "System.Char", ["Single"] = "System.Single", ["Double"] = "System.Double", + ["Decimal"] = "System.Decimal", ["String"] = "System.String", ["Object"] = "System.Object", + ["Date"] = "System.DateTime" + }; [GeneratedRegex(@"(? + /// Visual Basic displays name the member kind and end a function with its return type + /// (Function A.M(x As Integer) As Long); reduce them to A.M(x As Integer). + /// + internal static string VisualBasicDisplay(string display) + { + if (display.StartsWith("Sub ", StringComparison.Ordinal)) return display[4..]; + if (!display.StartsWith("Function ", StringComparison.Ordinal)) return display; + var end = display.LastIndexOf(") As ", StringComparison.Ordinal); + return end < 0 ? display[9..] : display[9..(end + 1)]; + } + internal static void ValidateLength(string text, string identity) { if (text.Length > MaxSignatureLength) @@ -189,8 +215,16 @@ private static IEnumerable BareNames(string signature) var types = new List(); foreach (var parameter in BalancedText.SplitTopLevel(parameters)) { - var equals = BalancedText.LastIndexOfTopLevel(parameter, '='); - var declaration = equals < 0 ? parameter : parameter[..equals].TrimEnd(); + var unbracketed = parameter.StartsWith('[') ? parameter[1..^1] : parameter; + var equals = BalancedText.LastIndexOfTopLevel(unbracketed, '='); + var declaration = equals < 0 ? unbracketed : unbracketed[..equals].TrimEnd(); + var visualBasic = VisualBasicParameter().Match(declaration); + if (visualBasic.Success) + { + if (VisualBasicType(visualBasic.Groups[2].Value) is not { } type) return null; + types.Add(NormalizeType((visualBasic.Groups[1].Value.Contains("ByRef") ? "ref " : "") + type)); + continue; + } var space = BalancedText.LastIndexOfTopLevel(declaration, ' '); if (space < 0) return null; types.Add(NormalizeType(declaration[..space])); @@ -199,6 +233,38 @@ private static IEnumerable BareNames(string signature) return string.Join(",", types); } + // Visual Basic spells generics List(Of T) and arrays T() or T(,). Its tuples, a parenthesis + // at the start or after a space, stay unknown. + private static string? VisualBasicType(string type) + { + var output = new StringBuilder(type.Length); + var closers = new Stack(); + for (var i = 0; i < type.Length; i++) + { + switch (type[i]) + { + case '(' when type.AsSpan(i + 1).StartsWith("Of "): + output.Append('<'); + closers.Push('>'); + i += 3; + break; + case '(' when i > 0 && type[i - 1] != ' ': + output.Append('['); + closers.Push(']'); + break; + case '(': + return null; + case ')': + output.Append(closers.TryPop(out var close) ? close : ')'); + break; + default: + output.Append(type[i]); + break; + } + } + return TypeWords().Replace(output.ToString(), static word => VisualBasicAliases.GetValueOrDefault(word.Value, word.Value)); + } + private sealed class TupleFrame(int angle, int array, int start, bool nullable) { public readonly int Angle = angle; diff --git a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs index 4d07900..ff5bbca 100644 --- a/tests/DotCov.Tests/CrapMetricsIdentityTests.cs +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -36,6 +36,25 @@ public async Task OversizedSignature_IsRejectedThroughCli(bool metricsTooLong) await Assert.That(stderr.ToString()).Contains(metricsTooLong ? "A.M(" : "App.A.M"); } + [Test] + public async Task MalformedMetricsTuple_NamesTheFileAndMemberThroughCli() + { + using var ws = TempWorkspace.Create("dotcov-metrics-tuple-"); + var coverage = ws.Write("coverage.xml", Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .Method("M", "()", "1", m => m.Line(1, 1))).ToBytes()); + var deep = new string('(', 65) + "int" + string.Concat(Enumerable.Repeat(",int)", 65)); + var metrics = ws.Write("metrics.xml", CodeMetricsReportBuilder.Create() + .Namespace("App", t => t.Type("A", m => m.Method($"void A.M({deep} value)", 2, 1))).ToBytes()); + var stdout = new StringWriter(); + var stderr = new StringWriter(); + + var exitCode = await DotCovCli.RunAsync(["crap", coverage, "--metrics", metrics], stdout, stderr); + + await Assert.That(exitCode).IsEqualTo(DotCovCli.ErrorExitCode); + await Assert.That(stderr.ToString()).Contains($"{metrics}: Metrics member display 'void A.M((("); + await Assert.That(stderr.ToString()).Contains("Tuple nesting exceeds 64 levels."); + } + [Test] [Arguments("App.A/d__0", "MoveNext", "()", true)] [Arguments("App.A", "M", "()", false)] diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 4e9ce86..9bc70a6 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -332,6 +332,18 @@ public async Task NonPositiveComplexity_IsNotAMeasurement_AndCannotPass(string c await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + [Test] + public async Task NonPositiveComplexity_StillCountsAsAnUnmatchedRow() + { + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("void A.Kept()", 1, 1).Method("void A.Missing()", 0)); + + var report = CrapAnalysis.Analyze(KeptCoverage(), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.Missing()"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + [Test] public async Task ExecutableLines_StayOnTheirOwnMember_AndDoNotEraseMeasuredBodies() { diff --git a/tests/DotCov.Tests/CrapTupleAndRootTests.cs b/tests/DotCov.Tests/CrapTupleAndRootTests.cs index 78c9679..4a48c18 100644 --- a/tests/DotCov.Tests/CrapTupleAndRootTests.cs +++ b/tests/DotCov.Tests/CrapTupleAndRootTests.cs @@ -49,13 +49,12 @@ public async Task ParenthesesInCoverageSignatures_AreUnknown_NotErrors(string sh [Arguments("empty")] public async Task InvalidTupleStructure_InMetrics_IsRejected(string shape) { - using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c - .Method("M", "(System.Int32)", null, m => m.Line(1, 1))).ToBytes()); - var parsed = CoberturaParser.ParseMethods(input); - var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method($"void A.M({TupleShape(shape)} x)", 2, 1)); + var display = $"void A.M({TupleShape(shape)} x)"; + + var error = Assert.Throws(() => + CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method(display, 2, 1))); - var error = Assert.Throws(() => CrapAnalysis.Analyze(parsed, metrics)); - await Assert.That(error.Message).IsNotEmpty(); + await Assert.That(error.Message).StartsWith($"Metrics member display '{display[..Math.Min(display.Length, 200)]}': "); } // A display parses when its brackets balance as a whole, so the stray tuple parenthesis diff --git a/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs b/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs new file mode 100644 index 0000000..d8b61e0 --- /dev/null +++ b/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs @@ -0,0 +1,65 @@ +using DotCov.Tests.Infrastructure; + +namespace DotCov.Tests; + +/// +/// Roslyn writes Visual Basic members as Sub A.M(value As Integer) and +/// Function A.M(value As Integer) As Long; they match Coverlet's IL signatures like C# displays. +/// +public sealed class CrapVisualBasicMetricsTests +{ + [Test] + [Arguments("Sub A.M(value As Integer)", "System.Int32")] + [Arguments("Sub A.M(ByRef value As Integer)", "System.Int32&")] + [Arguments("Sub A.M(Optional value As Integer = 5)", "System.Int32")] + [Arguments("Sub A.M([value As Integer = 5])", "System.Int32")] + [Arguments("Sub A.M(when As Date)", "System.DateTime")] + [Arguments("Sub A.M(values As Integer())", "System.Int32[]")] + [Arguments("Sub A.M(grid As Integer(,))", "System.Int32[,]")] + [Arguments("Sub A.M(ParamArray values As String())", "System.String[]")] + [Arguments("Sub A.M(values As List(Of Integer))", "System.Collections.Generic.List`1")] + [Arguments("Sub A.M(map As Dictionary(Of String, List(Of Long)))", + "System.Collections.Generic.Dictionary`2>")] + [Arguments("Function A.M(value As Integer) As List(Of Long)", "System.Int32")] + public async Task VisualBasicOverloads_MatchTheirOwnRows(string display, string ilType) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.vb", "App.A", c => c + .Method("M", $"({ilType})", null, m => m.Line(1, 1)) + .Method("M", "(System.Boolean)", null, m => m.Line(2, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method(display, 2, 1).Method("Sub A.M(flag As Boolean)", 7, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Select(m => m.Complexity)).IsEquivalentTo([2, 7]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + // A tuple's parenthesis opens the type or follows a space, and a function display may lack its + // return clause: the signature stays unknown and the sole method matches by name. + [Test] + [Arguments("Sub A.M(pair As (Integer, String))")] + [Arguments("Sub A.M(items As List(Of (Integer, String)))")] + [Arguments("Function A.M")] + public async Task UnknownVisualBasicShapes_MatchASoleMethod(string display) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.vb", "App.A", c => c + .Method("M", "(System.ValueTuple`2)", null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method(display, 2, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.Pass); + } + + [Test] + public async Task UnbalancedVisualBasicType_IsAnInputErrorNamingTheMember() + { + var error = Assert.Throws(() => + CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method("Sub A.M(x As Date: Wed, 14 Oct 2026 15:55:15 +0200 Subject: [PATCH 42/42] Keep rows without complexity, pair source-less rows with one file, read VB generics - The metrics reader dropped a member whose complexity was missing or malformed, so an incomplete metrics file could pass. Such members stay in the table as 0, which never scores, and count as unmatched. - Rows without a source file served the async bodies of every file, so two files' bodies could consume the same two rows. They now pair only when one file holds such bodies. - Visual Basic generic methods (M(Of T)) and Sub New constructors normalize to the C# shape; the raw display stays as DisplayName. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++--- docs/releases/3.0.1.md | 13 +++++----- src/DotCov/CodeMetrics.cs | 24 +++++++++---------- src/DotCov/CrapAnalysis.cs | 20 ++++++++++------ src/DotCov/MethodSignatures.cs | 21 ++++++++++++---- .../CodeMetricsReaderEdgeTests.cs | 5 ++-- tests/DotCov.Tests/CodeMetricsReaderTests.cs | 5 ++-- .../CrapFileLocalAndConversionTests.cs | 23 ++++++++++++++++++ tests/DotCov.Tests/CrapMetricsScopeTests.cs | 19 +++++++++++++++ .../CrapVisualBasicMetricsTests.cs | 18 ++++++++++++++ 10 files changed, 120 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce5034..16d27a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -818,8 +818,10 @@ Verified: - Patched Fallout cryptography dependencies to 10.0.13 without suppressing NuGet audit. - A metrics complexity below 1 on an executable member supplies no complexity (reader input and library callers alike) and the row still counts as unmatched, so the gate is NODATA. -- Visual Basic metrics displays (`Sub`/`Function … As T`, `ByRef`, `Optional`, `ParamArray`, - `(Of T)`, `T()`) are normalized in the reader; tuple syntax is validated there too, so a + The reader keeps rows with a missing or malformed complexity as 0 instead of dropping them. +- Visual Basic metrics displays (`Sub`/`Function … As T`, generic `M(Of T)`, `Sub New`, + `ByRef`, `Optional`, `ParamArray`, `(Of T)`, `T()`) are normalized for matching while the + raw display is kept for listings; tuple syntax is validated there too, so a malformed member names the metrics file and member. - Parentheses in a Cobertura signature (only a truncated function pointer produces them) make it unknown instead of an error; malformed tuple syntax stays an input error in @@ -833,7 +835,8 @@ Verified: `void` aliased to `System.Void`), so they conflict with other known signatures while Coverlet's truncated spelling stays unknown. - State-machine-only async overloads consume their metrics rows when the body and row counts - of a member in one file agree; `--source-root` now also roots relative metrics paths. + of a member in one file agree (rows without a source file only when one file holds such + bodies); `--source-root` now also roots relative metrics paths. - `GateAwareTableWiringTests` use the shared `FakeDotnet`, so their two `dotcov test` cases run on Windows too; one Linux/macOS-only test remains (Unix file modes). - Aligned package/action/README versions and rewrote `docs/releases/3.0.1.md` around diff --git a/docs/releases/3.0.1.md b/docs/releases/3.0.1.md index 47b1273..d6b2043 100644 --- a/docs/releases/3.0.1.md +++ b/docs/releases/3.0.1.md @@ -14,18 +14,19 @@ reported a pass it could not prove. unmatched, so the gate says `NODATA` instead of lending one method's complexity to a sibling. - **Declarations without a body need no coverage.** Members with `ExecutableLines=0` (interface and abstract members) neither block a pass nor supply complexity. -- **A complexity below 1 is not a measurement.** A metrics row with `CyclomaticComplexity` 0 or - negative supplies no complexity and still counts as unmatched, so the gate says `NODATA`; - 3.0.0 scored it 0 and could pass. -- **Visual Basic metrics match.** `Sub A.M(value As Integer)` and `Function A.M(...) As Long` - displays, including `ByRef`, `Optional`, `ParamArray`, `List(Of T)` and `T()`, match their IL - signatures like C# displays. +- **A complexity below 1 is not a measurement.** A metrics row whose `CyclomaticComplexity` is + 0, negative, malformed or missing supplies no complexity and still counts as unmatched, so the + gate says `NODATA`; 3.0.0 scored it 0 or dropped the row and could pass. +- **Visual Basic metrics match.** `Sub A.M(value As Integer)`, `Function A.M(...) As Long`, + generic `M(Of T)` methods and `Sub New` constructors, with `ByRef`, `Optional`, `ParamArray`, + `List(Of T)` and `T()` parameters, match their IL signatures like C# displays. - **Async overloads keep their own body.** When a lambda preceded its state machine in the report, 3.0.0 could fold an async overload into a covered synchronous one and pass. CRAP scores for such methods rise to their own values. - **Async overloads measured only through their state machines are matched.** When a file holds nothing but the state machines of a member's overloads and the metrics file has exactly as many rows for it, the rows pair with the bodies; each body is scored with the larger complexity. + Rows without a source file pair only when a single file holds such bodies. - **Function-pointer parameters no longer abort `crap`.** Coverlet truncates their signature; 3.0.0 failed with `Unbalanced tuple type`. Such methods now match by name and parameter count, and a metrics `delegate*` row never matches a method with other parameter types. diff --git a/src/DotCov/CodeMetrics.cs b/src/DotCov/CodeMetrics.cs index 087da59..6f97cf4 100644 --- a/src/DotCov/CodeMetrics.cs +++ b/src/DotCov/CodeMetrics.cs @@ -41,8 +41,10 @@ public enum CodeMetricsMemberKind /// Member kind — accessors and methods are gate-matchable; aggregates are fallbacks. /// Parameter count from a method or indexer display, including the value /// parameter for indexer setters; otherwise null. -/// Roslyn's cyclomatic complexity for the member. -/// The raw Name attribute, kept verbatim for unmatched-member listings. +/// Roslyn's cyclomatic complexity for the member; 0 when the metrics +/// file gives none or an invalid value. Below 1 is no measurement and never scores. +/// The raw Name attribute, kept verbatim for unmatched-member listings +/// (Visual Basic displays included). public readonly record struct CodeMetricsMember( string TypeName, string MemberName, @@ -217,10 +219,9 @@ private static void OpenMember(XmlReader reader, ParserState state) // Metrics — nothing to record. if (state.TypeChain.Count > 0 && !reader.IsEmptyElement) { - var raw = reader.GetAttribute("Name") ?? ""; - var context = $"Metrics member display '{raw[..Math.Min(raw.Length, 200)]}'"; - MethodSignatures.ValidateLength(raw, context); - var display = MethodSignatures.VisualBasicDisplay(raw); + var display = reader.GetAttribute("Name") ?? ""; + var context = $"Metrics member display '{display[..Math.Min(display.Length, 200)]}'"; + MethodSignatures.ValidateLength(display, context); // Check the parameter syntax here, where the member and file are still known. try { @@ -238,11 +239,10 @@ private static void CloseMember(ParserState state) { if (state.MemberStack.Count is 0) return; var pending = state.MemberStack.Pop(); - // A member without a CyclomaticComplexity metric has nothing to contribute to a - // complexity table — skipped, not zeroed. - if (pending.Complexity is { } complexity) - state.Members.Add(Materialize(pending, - state.CachedType ??= DescribeType(state.Namespace, state.TypeChain), complexity)); + // A member without a usable CyclomaticComplexity metric is still a member: it stays in + // the table as 0, which never scores, so an executable one counts as unmatched. + state.Members.Add(Materialize(pending, + state.CachedType ??= DescribeType(state.Namespace, state.TypeChain), pending.Complexity ?? 0)); } private static void RecordMetric(XmlReader reader, ParserState state) @@ -288,7 +288,7 @@ private static TypeContext DescribeType(string ns, List typeChain) private static CodeMetricsMember Materialize(PendingMember pending, TypeContext type, int complexity) { - var (memberName, arity, kind) = ParseDisplay(pending.Display, pending.Element, type.SimpleName); + var (memberName, arity, kind) = ParseDisplay(MethodSignatures.VisualBasicDisplay(pending.Display), pending.Element, type.SimpleName); return new CodeMetricsMember(type.Name, memberName, kind, arity, complexity, pending.Display) { diff --git a/src/DotCov/CrapAnalysis.cs b/src/DotCov/CrapAnalysis.cs index 513f0c1..0ab9f2a 100644 --- a/src/DotCov/CrapAnalysis.cs +++ b/src/DotCov/CrapAnalysis.cs @@ -321,16 +321,22 @@ private static void MatchExcludedMetrics( // signature. When such bodies are all a file holds of one type's member, and exactly as many // metrics rows exist for it, those rows are these bodies. Each body was already scored with // the largest candidate complexity, so pairing them cannot lower a score. + // Rows without a source file belong to no file in particular, so they serve a member's + // bodies only when a single file holds such bodies. private static void ReconcileSyntheticOverloads(List order, MetricsIndex index, HashSet consumed) { - foreach (var group in order.Where(static m => !m.FoldedAway).GroupBy(static m => - (m.Name.TypeIdentity, Member: MethodIdentity.MetricsMemberName(m.Name.Method), m.File))) + var buckets = order.Where(static m => !m.FoldedAway) + .GroupBy(static m => (m.Name.TypeIdentity, Member: MethodIdentity.MetricsMemberName(m.Name.Method), m.File)) + .Where(static g => g.Count() > 1 && g.All(static m => m.Folded)) + .Select(static g => (Key: $"{g.Key.TypeIdentity}|{g.Key.Member}", g.Key.File, Count: g.Count())) + .ToArray(); + var filesPerKey = buckets.CountBy(static b => b.Key).ToDictionary(StringComparer.Ordinal); + foreach (var (key, file, count) in buckets) { - var key = $"{group.Key.TypeIdentity}|{group.Key.Member}"; - var scoped = index.ByFile.GetValueOrDefault(PathIdentity.NormalizeRoot(group.Key.File)); - var rows = index.Rows.GetValueOrDefault(key, []).Concat(scoped?.Rows.GetValueOrDefault(key, []) ?? []).ToArray(); - if (group.Count() > 1 && group.All(static m => m.Folded) && rows.Length == group.Count()) - consumed.UnionWith(rows); + var scoped = index.ByFile.GetValueOrDefault(PathIdentity.NormalizeRoot(file)); + var sourceless = filesPerKey[key] == 1 ? index.Rows.GetValueOrDefault(key, []) : []; + var rows = sourceless.Concat(scoped?.Rows.GetValueOrDefault(key, []) ?? []).ToArray(); + if (rows.Length == count) consumed.UnionWith(rows); } } diff --git a/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs index da6996f..eab0dc2 100644 --- a/src/DotCov/MethodSignatures.cs +++ b/src/DotCov/MethodSignatures.cs @@ -96,17 +96,27 @@ void AddNames(string? signature, bool includeUnqualified) } /// - /// Visual Basic displays name the member kind and end a function with its return type - /// (Function A.M(x As Integer) As Long); reduce them to A.M(x As Integer). + /// Visual Basic displays name the member kind, end a function with its return type, spell + /// type parameters (Of T) and name constructors New: + /// Function A.M(Of T)(x As T) As Long becomes A.M<T>(x As T) and + /// Sub A.New() becomes A.A(), the C# shape every later step reads. /// internal static string VisualBasicDisplay(string display) { - if (display.StartsWith("Sub ", StringComparison.Ordinal)) return display[4..]; - if (!display.StartsWith("Function ", StringComparison.Ordinal)) return display; + var function = display.StartsWith("Function ", StringComparison.Ordinal); + if (!function && !display.StartsWith("Sub ", StringComparison.Ordinal)) return display; var end = display.LastIndexOf(") As ", StringComparison.Ordinal); - return end < 0 ? display[9..] : display[9..(end + 1)]; + display = function && end >= 0 ? display[9..(end + 1)] : display[(display.IndexOf(' ') + 1)..]; + display = VisualBasicTypeParameters().Replace(display, "<$1>"); + return VisualBasicConstructor().Replace(display, "$1.$1("); } + [GeneratedRegex(@"\(Of ([^()]*)\)(?=[.(])", RegexOptions.CultureInvariant)] + private static partial Regex VisualBasicTypeParameters(); + + [GeneratedRegex(@"([^.\s(<>]+)\.New\(", RegexOptions.CultureInvariant)] + private static partial Regex VisualBasicConstructor(); + internal static void ValidateLength(string text, string identity) { if (text.Length > MaxSignatureLength) @@ -188,6 +198,7 @@ private static IEnumerable BareNames(string signature) internal static string? Metrics(string display) { ValidateLength(display, "Metrics member display"); + display = VisualBasicDisplay(display); string parameters; string? valueType = null; if (display.EndsWith(')')) diff --git a/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs index 99898a9..94f18ca 100644 --- a/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs +++ b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs @@ -414,7 +414,7 @@ public async Task MemberWithoutName_RecordedWithEmptyDisplay() [Arguments("2.5")] [Arguments("99999999999")] [Arguments("")] - public async Task NonIntegerComplexity_MemberSkippedNotZeroed(string value) + public async Task NonIntegerComplexity_IsKeptAsNoMeasurement(string value) { var members = ParseRaw($""" @@ -422,7 +422,8 @@ public async Task NonIntegerComplexity_MemberSkippedNotZeroed(string value) """); - await Assert.That(members).IsEmpty(); + var member = await Assert.That(members).HasSingleItem(); + await Assert.That(member.CyclomaticComplexity).IsEqualTo(0); } [Test] diff --git a/tests/DotCov.Tests/CodeMetricsReaderTests.cs b/tests/DotCov.Tests/CodeMetricsReaderTests.cs index faa752f..2556d3e 100644 --- a/tests/DotCov.Tests/CodeMetricsReaderTests.cs +++ b/tests/DotCov.Tests/CodeMetricsReaderTests.cs @@ -376,7 +376,7 @@ public async Task ExplicitConversion_NormalizesToOpExplicit() } [Test] - public async Task MemberWithoutComplexityMetric_IsSkippedNotZeroed() + public async Task MemberWithoutComplexityMetric_IsKeptAsNoMeasurement() { var members = Parse(""" @@ -386,7 +386,8 @@ public async Task MemberWithoutComplexityMetric_IsSkippedNotZeroed() """); - await Assert.That(members).IsEmpty(); + var member = await Assert.That(members).HasSingleItem(); + await Assert.That(member.CyclomaticComplexity).IsEqualTo(0); } [Test] diff --git a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs index ce155a6..7228d33 100644 --- a/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs +++ b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs @@ -165,6 +165,29 @@ public async Task AsyncOverloadsMeasuredOnlyThroughStateMachines_MatchTheirMetri await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(shape is "complete" ? GateOutcome.Pass : GateOutcome.NoData); } + // Rows without a source file cannot say which of two files' bodies they measured. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task AsyncOverloadBodiesInTwoFiles_NeedRowsNamingTheirFile(bool rowsNameTheirFile) + { + var doc = Cobertura.NewDoc(); + foreach (var file in new[] { "src/A.cs", "src/A.Part.cs" }) + doc.AddClass(file, "App.A/d__0", c => c.Method("MoveNext", "()", null, m => m.Line(1, 1))) + .AddClass(file, "App.A/d__1", c => c.Method("MoveNext", "()", null, m => m.Line(5, 1))); + using var input = new MemoryStream(doc.ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m + .Method("Task A.M(int value)", 2, 1).Method("Task A.M(string value)", 3, 1) + .Method("Task A.M(long value)", 2, 1).Method("Task A.M(bool value)", 3, 1)) + .Select((m, i) => rowsNameTheirFile ? m with { File = i < 2 ? "src/A.cs" : "src/A.Part.cs" } : m) + .Take(rowsNameTheirFile ? 4 : 2).ToArray(); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers.Count).IsEqualTo(rowsNameTheirFile ? 0 : 2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(rowsNameTheirFile ? GateOutcome.Pass : GateOutcome.NoData); + } + [Test] public async Task ChosenSourceRoot_AlsoRootsRelativeMetricsPaths() { diff --git a/tests/DotCov.Tests/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs index 9bc70a6..6eb14b3 100644 --- a/tests/DotCov.Tests/CrapMetricsScopeTests.cs +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -344,6 +344,25 @@ public async Task NonPositiveComplexity_StillCountsAsAnUnmatchedRow() await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); } + [Test] + [Arguments("")] + [Arguments("")] + public async Task RowWithoutUsableComplexity_StillCountsAsUnmatched(string complexityMetric) + { + var xml = $""" + + + {complexityMetric} + + """; + using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + + var report = CrapAnalysis.Analyze(KeptCoverage(), CodeMetricsReader.Parse(input)); + + await Assert.That(report.UnmatchedMetricsMembers).IsEquivalentTo(["void A.Missing()"]); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + [Test] public async Task ExecutableLines_StayOnTheirOwnMember_AndDoNotEraseMeasuredBodies() { diff --git a/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs b/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs index d8b61e0..4a79712 100644 --- a/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs +++ b/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs @@ -62,4 +62,22 @@ public async Task UnbalancedVisualBasicType_IsAnInputErrorNamingTheMember() await Assert.That(error.Message).StartsWith("Metrics member display 'Sub A.M(x As c + .Method(method, signature, null, m => m.Line(1, 1))).ToBytes()); + var metrics = CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method(display, 2, 1)); + + var report = CrapAnalysis.Analyze(CoberturaParser.ParseMethods(input), metrics); + + await Assert.That(report.UnmatchedMetricsMembers).IsEmpty(); + await Assert.That(report.Methods.Single().Complexity).IsEqualTo(2); + // The raw display stays what Roslyn wrote, for unmatched listings. + await Assert.That(metrics.Single().DisplayName).IsEqualTo(display); + } }