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/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index d6fd8d1..23b82f5 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -103,6 +103,21 @@ 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'): + 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 coverage 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; diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3f59d..16d27a1 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,42 @@ 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. +- 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. + 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 + 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; + 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 (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 + 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 1d5b625..045677f 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 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; 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/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/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..d6b2043 --- /dev/null +++ b/docs/releases/3.0.1.md @@ -0,0 +1,62 @@ +# DotCov 3.0.1 + +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. + +## CRAP gate + +- **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 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. +- **`--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 + return type, so their coverage cannot be told apart. + +## 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. A + malformed metrics member is reported with the metrics file and the member's name. + +## 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 + +```bash +dotnet tool update -g DotCov.Tool --version 3.0.1 +``` + +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.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/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/CoberturaParser.Methods.cs b/src/DotCov/CoberturaParser.Methods.cs index d484b7d..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); } /// @@ -61,10 +61,14 @@ public static MethodCoverageReport ParseMethods( { throw new ReportParseException(input.SourceName, ex); } + catch (InvalidDataException ex) + { + throw new InvalidDataException($"{input.SourceName}: {ex.Message}", ex); + } } scope.RequireDeclaredRoot(); - return methods.Materialize(); + return methods.Materialize(identity?.SourceRoot); } /// Walk one document into ; roots are per document, warnings accumulate. @@ -115,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/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 fefabb3..6f97cf4 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; @@ -38,16 +39,32 @@ 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. -/// Roslyn's cyclomatic complexity for the member. -/// The raw Name attribute, kept verbatim for unmatched-member listings. +/// Parameter count from a method or indexer display, including the value +/// parameter for indexer setters; otherwise null. +/// 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, 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; } + + /// 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; } +} /// /// Streaming reader for the Microsoft.CodeAnalysis.Metrics XML shape @@ -60,11 +77,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 class CodeMetricsReader +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) @@ -97,13 +116,21 @@ 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) + 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; + public int? LegacyExecutableLines; + public bool HasExecutableLines; } /// The document position ParseCore threads through its element handlers. @@ -111,7 +138,8 @@ 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 TypeContext? CachedType; public readonly Stack MemberStack = new(); // Property → its Accessor methods nest } @@ -137,16 +165,22 @@ 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) - state.TypeChain.Add(TypeSegment(reader.GetAttribute("Name") ?? "")); + { + 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": - RecordComplexity(reader, state); + RecordMetric(reader, state); break; default: @@ -161,11 +195,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: @@ -182,39 +218,84 @@ 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)); + { + 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 + { + MethodSignatures.Metrics(display); + } + catch (InvalidDataException ex) + { + throw new InvalidDataException($"{context}: {ex.Message}", ex); + } + state.MemberStack.Push(new PendingMember(display, reader.LocalName, reader.GetAttribute("File"))); + } } 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.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 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) return; + var pending = state.MemberStack.Peek(); + 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; + break; + case "ExecutableLines": + pending.ExecutableLines = value >= 0 ? value : null; + break; + case "LinesOfCode": + pending.LegacyExecutableLines = value >= 0 ? value : null; + break; + } } - 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); - 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}"; + } + + return new TypeContext(typeName, typeIdentity, SimpleName(TypeSegment(typeChain[^1]))); + } - var enclosingSimpleName = SimpleName(typeChain[^1]); - var (memberName, arity, kind) = ParseDisplay(pending.Display, pending.Element, enclosingSimpleName); + private static CodeMetricsMember Materialize(PendingMember pending, TypeContext type, int complexity) + { + var (memberName, arity, kind) = ParseDisplay(MethodSignatures.VisualBasicDisplay(pending.Display), pending.Element, type.SimpleName); - return new CodeMetricsMember(typeName, memberName, kind, arity, complexity, pending.Display); + return new CodeMetricsMember(type.Name, memberName, kind, arity, complexity, pending.Display) + { + ExecutableLines = pending.HasExecutableLines ? pending.ExecutableLines : pending.LegacyExecutableLines, + File = pending.File, + TypeIdentity = type.Identity + }; } private static string SimpleName(string dottedType) @@ -228,13 +309,18 @@ 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 dot = BalancedText.LastIndexOfTopLevel(stripped, '.'); - return dot >= 0 && stripped.AsSpan(dot + 1).StartsWith("extension(", StringComparison.Ordinal) - ? stripped[..dot] - : stripped; + 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); } /// @@ -287,8 +373,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); @@ -320,14 +406,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; } @@ -381,8 +475,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(')'); @@ -390,6 +483,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/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..0ab9f2a 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; @@ -118,7 +119,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 +145,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"); @@ -200,11 +207,14 @@ 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); + 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) { AmbiguousFiles = report.AmbiguousFiles, - Exclusion = report.Exclusion + Exclusion = exclusion }; } @@ -222,18 +232,160 @@ 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 excluded = FoldRawEntries(excludedMethods); + FoldSyntheticGroups(excluded); + metricsMembers = PrepareMetrics(metricsMembers, order.Concat(excluded)); + var metricSignatures = metricsMembers?.Select(static m => MethodSignatures.Metrics(m.DisplayName)).ToArray() ?? []; + var qualifications = MethodSignatures.TypeQualifications(order.Concat(excluded).Select(static m => m.Signature), + metricSignatures); + foreach (var logical in order.Concat(excluded)) + logical.Signature = MethodSignatures.QualifyTypes(logical.Signature, qualifications); + 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(); - var (scored, unscored) = ScoreMethods(order, metricsMembers, consumed); // 3. resolve comp, score + // 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, 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, 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) + .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); + ReconcileSyntheticOverloads(order, metricsIndex, 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) + .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 && + 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.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; + 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. + private static void MatchExcludedMetrics( + LogicalMethod logical, + MetricsIndex metricsIndex, + HashSet consumed) + { + var key = $"{logical.Name.TypeIdentity}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; + var compatible = CompatibleMetrics(metricsIndex, key, logical.Arity, logical.Signature, logical.File); + 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. + // 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) + { + 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 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); + } + } + + private static IReadOnlyList? PrepareMetrics( + IReadOnlyList? members, IEnumerable methods) + { + 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, []) + }).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; + } + /// /// 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 @@ -266,11 +418,15 @@ 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); } + // 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); } @@ -310,9 +466,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(); @@ -328,7 +484,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, @@ -356,6 +512,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; @@ -364,13 +521,14 @@ private static List CollectUnmatched(IReadOnlyList? m /// 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 string? Signature = signature; /// 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(); @@ -408,22 +566,69 @@ public void MergeEmbeddedComplexity(int? complexity) } } - private static (Dictionary> Index, Dictionary AccessorFallback) IndexMetrics( - IReadOnlyList? members) + private readonly record struct MetricsCandidates(int? Complexity, int? UniqueMember, int ExecutableCount, + bool HasUnknownSignature) + { + public bool SignatureMismatch { get; init; } + } + + 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); + 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) { - var index = new Dictionary>(StringComparer.Ordinal); - var accessorFallback = new Dictionary(StringComparer.Ordinal); - if (members is null) return (index, accessorFallback); + 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, + left.HasUnknownSignature || right.HasUnknownSignature); + } + + 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, string?[] signatures, + bool onlyWithoutSource = false) + { + var index = new MetricsIndex(); + if (members is null) return index; for (var i = 0; i < members.Count; i++) { 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.TypeName}|{m.MemberName}"; - if (!index.TryGetValue(key, out var list)) index[key] = list = []; - list.Add(i); + var key = $"{m.TypeIdentity ?? m.TypeName}|{m.MemberName}"; + var signature = signatures[i]; + // 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); + 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 + // blocks. An inferred receiver must not prove a match for a static member. break; case CodeMetricsMemberKind.Property: @@ -431,31 +636,29 @@ private static (Dictionary> Index, Dictionary Acc // 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; - accessorFallback.TryAdd($"{m.TypeName}|get_{m.MemberName}", i); - accessorFallback.TryAdd($"{m.TypeName}|set_{m.MemberName}", i); + 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; } } - return (index, accessorFallback); + return index; } /// - /// 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. /// 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); @@ -464,45 +667,36 @@ 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, - 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)) + var key = $"{logical.Name.TypeIdentity}|{MethodIdentity.MetricsMemberName(logical.Name.Method)}"; + var scoped = metricsIndex.ByFile.GetValueOrDefault(PathIdentity.NormalizeRoot(logical.File)); + if (metricsIndex.All.ContainsKey(key) || (scoped?.All.ContainsKey(key) ?? false)) { - // 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. - var pool = candidates; - if (logical.Arity is { } a) - { - var exact = candidates.Where(i => members[i].Arity == a).ToList(); - if (exact.Count > 0) pool = exact; - } - - var best = 0; - foreach (var i in pool) - { - consumed.Add(i); - best = Math.Max(best, members[i].CyclomaticComplexity); - } - return best; + // 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, logical.File); + if (compatible.SignatureMismatch) return null; + if (compatible.UniqueMember is { } index && !consumed.Add(index)) return null; + return compatible.Complexity ?? MergeCandidates(metricsIndex.All.GetValueOrDefault(key), + scoped?.All.GetValueOrDefault(key) ?? default).Complexity; } - if (accessorFallback.TryGetValue(key, out var propertyIndex)) + var propertyFound = scoped is not null && scoped.AccessorFallback.TryGetValue(key, out _); + var propertyIndex = propertyFound ? scoped! : metricsIndex; + if (propertyIndex.AccessorFallback.TryGetValue(key, out var property)) { - consumed.Add(propertyIndex); - return members[propertyIndex].CyclomaticComplexity; + // 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; } return null; diff --git a/src/DotCov/MethodCoverageReport.cs b/src/DotCov/MethodCoverageReport.cs index bfede36..f885801 100644 --- a/src/DotCov/MethodCoverageReport.cs +++ b/src/DotCov/MethodCoverageReport.cs @@ -41,6 +41,41 @@ 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; } = []; + + // 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; } = []; + + 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 @@ -55,11 +90,16 @@ 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) + Exclusion = Exclusion.Then(rules, keepRules, excluded), + ExclusionSteps = [.. ExclusionSteps, (rules, keepRules)], + SelectedSourceRoot = SelectedSourceRoot, + ExcludedMethods = ExcludedMethods.Concat( + Methods.Where(m => excludedPaths.Contains(m.File))).ToArray() }; } } diff --git a/src/DotCov/MethodIdentity.cs b/src/DotCov/MethodIdentity.cs index 93e949e..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); @@ -81,6 +90,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/src/DotCov/MethodSignatures.cs b/src/DotCov/MethodSignatures.cs new file mode 100644 index 0000000..eab0dc2 --- /dev/null +++ b/src/DotCov/MethodSignatures.cs @@ -0,0 +1,395 @@ +using System.Text.RegularExpressions; +using System.Text; + +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", + ["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(@"(?, 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. + private static readonly Regex TypeNamePattern = new(@"[\p{L}_][\w.]*", + RegexOptions.CultureInvariant | RegexOptions.NonBacktracking); + + internal static Dictionary TypeQualifications(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 TypeNamePattern.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 && (includeUnqualified || prior is null || + !prior.EndsWith("." + name, StringComparison.Ordinal))) names[simple] = null; + } + } + } + + internal static string? QualifyTypes(string? signature, Dictionary names, + bool fromMetrics = false) + { + 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 => + { + 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; + }); + } + + /// + /// 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) + { + var function = display.StartsWith("Function ", StringComparison.Ordinal); + if (!function && !display.StartsWith("Sub ", StringComparison.Ordinal)) return display; + var end = display.LastIndexOf(") As ", StringComparison.Ordinal); + 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) + throw new InvalidDataException($"{identity} exceeds 16384 characters."); + } + + internal sealed class NullableIdentities(IEnumerable coverage) + { + 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); + // 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) + { + 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)) + { + // 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, '>'); + } + 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 signature; + 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"); + 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)); + } + + internal static string? Metrics(string display) + { + ValidateLength(display, "Metrics member display"); + display = VisualBasicDisplay(display); + string parameters; + string? valueType = null; + if (display.EndsWith(')')) + { + var open = CodeMetricsReader.ParameterListOpen(display); + if (open < 0) return null; + parameters = display[(open + 1)..^1]; + } + else + { + var indexer = display.IndexOf(".this[", StringComparison.Ordinal); + var close = display.LastIndexOf(']'); + if (indexer < 0 || close < indexer) return null; + var accessor = display[(close + 1)..]; + if (accessor is not (".get" or ".set" or ".init")) return null; + parameters = display[(indexer + 6)..close]; + if (accessor is not ".get") + { + var space = BalancedText.LastIndexOfTopLevel(display.AsSpan(0, indexer), ' '); + if (space < 0) return null; + valueType = display[..space]; + if (valueType is "void") return null; + } + } + var types = new List(); + foreach (var parameter in BalancedText.SplitTopLevel(parameters)) + { + 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])); + } + if (valueType is not null) types.Add(NormalizeType(valueType)); + 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; + 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 is not 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 is 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 = 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..]; + // 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. + 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/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/CodeMetricsReaderEdgeTests.cs b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs index 647b3bd..94f18ca 100644 --- a/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs +++ b/tests/DotCov.Tests/CodeMetricsReaderEdgeTests.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Xml; namespace DotCov.Tests; @@ -252,6 +253,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() { @@ -398,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($""" @@ -406,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] @@ -422,4 +439,35 @@ public async Task Parse_LeavesTheCallersStreamOpen() await Assert.That(members.Count).IsEqualTo(1); await Assert.That(stream.CanRead).IsTrue(); } + [Test] + public async Task DeepTypeScope_WithManyMembers_PreservesNamesWhenReturningToParent() + { + const int depth = 256; + const int count = 1000; + var xml = new StringBuilder(""); + 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/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/CodeMetricsReportBuilder.cs b/tests/DotCov.Tests/CodeMetricsReportBuilder.cs index 6db35cf..d24886e 100644 --- a/tests/DotCov.Tests/CodeMetricsReportBuilder.cs +++ b/tests/DotCov.Tests/CodeMetricsReportBuilder.cs @@ -67,18 +67,22 @@ 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/CrapAnalysisEdgeTests.cs b/tests/DotCov.Tests/CrapAnalysisEdgeTests.cs index e5324cd..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 arity differs from the display and the name match decides. + // 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 }); } @@ -226,7 +228,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 +242,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/CrapAnalysisTests.cs b/tests/DotCov.Tests/CrapAnalysisTests.cs index 3f36375..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() { @@ -464,6 +485,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 +571,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/CrapFileLocalAndConversionTests.cs b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs new file mode 100644 index 0000000..7228d33 --- /dev/null +++ b/tests/DotCov.Tests/CrapFileLocalAndConversionTests.cs @@ -0,0 +1,212 @@ +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>int 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); + } + + [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); + } + + [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); + } + + // 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() + { + 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); + } +} 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/CrapMetricsIdentityTests.cs b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs new file mode 100644 index 0000000..ff5bbca --- /dev/null +++ b/tests/DotCov.Tests/CrapMetricsIdentityTests.cs @@ -0,0 +1,237 @@ +using DotCov.Tests.Infrastructure; +using DotCov.Tool; + +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(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] + 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)] + [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()"), + 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() + { + 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")] + 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/CrapMetricsScopeTests.cs b/tests/DotCov.Tests/CrapMetricsScopeTests.cs new file mode 100644 index 0000000..6eb14b3 --- /dev/null +++ b/tests/DotCov.Tests/CrapMetricsScopeTests.cs @@ -0,0 +1,613 @@ +using System.Text; +using System.Text.Json; +using DotCov.Tests.Infrastructure; +using DotCov.Tool; + +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.NoData)] + [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.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(unknownLines + exactLines == 2 ? 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() + { + 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)")] + [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_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))) + .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.Count).IsEqualTo(1); + await Assert.That(report.Methods.Count).IsEqualTo(exclude ? 1 : 2); + await Assert.That(report.Evaluate(30).Outcome).IsEqualTo(GateOutcome.NoData); + } + + [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(1); + await Assert.That(report.UnmatchedMetricsMembers) + .IsEquivalentTo(["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("1 metrics members without coverage data"); + } + + [Test] + [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)] + [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("1 metrics members without coverage data"); + } + + [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] + [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); + + 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 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] + [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() + { + 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"); + } + + [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"); + } + + [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); + } + + [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() + .AddClass("src/A.cs", "App.A", c => c.Method("Kept", "()", "1", m => m.Line(1, 1))).ToBytes()); + return CoberturaParser.ParseMethods(input); + } +} diff --git a/tests/DotCov.Tests/CrapSignatureMatchingTests.cs b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs new file mode 100644 index 0000000..7879641 --- /dev/null +++ b/tests/DotCov.Tests/CrapSignatureMatchingTests.cs @@ -0,0 +1,206 @@ +using DotCov.Tests.Infrastructure; + +namespace DotCov.Tests; + +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&")] + [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", "System.Collections.Generic.List`1")] + [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")] + [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[]")] + [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) + { + 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); + } + [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); + } + + [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/CrapTupleAndRootTests.cs b/tests/DotCov.Tests/CrapTupleAndRootTests.cs new file mode 100644 index 0000000..4a48c18 --- /dev/null +++ b/tests/DotCov.Tests/CrapTupleAndRootTests.cs @@ -0,0 +1,114 @@ +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); + } + + // 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 ParenthesesInCoverageSignatures_AreUnknown_NotErrors(string shape) + { + using var input = new MemoryStream(Cobertura.NewDoc().AddClass("src/A.cs", "App.A", c => c + .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) + { + var display = $"void A.M({TupleShape(shape)} x)"; + + var error = Assert.Throws(() => + CodeMetricsReportBuilder.ParseType("App", "A", m => m.Method(display, 2, 1))); + + 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 + // 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() + { + 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); + } +} diff --git a/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs new file mode 100644 index 0000000..b58ac46 --- /dev/null +++ b/tests/DotCov.Tests/CrapTypeAndConstructorTests.cs @@ -0,0 +1,124 @@ +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(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")] + [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); + } + [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); + } + +} diff --git a/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs b/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs new file mode 100644 index 0000000..4a79712 --- /dev/null +++ b/tests/DotCov.Tests/CrapVisualBasicMetricsTests.cs @@ -0,0 +1,83 @@ +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 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); + } +} 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"]); } } 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); }