From 55d2aa1567475976a15418bc162d8e8562e64007 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:44 +0100 Subject: [PATCH 01/18] =?UTF-8?q?=F0=9F=90=9B=20resolve=20highest=20matchi?= =?UTF-8?q?ng=20shared=20framework=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the shared framework directory by parsed NuGet version instead of string prefix matching, so prerelease and non-numeric folders no longer break runtime resolution. A regression test covers the selection rules. --- .../Instrumentation/CecilAssemblyResolver.cs | 15 +++++--- .../Instrumentation/InstrumenterTests.cs | 34 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs b/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs index 6df84950b..cfb43192f 100644 --- a/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs +++ b/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs @@ -478,10 +478,7 @@ public NetCoreSharedFrameworkResolver(string modulePath, ILogger logger) { var semVersion = NuGetVersion.Parse(frameworkVersion); var directory = new DirectoryInfo(Path.Combine(runtimeRootPath, frameworkName)); - string majorVersion = $"{semVersion.Major}.{semVersion.Minor}."; - uint latestVersion = directory.GetDirectories().Where(x => x.Name.StartsWith(majorVersion)) - .Select(x => Convert.ToUInt32(x.Name.Substring(majorVersion.Length))).Max(); - string resolvedPath = Directory.GetDirectories(directory.FullName, majorVersion + $"{latestVersion}*", SearchOption.TopDirectoryOnly)[0]; + string resolvedPath = GetSharedFrameworkPath(directory, semVersion); if (!_aspNetSharedFrameworkDirs.Any(path => path.Equals(resolvedPath, StringComparison.OrdinalIgnoreCase))) { _aspNetSharedFrameworkDirs.Add(resolvedPath); @@ -506,6 +503,16 @@ public NetCoreSharedFrameworkResolver(string modulePath, ILogger logger) } } + internal static string GetSharedFrameworkPath(DirectoryInfo directory, NuGetVersion frameworkVersion) + { + return directory.GetDirectories() + .Select(path => (Path: path, Parsed: NuGetVersion.TryParse(path.Name, out NuGetVersion version) ? version : null)) + .Where(item => item.Parsed != null && item.Parsed.Major == frameworkVersion.Major && + item.Parsed.Minor == frameworkVersion.Minor && (frameworkVersion.IsPrerelease || !item.Parsed.IsPrerelease)) + .OrderByDescending(item => item.Parsed) + .First().Path.FullName; + } + public bool TryResolveAssemblyPaths(CompilationLibrary library, List assemblies) { string dllName = $"{library.Name}.dll"; diff --git a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs index c94a31a24..b95ffb125 100644 --- a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs +++ b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs @@ -751,6 +751,29 @@ public void TestInstrument_NetstandardAwareAssemblyResolver_PreserveCompilationC Assert.NotNull(asm); } + [Theory] + [InlineData("11.0.0-test.1", "11.0.10-test.1")] + [InlineData("11.0.0", "11.0.2")] + public void TestInstrument_NetCoreSharedFrameworkResolver_SelectsMatchingVersion(string requestedVersion, string expectedDirectory) + { + string tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + try + { + foreach (string name in new[] { "10.0.99", "11.0.0-test.1", "11.0.0-test.2", "11.0.2", "11.0.10-test.1", "11.0.invalid" }) + { + Directory.CreateDirectory(Path.Combine(tempDirectory, name)); + } + + string path = NetCoreSharedFrameworkResolver.GetSharedFrameworkPath(new DirectoryInfo(tempDirectory), NuGet.Versioning.NuGetVersion.Parse(requestedVersion)); + Assert.Equal(Path.Combine(tempDirectory, expectedDirectory), path); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public void TestInstrument_NetstandardAwareAssemblyResolver_SiblingRuntimeConfigCanResolveSharedFrameworkAssembly() { @@ -762,17 +785,6 @@ public void TestInstrument_NetstandardAwareAssemblyResolver_SiblingRuntimeConfig string runtimeVersion = new DirectoryInfo(Path.GetDirectoryName(typeof(object).Assembly.Location)!).Name; var runtimeAssemblyVersion = typeof(object).Assembly.GetName().Version; - string runtimeRootPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "..", "..")); - string runtimeDirectory = Path.Combine(runtimeRootPath, "Microsoft.NETCore.App"); - string majorVersion = $"{runtimeAssemblyVersion.Major}.{runtimeAssemblyVersion.Minor}."; - - if (Directory.GetDirectories(runtimeDirectory, majorVersion + "*", SearchOption.TopDirectoryOnly) - .Select(Path.GetFileName) - .Any(name => !uint.TryParse(name[majorVersion.Length..], out _))) - { - return; - } - string runtimeConfigFile = Path.Combine(tempDirectory, "testhost.runtimeconfig.json"); File.WriteAllText(runtimeConfigFile, "{\n" + From 632b8b4853ea06b92be8c795c46ec4415aaba40d Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:45 +0100 Subject: [PATCH 02/18] =?UTF-8?q?=F0=9F=94=A7=20retarget=20library=20proje?= =?UTF-8?q?cts=20to=20.NET=2010=20and=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retained package surface no longer targets .NET 9, so the library projects and the SDK pin move to .NET 10 and .NET 11. --- global.json | 2 +- src/coverlet.MTP/coverlet.MTP.csproj | 6 +++--- src/coverlet.core/coverlet.core.csproj | 9 ++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/global.json b/global.json index 0777cf772..7e0263952 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.401", + "version": "11.0.100-rc.3.26522.148", "rollForward": "latestFeature" }, "test": { diff --git a/src/coverlet.MTP/coverlet.MTP.csproj b/src/coverlet.MTP/coverlet.MTP.csproj index 03062aa46..0fad3410c 100644 --- a/src/coverlet.MTP/coverlet.MTP.csproj +++ b/src/coverlet.MTP/coverlet.MTP.csproj @@ -1,7 +1,7 @@ - netstandard2.0;net9.0;net10.0 + netstandard2.0;net10.0;net11.0 Coverlet.MTP true enable @@ -35,7 +35,7 @@ https://github.com/codebeltnet/coverlet coverlet-icon.png false - Cross-platform code coverage for .NET with explicit TFM support and framework-aligned dependencies. + Cross-platform code coverage for .NET with explicit target framework support. coverage;microsoft-testing-platform;code-coverage Coverlet.MTP.Integration.md https://github.com/codebeltnet/coverlet/releases @@ -55,7 +55,7 @@ - + diff --git a/src/coverlet.core/coverlet.core.csproj b/src/coverlet.core/coverlet.core.csproj index 8522f42c8..6a7e3237b 100644 --- a/src/coverlet.core/coverlet.core.csproj +++ b/src/coverlet.core/coverlet.core.csproj @@ -2,7 +2,7 @@ Library - netstandard2.0;net9.0;net10.0 + netstandard2.0;net10.0;net11.0 false $(NoWarn);IDE0057 @@ -13,8 +13,7 @@ @@ -22,8 +21,8 @@ - - + + From 3d03069c5eed78a6fc18e6c12ed92b7f7d304000 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:45 +0100 Subject: [PATCH 03/18] =?UTF-8?q?=F0=9F=93=8C=20pin=20.NET=2011=20runtime?= =?UTF-8?q?=20package=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime packages are selected per target framework so newer generation assets never land in older TFM groups. --- Directory.Packages.props | 50 +++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index cfd9a4250..ff114f5ee 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,17 +4,10 @@ true - - 10.0.13 - 9.0.21 + 10.0.14 + 11.0.0-rc.3.26522.148 $(MicrosoftNet10RuntimeVersion) - $(MicrosoftNet9RuntimeVersion) + $(MicrosoftNet11RuntimeVersion) 18.10.1 2.4.1 4.20.72 @@ -24,14 +17,7 @@ - - - - - - - @@ -46,14 +32,36 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + From 792f31a3421005b8b747bc718c2b5e96c170997b Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:45 +0100 Subject: [PATCH 04/18] =?UTF-8?q?=E2=9C=85=20retarget=20test=20projects=20?= =?UTF-8?q?to=20.NET=2010=20and=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test projects run on the same target matrix as the packages, and the .NET 9 specific package override is no longer needed. --- test/coverlet.MTP.tests/coverlet.MTP.tests.csproj | 2 +- .../BasicTestProject/BasicTestProject.csproj | 2 +- .../coverlet.MTP.validation.tests.csproj | 2 +- .../coverlet.core.coverage.tests.csproj | 5 ++--- test/coverlet.core.tests/coverlet.core.tests.csproj | 9 ++------- ...verlet.tests.projectsample.excludedbyattribute.csproj | 2 +- .../coverlet.tests.projectsample.fsharp.fsproj | 2 +- test/coverlet.tests.utils/TestUtils.cs | 6 +++--- test/coverlet.tests.utils/coverlet.tests.utils.csproj | 2 +- 9 files changed, 13 insertions(+), 19 deletions(-) diff --git a/test/coverlet.MTP.tests/coverlet.MTP.tests.csproj b/test/coverlet.MTP.tests/coverlet.MTP.tests.csproj index 259ce7fc2..e9d30b956 100644 --- a/test/coverlet.MTP.tests/coverlet.MTP.tests.csproj +++ b/test/coverlet.MTP.tests/coverlet.MTP.tests.csproj @@ -1,7 +1,7 @@  Exe - net9.0;net10.0 + net10.0;net11.0 enable enable true diff --git a/test/coverlet.MTP.validation.tests/TestProjects/BasicTestProject/BasicTestProject.csproj b/test/coverlet.MTP.validation.tests/TestProjects/BasicTestProject/BasicTestProject.csproj index 0723331a6..363d4b7fd 100644 --- a/test/coverlet.MTP.validation.tests/TestProjects/BasicTestProject/BasicTestProject.csproj +++ b/test/coverlet.MTP.validation.tests/TestProjects/BasicTestProject/BasicTestProject.csproj @@ -3,7 +3,7 @@ - net9.0;net10.0 + net10.0;net11.0 12.0 Exe false diff --git a/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj b/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj index f1e21ca86..224a00c68 100644 --- a/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj +++ b/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj @@ -5,7 +5,7 @@ enable enable Exe - net9.0;net10.0 + net10.0;net11.0 true true false diff --git a/test/coverlet.core.coverage.tests/coverlet.core.coverage.tests.csproj b/test/coverlet.core.coverage.tests/coverlet.core.coverage.tests.csproj index ba30fde0c..d0a932364 100644 --- a/test/coverlet.core.coverage.tests/coverlet.core.coverage.tests.csproj +++ b/test/coverlet.core.coverage.tests/coverlet.core.coverage.tests.csproj @@ -1,7 +1,7 @@ - net9.0;net10.0 + net10.0;net11.0 Exe portable true @@ -20,8 +20,7 @@ - - + diff --git a/test/coverlet.core.tests/coverlet.core.tests.csproj b/test/coverlet.core.tests/coverlet.core.tests.csproj index e2c444906..c3028476d 100644 --- a/test/coverlet.core.tests/coverlet.core.tests.csproj +++ b/test/coverlet.core.tests/coverlet.core.tests.csproj @@ -1,7 +1,7 @@  - net9.0;net10.0 + net10.0;net11.0 Exe portable true @@ -26,7 +26,6 @@ - all @@ -34,11 +33,7 @@ - - - - - + diff --git a/test/coverlet.tests.projectsample.excludedbyattribute/coverlet.tests.projectsample.excludedbyattribute.csproj b/test/coverlet.tests.projectsample.excludedbyattribute/coverlet.tests.projectsample.excludedbyattribute.csproj index cba154e8c..133db12be 100644 --- a/test/coverlet.tests.projectsample.excludedbyattribute/coverlet.tests.projectsample.excludedbyattribute.csproj +++ b/test/coverlet.tests.projectsample.excludedbyattribute/coverlet.tests.projectsample.excludedbyattribute.csproj @@ -1,7 +1,7 @@ - net9.0;net10.0 + net10.0;net11.0 false false diff --git a/test/coverlet.tests.projectsample.fsharp/coverlet.tests.projectsample.fsharp.fsproj b/test/coverlet.tests.projectsample.fsharp/coverlet.tests.projectsample.fsharp.fsproj index b7f8f5ffb..d90688cd7 100644 --- a/test/coverlet.tests.projectsample.fsharp/coverlet.tests.projectsample.fsharp.fsproj +++ b/test/coverlet.tests.projectsample.fsharp/coverlet.tests.projectsample.fsharp.fsproj @@ -1,7 +1,7 @@ - net9.0;net10.0 + net10.0;net11.0 true false false diff --git a/test/coverlet.tests.utils/TestUtils.cs b/test/coverlet.tests.utils/TestUtils.cs index ebab1dc1f..e5c4ea617 100644 --- a/test/coverlet.tests.utils/TestUtils.cs +++ b/test/coverlet.tests.utils/TestUtils.cs @@ -38,11 +38,11 @@ public static BuildConfiguration GetAssemblyBuildConfiguration() public static string GetAssemblyTargetFramework() { -#if NET9_0 - return "net9.0"; -#endif #if NET10_0 return "net10.0"; +#endif +#if NET11_0 + return "net11.0"; #endif throw new NotSupportedException($"Build configuration not supported"); } diff --git a/test/coverlet.tests.utils/coverlet.tests.utils.csproj b/test/coverlet.tests.utils/coverlet.tests.utils.csproj index a4b1fb79b..470b6a5d0 100644 --- a/test/coverlet.tests.utils/coverlet.tests.utils.csproj +++ b/test/coverlet.tests.utils/coverlet.tests.utils.csproj @@ -1,7 +1,7 @@ - netstandard2.0;net9.0;net10.0 + netstandard2.0;net10.0;net11.0 false false False From d02f949838a73cfe2a162cb07a4c86647c95084c Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:46 +0100 Subject: [PATCH 05/18] =?UTF-8?q?=E2=9C=85=20update=20validation=20tests?= =?UTF-8?q?=20for=20.NET=2011=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation tests read the .NET 11 runtime version from Directory.Packages.props and assert that the package carries no .NET 9 assets. --- .../PackageLayoutTests.cs | 11 +++++----- .../TestInfrastructure/MtpPackageVersions.cs | 20 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/test/coverlet.MTP.validation.tests/PackageLayoutTests.cs b/test/coverlet.MTP.validation.tests/PackageLayoutTests.cs index 6601a9ae5..4eaf233b9 100644 --- a/test/coverlet.MTP.validation.tests/PackageLayoutTests.cs +++ b/test/coverlet.MTP.validation.tests/PackageLayoutTests.cs @@ -9,7 +9,7 @@ namespace Coverlet.MTP.validation.tests; [Collection(nameof(MtpValidationTests))] public class PackageLayoutTests : MtpValidationTestBase { - private static readonly string[] s_supportedTfms = ["netstandard2.0", "net9.0", "net10.0"]; + private static readonly string[] s_supportedTfms = ["netstandard2.0", "net10.0", "net11.0"]; [Fact] public void PackageContainsOnlySupportedFrameworkAssets() @@ -40,6 +40,7 @@ public void PackageContainsOnlySupportedFrameworkAssets() Assert.Contains("lib/netstandard2.0/System.Text.Json.dll", files); Assert.DoesNotContain(files, file => file.Contains("net8.0", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(files, file => file.Contains("net9.0", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(files, file => file.Contains("coverlet.console", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(files, file => file.Contains("coverlet.collector", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(files, file => file.Contains("coverlet.msbuild", StringComparison.OrdinalIgnoreCase)); @@ -70,10 +71,10 @@ public void NuspecContainsFrameworkAlignedDependencyGroups() AssertDependency(dependencyGroups["netstandard2.0"], "System.Buffers", "4.6.1"); AssertDependency(dependencyGroups["netstandard2.0"], "System.Memory", "4.6.3"); - AssertDependency(dependencyGroups["net9.0"], "Microsoft.Extensions.Configuration", MtpPackageVersions.MicrosoftNet9Runtime); - AssertDependency(dependencyGroups["net9.0"], "Microsoft.Extensions.Configuration.Json", MtpPackageVersions.MicrosoftNet9Runtime); - AssertDependency(dependencyGroups["net9.0"], "Microsoft.Extensions.DependencyInjection", MtpPackageVersions.MicrosoftNet9Runtime); - AssertDependency(dependencyGroups["net9.0"], "Microsoft.Testing.Platform", MtpPackageVersions.MicrosoftTestingPlatform); + AssertDependency(dependencyGroups["net11.0"], "Microsoft.Extensions.Configuration", MtpPackageVersions.MicrosoftNet11Runtime); + AssertDependency(dependencyGroups["net11.0"], "Microsoft.Extensions.Configuration.Json", MtpPackageVersions.MicrosoftNet11Runtime); + AssertDependency(dependencyGroups["net11.0"], "Microsoft.Extensions.DependencyInjection", MtpPackageVersions.MicrosoftNet11Runtime); + AssertDependency(dependencyGroups["net11.0"], "Microsoft.Testing.Platform", MtpPackageVersions.MicrosoftTestingPlatform); AssertDependency(dependencyGroups["net10.0"], "Microsoft.Extensions.Configuration", MtpPackageVersions.MicrosoftNet10Runtime); AssertDependency(dependencyGroups["net10.0"], "Microsoft.Extensions.Configuration.Json", MtpPackageVersions.MicrosoftNet10Runtime); diff --git a/test/coverlet.MTP.validation.tests/TestInfrastructure/MtpPackageVersions.cs b/test/coverlet.MTP.validation.tests/TestInfrastructure/MtpPackageVersions.cs index c24cab9d3..e57ba882a 100644 --- a/test/coverlet.MTP.validation.tests/TestInfrastructure/MtpPackageVersions.cs +++ b/test/coverlet.MTP.validation.tests/TestInfrastructure/MtpPackageVersions.cs @@ -12,7 +12,7 @@ namespace Coverlet.MTP.validation.tests; /// internal static class MtpPackageVersions { - private static readonly Lazy<(string MicrosoftTestingPlatform, string XunitV3, string MicrosoftNet9Runtime, string MicrosoftNet10Runtime)> s_versions = + private static readonly Lazy<(string MicrosoftTestingPlatform, string XunitV3, string MicrosoftNet10Runtime, string MicrosoftNet11Runtime)> s_versions = new(ReadVersionsFromProps); /// Gets the value of MicrosoftTestingPlatformVersion from Directory.Packages.props. @@ -21,13 +21,13 @@ internal static class MtpPackageVersions /// Gets the value of XunitV3Version from Directory.Packages.props. internal static string XunitV3 => s_versions.Value.XunitV3; - /// Gets the value of MicrosoftNet9RuntimeVersion from Directory.Packages.props. - internal static string MicrosoftNet9Runtime => s_versions.Value.MicrosoftNet9Runtime; - /// Gets the value of MicrosoftNet10RuntimeVersion from Directory.Packages.props. internal static string MicrosoftNet10Runtime => s_versions.Value.MicrosoftNet10Runtime; - private static (string MicrosoftTestingPlatform, string XunitV3, string MicrosoftNet9Runtime, string MicrosoftNet10Runtime) ReadVersionsFromProps() + /// Gets the value of MicrosoftNet11RuntimeVersion from Directory.Packages.props. + internal static string MicrosoftNet11Runtime => s_versions.Value.MicrosoftNet11Runtime; + + private static (string MicrosoftTestingPlatform, string XunitV3, string MicrosoftNet10Runtime, string MicrosoftNet11Runtime) ReadVersionsFromProps() { string? dir = AppContext.BaseDirectory; while (!string.IsNullOrEmpty(dir)) @@ -50,15 +50,15 @@ private static (string MicrosoftTestingPlatform, string XunitV3, string Microsof ?? throw new InvalidOperationException( $"Property 'XunitV3Version' not found in '{candidate}'."); - string net9Runtime = GetProp("MicrosoftNet9RuntimeVersion") - ?? throw new InvalidOperationException( - $"Property 'MicrosoftNet9RuntimeVersion' not found in '{candidate}'."); - string net10Runtime = GetProp("MicrosoftNet10RuntimeVersion") ?? throw new InvalidOperationException( $"Property 'MicrosoftNet10RuntimeVersion' not found in '{candidate}'."); - return (mtp, xunit, net9Runtime, net10Runtime); + string net11Runtime = GetProp("MicrosoftNet11RuntimeVersion") + ?? throw new InvalidOperationException( + $"Property 'MicrosoftNet11RuntimeVersion' not found in '{candidate}'."); + + return (mtp, xunit, net10Runtime, net11Runtime); } dir = Path.GetDirectoryName(dir); From 7ec7f270844843ce960429ed46a8655ee46a3f34 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:46 +0100 Subject: [PATCH 06/18] =?UTF-8?q?=F0=9F=91=B7=20run=20ci=20matrix=20on=20.?= =?UTF-8?q?NET=2010=20and=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI test matrix runs on the same two targets the package supports. --- .github/workflows/ci-pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 01c73e172..8d91a9f2b 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -89,7 +89,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-26.04, windows-2025, macos-26] - framework: [net9.0, net10.0] + framework: [net10.0, net11.0] permissions: contents: read steps: From 709c93e8dcc97c560bdcf127604f00401f017847 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:46 +0100 Subject: [PATCH 07/18] =?UTF-8?q?=F0=9F=90=B3=20use=20.NET=2011=20base=20i?= =?UTF-8?q?mage=20in=20devcontainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The devcontainer builds on the .NET 11 SDK and keeps .NET 10 side by side as the previous runtime. --- .devcontainer/Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8ac4c79b4..7463810fa 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ -# Use the most recent .NET LTS as the base image -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS base +# Use .NET 11 as the base image +FROM mcr.microsoft.com/dotnet/sdk:11.0 AS base -# Install .NET 9 SDK and shared frameworks side-by-side -COPY --from=mcr.microsoft.com/dotnet/sdk:9.0 /usr/share/dotnet/sdk /usr/share/dotnet/sdk -COPY --from=mcr.microsoft.com/dotnet/sdk:9.0 /usr/share/dotnet/shared /usr/share/dotnet/shared +# Install .NET 10 SDK and shared frameworks side-by-side +COPY --from=mcr.microsoft.com/dotnet/sdk:10.0 /usr/share/dotnet/sdk /usr/share/dotnet/sdk +COPY --from=mcr.microsoft.com/dotnet/sdk:10.0 /usr/share/dotnet/shared /usr/share/dotnet/shared From 89c0b0887c01e9ce6734124be2f432e291739dd3 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:12:47 +0100 Subject: [PATCH 08/18] =?UTF-8?q?=F0=9F=93=9D=20document=20.NET=2010=20and?= =?UTF-8?q?=2011=20target=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README, contributing guide, agent guide, and integration docs describe the .NET 10 and .NET 11 targets instead of the retired .NET 9 target. --- AGENTS.md | 2 +- CONTRIBUTING.md | 4 ++-- Documentation/Coverlet.MTP.Integration.md | 4 ++-- README.md | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8eee83066..14cbbac78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ When synchronizing from `coverlet-coverage/coverlet`, keep the sync selective: 3. Port only changes that apply to the retained package surface: `src\coverlet.core`, `src\coverlet.MTP`, and the directly related tests. 4. Do not reintroduce upstream surfaces removed by this fork, including legacy console, collector, msbuild packages, legacy Azure Pipelines, broad upstream documentation, or `version.json`. -5. Preserve `Codebelt.Coverlet.MTP` package identity, `Coverlet.MTP` assembly identity, and the multi-targeted `netstandard2.0;net9.0;net10.0` layout. +5. Preserve `Codebelt.Coverlet.MTP` package identity, `Coverlet.MTP` assembly identity, and the multi-targeted `netstandard2.0;net10.0;net11.0` layout. 6. Keep versioning on MinVer `v*` tags and the existing release workflow guards. Do not restore upstream Nerdbank.GitVersioning files. 7. Adapt release notes to this fork's root `CHANGELOG.md`; describe only changes that were actually ported. 8. If an upstream tag was accidentally fetched and points at an upstream commit, delete that local tag before release preparation: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25a43ee1f..cd6b0b484 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Contributions are welcome. This repository is a deliberately narrow Codebelt-mai ## Requirements -Use the .NET SDK pinned by `global.json`. The retained production matrix is `netstandard2.0`, `net9.0`, and `net10.0`; tests execute on `net9.0` and `net10.0`. +Use the .NET SDK pinned by `global.json`. The retained production matrix is `netstandard2.0`, `net10.0`, and `net11.0`; tests execute on `net10.0` and `net11.0`. ## Building, testing, and packing @@ -36,7 +36,7 @@ Examples: ```bash dotnet test test/coverlet.MTP.tests/coverlet.MTP.tests.csproj -c Release -f net10.0 -- --filter-class Coverlet.MTP.Collector.Tests.CollectorExtensionTests -dotnet test test/coverlet.core.tests/coverlet.core.tests.csproj -c Release -f net9.0 -- --filter-method PatternMatchingOr_Should_Report_100_Percent_Branch_Coverage +dotnet test test/coverlet.core.tests/coverlet.core.tests.csproj -c Release -f net11.0 -- --filter-method PatternMatchingOr_Should_Report_100_Percent_Branch_Coverage ``` ## Debugging isolated coverage tests diff --git a/Documentation/Coverlet.MTP.Integration.md b/Documentation/Coverlet.MTP.Integration.md index 523a2cb76..7f5247cdb 100644 --- a/Documentation/Coverlet.MTP.Integration.md +++ b/Documentation/Coverlet.MTP.Integration.md @@ -15,8 +15,8 @@ More information is available here: This package provides explicit dependency groups for: - `netstandard2.0` -- `net9.0` - `net10.0` +- `net11.0` ## Quick Start @@ -641,6 +641,6 @@ dotnet test --coverlet --diagnostic --diagnostic-verbosity Trace ## Requirements -- .NET 9.0 SDK or newer for `net9.0` projects; .NET 10.0 SDK or newer for `net10.0` projects +- .NET 10.0 SDK or newer for `net10.0` projects; .NET 11.0 SDK or newer for `net11.0` projects - Microsoft.Testing.Platform 2.0.0 or newer - Test framework with MTP support (e.g., xUnit v3 (xunit.v3.mtp-v2), MSTest v3, NUnit with MTP adapter) diff --git a/README.md b/README.md index e7b98d082..85af61d80 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/codebeltnet/coverlet/actions/workflows/ci-pipeline.yml/badge.svg)](https://github.com/codebeltnet/coverlet/actions/workflows/ci-pipeline.yml) [![NuGet](https://img.shields.io/nuget/v/Codebelt.Coverlet.MTP.svg)](https://www.nuget.org/packages/Codebelt.Coverlet.MTP/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Cross-platform code coverage for .NET with explicit TFM support and framework-aligned dependencies. +Cross-platform code coverage for .NET with explicit target framework support. `Codebelt.Coverlet.MTP` is a deliberately narrow Codebelt-maintained fork of [Coverlet](https://github.com/coverlet-coverage/coverlet) for Microsoft Testing Platform integration. Coverlet remains the upstream project; this repository keeps the Coverlet engine and MTP integration recognizable while focusing package identity, target frameworks, dependency groups, and CI/CD on the Codebelt-maintained MTP package. @@ -22,11 +22,11 @@ The package supports: ```text netstandard2.0 -net9.0 net10.0 +net11.0 ``` -`netstandard2.0` is a package compatibility target. Tests execute on `net9.0` and `net10.0`. +`netstandard2.0` is a package compatibility target. Tests execute on `net10.0` and `net11.0`. ## Installation @@ -87,7 +87,7 @@ See [Coverlet.MTP.Integration.md](Documentation/Coverlet.MTP.Integration.md) for ## Upstream relationship -This fork is based on [coverlet-coverage/coverlet](https://github.com/coverlet-coverage/coverlet). The intent is not to replace Coverlet or rewrite its instrumentation internals; the fork exists to provide explicit target-framework support and framework-aligned NuGet dependency groups for the Microsoft Testing Platform package. +This fork is based on [coverlet-coverage/coverlet](https://github.com/coverlet-coverage/coverlet). The intent is not to replace Coverlet or rewrite its instrumentation internals; the fork exists to provide explicit target-framework support and NuGet dependency groups for the Microsoft Testing Platform package. For Coverlet drivers outside this repository's scope, such as the console tool, VSTest collector, or MSBuild integration, use the upstream Coverlet packages and documentation. From 70640b4b9b2a15a9a9042dd860109b1c88f03084 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:14:13 +0100 Subject: [PATCH 09/18] =?UTF-8?q?=F0=9F=92=AC=20add=2011.0.0=20changelog?= =?UTF-8?q?=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changelog records the .NET 11 target, the removal of the .NET 9 target, and the validation package preparation change, so users can see what this release changes. --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f079195bc..743c7ba82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [vNext] - 2026-10-30 +## [11.0.0] - 2026-11-10 -This is a patch release that makes the MTP package-validation tests prepare their own Codebelt.Coverlet.MTP package during local builds, including Visual Studio Test Explorer runs, with no runtime or package behavior changes. +This release adds .NET 11 support, removes the dedicated .NET 9 target, and makes the MTP package-validation tests prepare their own Codebelt.Coverlet.MTP package during local builds, including Visual Studio Test Explorer runs. ### Added @@ -14,6 +14,7 @@ This is a patch release that makes the MTP package-validation tests prepare thei ### Changed +- Target .NET 10 and .NET 11 alongside .NET Standard 2.0, removing the dedicated .NET 9 target, - Change the validation tests to read the package folder from MSBuild metadata instead of a hard-coded `artifacts/package/` path, with missing-package guidance that points to `-p:PrepareValidationPackage=true` or an existing package, - Disable automatic package preparation in GitHub Actions so validation consumes the downloaded release package, while allowing opt-out outside GitHub Actions with `-p:PrepareValidationPackage=false`, - Document the automatic packing behavior and opt-out in `CONTRIBUTING.md`, replacing the manual `dotnet pack` step. From ff8be2d21c9c63a96a41ab82b6e6255533778ef3 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Fri, 30 Oct 2026 09:23:26 +0100 Subject: [PATCH 10/18] fix --- .github/workflows/ci-pipeline.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 8d91a9f2b..40429877a 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -15,6 +15,7 @@ jobs: with: projects: coverlet.slnx configuration: Release + include-preview: true upload-build-artifact-name: build-release timeout-minutes: 45 @@ -65,6 +66,7 @@ jobs: configuration: Release build: true restore: true + include-preview: true download-pattern: build-release timeout-minutes: 45 @@ -76,6 +78,7 @@ jobs: projects: src/coverlet.MTP/coverlet.MTP.csproj configuration: Release version: ${{ needs.build.outputs.version }} + include-preview: true download-build-artifact-pattern: build-release upload-packed-artifact-name: NuGet-Release timeout-minutes: 30 @@ -98,6 +101,8 @@ jobs: - name: Install .NET uses: codebeltnet/install-dotnet@v3 + with: + includePreview: true - name: Download validated package uses: actions/download-artifact@v8 From f6f030c66bdea899a70612a618749446edde159d Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 09:44:40 +0100 Subject: [PATCH 11/18] =?UTF-8?q?=E2=9C=85=20align=20pattern=20matching=20?= =?UTF-8?q?coverage=20expectations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current SDK emits one conditional jump for both pattern matching and the equivalent logical expression, so the tests should expect the same two covered paths. --- .../Coverage/CoverageTests.PatternMatchingOr.cs | 14 +++----------- .../Symbols/CecilSymbolHelperTests.cs | 16 ++-------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/test/coverlet.core.coverage.tests/Coverage/CoverageTests.PatternMatchingOr.cs b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.PatternMatchingOr.cs index a3fb5d324..9c3fb53c9 100644 --- a/test/coverlet.core.coverage.tests/Coverage/CoverageTests.PatternMatchingOr.cs +++ b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.PatternMatchingOr.cs @@ -7,7 +7,6 @@ using Coverlet.Core; using Coverlet.Core.CoverageSamples.Tests; using Coverlet.Core.Tests; -using Coverlet.Tests.Utils; using Xunit; namespace Coverlet.CoreCoverage.Tests @@ -40,16 +39,9 @@ public void PatternMatchingOr_Should_Report_100_Percent_Branch_Coverage() var branches = methodDocument.Branches.Values.OrderBy(x => x.Ordinal).ToArray(); - if (TestUtils.GetAssemblyBuildConfiguration() == BuildConfiguration.Debug) - { - Assert.Equal(2, branches.Length); - Assert.Equal(new uint[] { 0, 1 }, branches.Select(x => x.Ordinal).ToArray()); - Assert.Equal(new[] { 0, 1 }, branches.Select(x => x.Path).ToArray()); - } else { - Assert.Equal(4, branches.Length); - Assert.Equal(new uint[] { 0, 1, 2, 3 }, branches.Select(x => x.Ordinal).ToArray()); - Assert.Equal(new[] { 0, 1, 0,1 }, branches.Select(x => x.Path).ToArray()); - } + Assert.Equal(2, branches.Length); + Assert.Equal(new uint[] { 0, 1 }, branches.Select(x => x.Ordinal).ToArray()); + Assert.Equal(new[] { 0, 1 }, branches.Select(x => x.Path).ToArray()); Assert.All(branches, branch => Assert.Equal(9, branch.Number)); Assert.Equal(branches[0].Offset, branches[1].Offset); Assert.All(branches, branch => Assert.True(branch.Hits > 0)); diff --git a/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs b/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs index 25e354692..474997bac 100644 --- a/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs +++ b/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs @@ -7,7 +7,6 @@ using coverlet.tests.projectsample.netframework; using Coverlet.Core.Samples.Tests; using Coverlet.Core.Symbols; -using Coverlet.Tests.Utils; using Mono.Cecil; using Mono.Cecil.Cil; using Xunit; @@ -593,20 +592,9 @@ public void GetBranchPoints_PatternMatchingOr_ShouldMatchOrOperator_AndStayOnSou System.Collections.Generic.IReadOnlyList patternMatchingOrPoints = _cecilSymbolHelper.GetBranchPoints(patternMatchingOrMethod); // assert - // OrOperator (text == "hello" || text == "world") compiles to ONE conditional branch - // (brtrue.s for the first comparison; the second feeds directly into br.s/stloc) = 2 branch points. - // PatternMatchingOrMethod (text is "hello" or "world") compiles to TWO brtrue.s instructions; - // the heuristic skips the last one (whose fall-through is an unconditional br) so the - // effective count equals OrOperator: 2 branch points. + // Both expressions compile to one conditional branch with the current SDK. Assert.Equal(2, orOperatorPoints.Count); - if (TestUtils.GetAssemblyBuildConfiguration() == BuildConfiguration.Debug) - { - Assert.Equal(2, patternMatchingOrPoints.Count); - } - else - { - Assert.Equal(4, patternMatchingOrPoints.Count); - } + Assert.Equal(orOperatorPoints.Count, patternMatchingOrPoints.Count); // All branch points must map to the same user-visible source line (the or-expression itself). // We derive the expected line from the branch points rather than from the first sequence point, // because in Debug builds the first sequence point is the opening brace on the preceding line. From a6f6dfb43618f46580c98cbcb8a49c4943a1d205 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 16:04:43 +0100 Subject: [PATCH 12/18] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20upgrade=20test,=20nu?= =?UTF-8?q?get=20and=20runtime=20dependency=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Microsoft Testing Platform, test SDK, xUnit, Moq, LinqKit and NuGet.Packaging/NuGet.Versioning pins to current releases. NuGet.Versioning for netstandard2.0 stays on 6.14.3, the newest release that still supports that target. --- Directory.Packages.props | 14 +++++++------- src/coverlet.core/coverlet.core.csproj | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ff114f5ee..dd604b66b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,15 +8,15 @@ 11.0.0-rc.3.26522.148 $(MicrosoftNet10RuntimeVersion) $(MicrosoftNet11RuntimeVersion) - 18.10.1 - 2.4.1 - 4.20.72 - 7.6.0 - 4.0.1 - 4.0.0 + 18.11.0 + 2.5.1 + 4.21.0 + 7.9.0 + 4.0.2 + 4.0.1 - + diff --git a/src/coverlet.core/coverlet.core.csproj b/src/coverlet.core/coverlet.core.csproj index 6a7e3237b..33b066979 100644 --- a/src/coverlet.core/coverlet.core.csproj +++ b/src/coverlet.core/coverlet.core.csproj @@ -38,7 +38,7 @@ - + From 362dddff57ba9070b0ca1356e0256eb30755e75f Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 16:04:49 +0100 Subject: [PATCH 13/18] =?UTF-8?q?=E2=9C=A8=20add=20control-flow=20branch?= =?UTF-8?q?=20reachability=20to=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branches that cannot be reached from method entry, including through exception handlers, can never be covered and were inflating the missed-branch count. Each branch now records whether it is reachable, unreachable branches are excluded from branch totals, and the Cobertura, LCOV and OpenCover reporters skip them. State files written before this change still read back as reachable. --- src/coverlet.core/Coverage.cs | 8 +- src/coverlet.core/CoverageResult.cs | 7 ++ src/coverlet.core/CoverageSummary.cs | 7 +- .../Instrumentation/Instrumenter.cs | 21 +++- .../Instrumentation/InstrumenterResult.cs | 7 ++ .../Reporters/CoberturaReporter.cs | 4 +- src/coverlet.core/Reporters/LcovReporter.cs | 2 +- .../Reporters/OpenCoverReporter.cs | 2 +- src/coverlet.core/Symbols/BranchPoint.cs | 8 ++ .../Symbols/CecilSymbolHelper.cs | 100 +++++++++++++++++- 10 files changed, 147 insertions(+), 19 deletions(-) diff --git a/src/coverlet.core/Coverage.cs b/src/coverlet.core/Coverage.cs index 56002a0e5..1f31152ba 100644 --- a/src/coverlet.core/Coverage.cs +++ b/src/coverlet.core/Coverage.cs @@ -256,14 +256,14 @@ public CoverageResult GetCoverageResult() if (methods.TryGetValue(branch.Method, out Method method)) { method.Branches.Add(new BranchInfo - { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal } + { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal, IsReachable = branch.IsReachable } ); } else { documents[doc.Path][branch.Class].Add(branch.Method, new Method()); documents[doc.Path][branch.Class][branch.Method].Branches.Add(new BranchInfo - { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal } + { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal, IsReachable = branch.IsReachable } ); } } @@ -272,7 +272,7 @@ public CoverageResult GetCoverageResult() documents[doc.Path].Add(branch.Class, []); documents[doc.Path][branch.Class].Add(branch.Method, new Method()); documents[doc.Path][branch.Class][branch.Method].Branches.Add(new BranchInfo - { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal } + { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal, IsReachable = branch.IsReachable } ); } } @@ -282,7 +282,7 @@ public CoverageResult GetCoverageResult() documents[doc.Path].Add(branch.Class, []); documents[doc.Path][branch.Class].Add(branch.Method, new Method()); documents[doc.Path][branch.Class][branch.Method].Branches.Add(new BranchInfo - { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal } + { Line = branch.Number, Hits = branch.Hits, Offset = branch.Offset, EndOffset = branch.EndOffset, Path = branch.Path, Ordinal = branch.Ordinal, IsReachable = branch.IsReachable } ); } } diff --git a/src/coverlet.core/CoverageResult.cs b/src/coverlet.core/CoverageResult.cs index c3a92ee29..30a46fcbb 100644 --- a/src/coverlet.core/CoverageResult.cs +++ b/src/coverlet.core/CoverageResult.cs @@ -17,6 +17,13 @@ internal class BranchInfo public int Path { get; set; } public uint Ordinal { get; set; } public int Hits { get; set; } + + /// + /// Indicates whether this branch can be reached from the method entry. + /// Unreachable branches are not counted in branch coverage. + /// Default value: true (assume all branches are reachable unless proven otherwise) + /// + public bool IsReachable { get; set; } = true; } internal class Lines : SortedDictionary { } internal class Branches : List { } diff --git a/src/coverlet.core/CoverageSummary.cs b/src/coverlet.core/CoverageSummary.cs index bbec2cd9d..5613adf67 100644 --- a/src/coverlet.core/CoverageSummary.cs +++ b/src/coverlet.core/CoverageSummary.cs @@ -117,10 +117,13 @@ public static CoverageDetails CalculateLineCoverage(Modules modules) public static CoverageDetails CalculateBranchCoverage(IList branches) { + // Unreachable branches cannot be covered, so they do not count against coverage + var reachableBranches = branches.Where(b => b.IsReachable).ToList(); + var details = new CoverageDetails { - Covered = branches.Count(bi => bi.Hits > 0), - Total = branches.Count + Covered = reachableBranches.Count(bi => bi.Hits > 0), + Total = reachableBranches.Count }; return details; } diff --git a/src/coverlet.core/Instrumentation/Instrumenter.cs b/src/coverlet.core/Instrumentation/Instrumenter.cs index 693b14df1..813544abf 100644 --- a/src/coverlet.core/Instrumentation/Instrumenter.cs +++ b/src/coverlet.core/Instrumentation/Instrumenter.cs @@ -699,7 +699,8 @@ private void InstrumentType(TypeDefinition type) { MethodDefinition actualMethod = method; IEnumerable customAttributes = method.CustomAttributes; - if (_instrumentationHelper.IsLocalMethod(method.Name)) + bool isLocalFunction = _instrumentationHelper.IsLocalMethod(method.Name); + if (isLocalFunction) actualMethod = methods.FirstOrDefault(m => m.Name == method.Name.Split('>')[0].Substring(1)) ?? method; if (actualMethod.IsGetter || actualMethod.IsSetter) @@ -725,7 +726,17 @@ private void InstrumentType(TypeDefinition type) continue; } - if (!customAttributes.Any(IsExcludeAttribute)) + // Issue #2045: Local functions are compiled as regular methods decorated with + // [CompilerGeneratedAttribute] by Roslyn. Since this attribute is in the default + // ExcludeByAttribute list, local functions would otherwise be entirely excluded + // from instrumentation. Mirror the Issue #1843 state-machine carve-out: ignore + // CompilerGenerated/GeneratedCode attributes for local functions, but still honor + // other user-configured exclusion attributes (e.g. ExcludeFromCodeCoverage). + bool isExcluded = isLocalFunction + ? customAttributes.Any(attr => IsExcludeAttribute(attr) && !IsCompilerGeneratedOrGeneratedCodeAttribute(attr)) + : customAttributes.Any(IsExcludeAttribute); + + if (!isExcluded) { InstrumentMethod(method); } @@ -1028,7 +1039,8 @@ private Instruction AddInstrumentationCode(MethodDefinition method, ILProcessor Offset = branchPoint.Offset, EndOffset = branchPoint.EndOffset, Path = branchPoint.Path, - Ordinal = branchPoint.Ordinal + Ordinal = branchPoint.Ordinal, + IsReachable = branchPoint.IsReachable } ); @@ -1100,7 +1112,8 @@ private Instruction AddBranchTrampolineCode(MethodDefinition method, ILProcessor Offset = branchPoint.Offset, EndOffset = branchPoint.EndOffset, Path = branchPoint.Path, - Ordinal = branchPoint.Ordinal + Ordinal = branchPoint.Ordinal, + IsReachable = branchPoint.IsReachable } ); diff --git a/src/coverlet.core/Instrumentation/InstrumenterResult.cs b/src/coverlet.core/Instrumentation/InstrumenterResult.cs index 44ef6bdfd..1753b1ef2 100644 --- a/src/coverlet.core/Instrumentation/InstrumenterResult.cs +++ b/src/coverlet.core/Instrumentation/InstrumenterResult.cs @@ -35,6 +35,13 @@ internal class Branch : Line public int Path; [DataMember] public uint Ordinal; + [DataMember] + public bool IsReachable = true; + + // DataContractSerializer does not run field initializers, so without this a state file + // written before IsReachable existed would read back with every branch unreachable + [OnDeserializing] + private void OnDeserializing(StreamingContext context) => IsReachable = true; } [DebuggerDisplay("line = {Line} Ordinal = {Ordinal}")] diff --git a/src/coverlet.core/Reporters/CoberturaReporter.cs b/src/coverlet.core/Reporters/CoberturaReporter.cs index aca67a4a2..d2e6ad9d1 100644 --- a/src/coverlet.core/Reporters/CoberturaReporter.cs +++ b/src/coverlet.core/Reporters/CoberturaReporter.cs @@ -93,7 +93,7 @@ public string Report(CoverageResult result, ISourceRootTranslator sourceRootTran var lines = new XElement("lines"); foreach (KeyValuePair ln in meth.Value.Lines) { - bool isBranchPoint = meth.Value.Branches.Any(b => b.Line == ln.Key); + bool isBranchPoint = meth.Value.Branches.Any(b => b.Line == ln.Key && b.IsReachable); var line = new XElement("line"); line.Add(new XAttribute("number", ln.Key.ToString())); line.Add(new XAttribute("hits", ln.Value.ToString())); @@ -101,7 +101,7 @@ public string Report(CoverageResult result, ISourceRootTranslator sourceRootTran if (isBranchPoint) { - var branches = meth.Value.Branches.Where(b => b.Line == ln.Key).ToList(); + var branches = meth.Value.Branches.Where(b => b.Line == ln.Key && b.IsReachable).ToList(); CoverageDetails branchInfoCoverage = CoverageSummary.CalculateBranchCoverage(branches); line.Add(new XAttribute("condition-coverage", $"{branchInfoCoverage.Percent.ToString(CultureInfo.InvariantCulture)}% ({branchInfoCoverage.Covered.ToString(CultureInfo.InvariantCulture)}/{branchInfoCoverage.Total.ToString(CultureInfo.InvariantCulture)})")); var conditions = new XElement("conditions"); diff --git a/src/coverlet.core/Reporters/LcovReporter.cs b/src/coverlet.core/Reporters/LcovReporter.cs index cc9f749b3..9027eac0e 100644 --- a/src/coverlet.core/Reporters/LcovReporter.cs +++ b/src/coverlet.core/Reporters/LcovReporter.cs @@ -48,7 +48,7 @@ public string Report(CoverageResult result, ISourceRootTranslator sourceRootTran foreach (KeyValuePair line in method.Value.Lines) lcov.Add($"DA:{line.Key},{line.Value}"); - foreach (BranchInfo branch in method.Value.Branches) + foreach (BranchInfo branch in method.Value.Branches.Where(b => b.IsReachable)) { lcov.Add($"BRDA:{branch.Line},{branch.Offset},{branch.Path},{branch.Hits}"); } diff --git a/src/coverlet.core/Reporters/OpenCoverReporter.cs b/src/coverlet.core/Reporters/OpenCoverReporter.cs index f57a52313..0880afca1 100644 --- a/src/coverlet.core/Reporters/OpenCoverReporter.cs +++ b/src/coverlet.core/Reporters/OpenCoverReporter.cs @@ -147,7 +147,7 @@ public string Report(CoverageResult result, ISourceRootTranslator sourceRootTran k++; } - foreach (BranchInfo branche in meth.Value.Branches) + foreach (BranchInfo branche in meth.Value.Branches.Where(b => b.IsReachable)) { var branchPoint = new XElement("BranchPoint"); branchPoint.Add(new XAttribute("vc", branche.Hits.ToString())); diff --git a/src/coverlet.core/Symbols/BranchPoint.cs b/src/coverlet.core/Symbols/BranchPoint.cs index d600dc02f..d2e096aac 100644 --- a/src/coverlet.core/Symbols/BranchPoint.cs +++ b/src/coverlet.core/Symbols/BranchPoint.cs @@ -47,5 +47,13 @@ internal class BranchPoint /// The url to the document if an entry was not mapped to an id /// public string Document { get; set; } + + /// + /// Indicates whether this branch path can be reached from the method entry. + /// A path is reachable when its branching instruction is reachable in the + /// method's control-flow graph, including through exception handlers. + /// Default value: true (branch is considered reachable unless proven otherwise) + /// + public bool IsReachable { get; set; } = true; } } diff --git a/src/coverlet.core/Symbols/CecilSymbolHelper.cs b/src/coverlet.core/Symbols/CecilSymbolHelper.cs index e4e392318..40dda7cf7 100644 --- a/src/coverlet.core/Symbols/CecilSymbolHelper.cs +++ b/src/coverlet.core/Symbols/CecilSymbolHelper.cs @@ -1019,6 +1019,91 @@ private static bool SkipExpressionBreakpointsBranches(Instruction instruction) = instruction.Next is not null && instruction.Next.OpCode == OpCodes.Nop && instruction.Operand == instruction.Next?.Next; + /// + /// Computes the instructions reachable from the method entry using control-flow edges: + /// branch targets, the next instruction unless control transfers unconditionally + /// (br, leave, ret, jmp, throw, rethrow, endfinally, endfilter), and the filter and handler + /// entries of every exception handler whose try block is reachable. + /// + /// Method body to analyze. + /// Set of reachable instructions. + private static HashSet ComputeReachableInstructions(MethodBody body) + { + var reached = new HashSet(); + if (body.Instructions.Count == 0) + { + return reached; + } + + var work = new Stack(); + + void Visit(Instruction instruction) + { + if (instruction is not null && reached.Add(instruction)) + { + work.Push(instruction); + } + } + + Visit(body.Instructions[0]); + + bool grew = true; + while (grew) + { + while (work.Count > 0) + { + Instruction instruction = work.Pop(); + switch (instruction.OpCode.FlowControl) + { + case FlowControl.Branch: + Visit(instruction.Operand as Instruction); + break; + case FlowControl.Cond_Branch: + if (instruction.Operand is Instruction conditionalTarget) + { + Visit(conditionalTarget); + } + else if (instruction.Operand is Instruction[] switchTargets) + { + foreach (Instruction switchTarget in switchTargets) + { + Visit(switchTarget); + } + } + + Visit(instruction.Next); + break; + case FlowControl.Return: + case FlowControl.Throw: + break; + case FlowControl.Call when instruction.OpCode.Code == Code.Jmp: + // jmp leaves the method; Cecil classifies it as a call + break; + default: + Visit(instruction.Next); + break; + } + } + + // Handlers are entered from their try block, not by a branch + grew = false; + foreach (ExceptionHandler exceptionHandler in body.ExceptionHandlers) + { + if (!reached.Contains(exceptionHandler.TryStart)) + { + continue; + } + + int before = reached.Count; + Visit(exceptionHandler.FilterStart); + Visit(exceptionHandler.HandlerStart); + grew |= reached.Count != before; + } + } + + return reached; + } + public IReadOnlyList GetBranchPoints(MethodDefinition methodDefinition) { var list = new List(); @@ -1029,6 +1114,7 @@ public IReadOnlyList GetBranchPoints(MethodDefinition methodDefinit uint ordinal = 0; var instructions = methodDefinition.Body.Instructions.ToList(); + HashSet reachableInstructions = ComputeReachableInstructions(methodDefinition.Body); bool isAsyncStateMachineMoveNext = IsMoveNextInsideAsyncStateMachine(methodDefinition); bool isMoveNextInsideAsyncStateMachineProlog = isAsyncStateMachineMoveNext && IsMoveNextInsideAsyncStateMachineProlog(methodDefinition); @@ -1134,7 +1220,7 @@ public IReadOnlyList GetBranchPoints(MethodDefinition methodDefinit return list; } - if (!BuildPointsForConditionalBranch(list, instruction, branchingInstructionLine, document, branchOffset, pathCounter, instructions, ref ordinal, methodDefinition)) + if (!BuildPointsForConditionalBranch(list, instruction, branchingInstructionLine, document, branchOffset, pathCounter, instructions, ref ordinal, methodDefinition, reachableInstructions.Contains(instruction))) { return list; } @@ -1149,7 +1235,7 @@ public IReadOnlyList GetBranchPoints(MethodDefinition methodDefinit private static bool BuildPointsForConditionalBranch(List list, Instruction instruction, int branchingInstructionLine, string document, int branchOffset, int pathCounter, - List instructions, ref uint ordinal, MethodDefinition methodDefinition) + List instructions, ref uint ordinal, MethodDefinition methodDefinition, bool branchInstructionReachable) { // Add Default branch (Path=0) @@ -1170,7 +1256,9 @@ private static bool BuildPointsForConditionalBranch(List list, Inst pathOffsetList.Count > 1 ? pathOffsetList.GetRange(0, pathOffsetList.Count - 1) : [], - EndOffset = pathOffsetList.Last() + EndOffset = pathOffsetList.Last(), + // Every path of a branch is reachable exactly when the branch instruction is reachable + IsReachable = branchInstructionReachable }; // Add Conditional Branch (Path=1) @@ -1211,7 +1299,8 @@ private static uint BuildPointsForBranch(List list, Instruction the pathOffsetList1.Count > 1 ? pathOffsetList1.GetRange(0, pathOffsetList1.Count - 1) : [], - EndOffset = pathOffsetList1.Last() + EndOffset = pathOffsetList1.Last(), + IsReachable = path0.IsReachable }; // only add branch if branch does not match a known sequence @@ -1269,7 +1358,8 @@ private static uint BuildPointsForSwitchCases(List list, BranchPoin pathOffsetList1.Count > 1 ? pathOffsetList1.GetRange(0, pathOffsetList1.Count - 1) : [], - EndOffset = pathOffsetList1.Last() + EndOffset = pathOffsetList1.Last(), + IsReachable = path0.IsReachable })); pathCounter = counter; return ordinal; From 06be507d84ddda8b1abf508ae503eeb3aa4b8b34 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 16:05:04 +0100 Subject: [PATCH 14/18] =?UTF-8?q?=E2=9C=85=20add=20regression=20tests=20fo?= =?UTF-8?q?r=20branch=20reachability=20and=20mtp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reachability change and the MTP configuration, report format and threshold paths need regression coverage. These tests pin the new IsReachable behavior, the reporter output for unreachable branches, and the shared-framework resolution fix. --- ...ollectorExtensionThresholdCoverageTests.cs | 371 ++++++++++++++++++ .../ConfigurationFileTests.cs | 91 ++++- .../CoverageTests.CecilAssemblyResolver.cs | 117 ++++++ .../Coverage/CoverageTests.IsReachable.cs | 153 ++++++++ .../Coverage/CoverageSummaryTests.cs | 48 +++ .../Coverage/CoverageTests.IsReachable.cs | 39 ++ .../Instrumentation/InstrumenterTests.cs | 21 + .../Reporters/CoberturaReporterTests.cs | 97 +++++ .../Reporters/LcovReporterTests.cs | 40 ++ .../Reporters/OpenCoverReporterTests.cs | 45 +++ test/coverlet.core.tests/Samples/Samples.cs | 20 + .../Symbols/BranchPointReachabilityTests.cs | 366 +++++++++++++++++ 12 files changed, 1407 insertions(+), 1 deletion(-) create mode 100644 test/coverlet.MTP.tests/Collector/CollectorExtensionThresholdCoverageTests.cs create mode 100644 test/coverlet.core.coverage.tests/Coverage/CoverageTests.CecilAssemblyResolver.cs create mode 100644 test/coverlet.core.coverage.tests/Coverage/CoverageTests.IsReachable.cs create mode 100644 test/coverlet.core.tests/Coverage/CoverageTests.IsReachable.cs create mode 100644 test/coverlet.core.tests/Symbols/BranchPointReachabilityTests.cs diff --git a/test/coverlet.MTP.tests/Collector/CollectorExtensionThresholdCoverageTests.cs b/test/coverlet.MTP.tests/Collector/CollectorExtensionThresholdCoverageTests.cs new file mode 100644 index 000000000..b96eeccc7 --- /dev/null +++ b/test/coverlet.MTP.tests/Collector/CollectorExtensionThresholdCoverageTests.cs @@ -0,0 +1,371 @@ +// Copyright (c) Toni Solarin-Sodara +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Reflection; +using Coverlet.Core; +using Coverlet.Core.Abstractions; +using Coverlet.Core.Enums; +using Coverlet.MTP.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Configurations; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.Messages; +using Microsoft.Testing.Platform.OutputDevice; +using Moq; +using Xunit; + +namespace Coverlet.MTP.Collector.Tests; + +public class CollectorExtensionThresholdCoverageTests +{ + private const string SimulatedTestModulePath = "/fake/path/test.dll"; + private const string SimulatedReportDirectory = "/fake/reports"; + + [Fact] + public void InitializeCoverageWhenServiceProviderIsNullKeepsCoverageUnset() + { + CollectorExtension collector = CreateCollector(); + ConfigureCollectorState(collector, formats: ["json"], threshold: null, thresholdTypes: ["line"]); + + SetPrivateField(collector, "_serviceProvider", null); + + InvokeInitializeCoverage(collector); + + Assert.Null(GetPrivateField(collector, "_coverage")); + } + + [Fact] + public void InitializeCoverageWhenServiceProviderIsAvailableCreatesCoverageInstance() + { + CollectorExtension collector = CreateCollector(); + ConfigureCollectorState(collector, formats: ["json"], threshold: null, thresholdTypes: ["line"]); + + var services = new ServiceCollection(); + services.AddSingleton(CreateMockFileSystem().Object); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + SetPrivateField(collector, "_serviceProvider", services.BuildServiceProvider()); + + InvokeInitializeCoverage(collector); + + Assert.NotNull(GetPrivateField(collector, "_coverage")); + } + + [Fact] + public async Task PublishCoverageDataAsyncPublishesReportMessagesForDetectedFormatsAndSkipsEmptyFormat() + { + Mock messageBus = CreateMockMessageBus(); + var publishedReportMessages = new List(); + messageBus.Setup(bus => bus.PublishAsync(It.IsAny(), It.IsAny())) + .Callback((_, data) => + { + if (data is TestCoverageReportMessage reportMessage) + { + publishedReportMessages.Add(reportMessage); + } + }) + .Returns(Task.CompletedTask); + + CollectorExtension collector = CreateCollector(messageBus: messageBus.Object); + ConfigureCollectorState(collector, formats: [], threshold: null, thresholdTypes: ["line"]); + + CoverageResult coverageResult = CreateCoverageResult(hits: 1); + var generatedReports = new List + { + "/fake/reports/coverage.cobertura.123.xml", + "/fake/reports/coverage.opencover.123.xml", + "/fake/reports/coverage.info", + "/fake/reports/coverage.json", + "/fake/reports/coverage.", + }; + + await InvokePublishCoverageDataAsync(collector, coverageResult, generatedReports); + + Assert.Equal(4, publishedReportMessages.Count); + Assert.Contains(publishedReportMessages, message => message.Format == CoverageReportFormat.Cobertura); + Assert.Contains(publishedReportMessages, message => message.Format == CoverageReportFormat.OpenCover); + Assert.Contains(publishedReportMessages, message => message.Format == CoverageReportFormat.Lcov); + Assert.Contains(publishedReportMessages, message => + message.Format == CoverageReportFormat.Custom && message.CustomFormatName == "coverlet.json"); + } + + [Theory] + [InlineData("line")] + [InlineData("branch")] + [InlineData("method")] + public void GetCoveragePercentReturnsExpectedCoverageForThresholdType(string thresholdTypeName) + { + Documents module = CreateCoverageResult(hits: 1).Modules.Values.Single(); + + ThresholdTypeFlags thresholdType = thresholdTypeName switch + { + "line" => ThresholdTypeFlags.Line, + "branch" => ThresholdTypeFlags.Branch, + "method" => ThresholdTypeFlags.Method, + _ => throw new ArgumentOutOfRangeException(nameof(thresholdTypeName), thresholdTypeName, "Unsupported threshold type."), + }; + + double actualCoveragePercent = InvokeGetCoveragePercent(module, thresholdType); + double expectedCoveragePercent = thresholdType switch + { + ThresholdTypeFlags.Line => CoverageSummary.CalculateLineCoverage(module).Percent, + ThresholdTypeFlags.Branch => CoverageSummary.CalculateBranchCoverage(module).Percent, + ThresholdTypeFlags.Method => CoverageSummary.CalculateMethodCoverage(module).Percent, + _ => throw new InvalidOperationException($"Unsupported threshold type {thresholdType}"), + }; + + Assert.Equal(expectedCoveragePercent, actualCoveragePercent); + } + + [Fact] + public void GetCoveragePercentWhenThresholdTypeIsInvalidThrowsArgumentOutOfRangeException() + { + Documents module = CreateCoverageResult(hits: 1).Modules.Values.Single(); + + TargetInvocationException ex = Assert.Throws(() => InvokeGetCoveragePercent(module, ThresholdTypeFlags.None)); + Assert.IsType(ex.InnerException); + } + + [Theory] + [InlineData(null, false)] + [InlineData(new[] { "1, 14 ; 7" }, true)] + [InlineData(new[] { "1,13;7" }, false)] + public void IsCoverageThresholdExitCodeIgnoredReturnsExpectedValue(string[]? ignoredExitCodes, bool expected) + { + Mock commandLineOptions = CreateMockCommandLineOptions(); + + if (ignoredExitCodes is null) + { + commandLineOptions + .Setup(x => x.TryGetOptionArgumentList("ignore-exit-code", out It.Ref.IsAny)) + .Returns(false); + } + else + { + commandLineOptions + .Setup(x => x.TryGetOptionArgumentList("ignore-exit-code", out ignoredExitCodes)) + .Returns(true); + } + + CollectorExtension collector = CreateCollector(commandLineOptions: commandLineOptions.Object); + + bool isIgnored = InvokeIsCoverageThresholdExitCodeIgnored(collector); + + Assert.Equal(expected, isIgnored); + } + + [Fact] + public async Task GenerateReportsAsyncWhenThresholdFailsAndExitCodeIsIgnoredQueriesIgnoreExitCodeOption() + { + string[]? ignoredExitCodes = ["14"]; + Mock commandLineOptions = CreateMockCommandLineOptions(); + commandLineOptions + .Setup(x => x.TryGetOptionArgumentList("ignore-exit-code", out ignoredExitCodes)) + .Returns(true); + + CollectorExtension collector = CreateCollector(commandLineOptions: commandLineOptions.Object); + ConfigureCollectorState(collector, formats: [], threshold: 80, thresholdTypes: ["line"]); + + await InvokeGenerateReportsAsync(collector, CreateCoverageResult(hits: 0)); + + commandLineOptions.Verify(x => x.TryGetOptionArgumentList("ignore-exit-code", out ignoredExitCodes), Times.Once); + } + + [Fact] + public async Task GenerateReportsAsyncWhenThresholdFailsAndExitCodeIsNotIgnoredStillQueriesIgnoreExitCodeOption() + { + string[]? ignoredExitCodes = ["13"]; + Mock commandLineOptions = CreateMockCommandLineOptions(); + commandLineOptions + .Setup(x => x.TryGetOptionArgumentList("ignore-exit-code", out ignoredExitCodes)) + .Returns(true); + + CollectorExtension collector = CreateCollector(commandLineOptions: commandLineOptions.Object); + ConfigureCollectorState(collector, formats: [], threshold: 80, thresholdTypes: ["line"]); + + await InvokeGenerateReportsAsync(collector, CreateCoverageResult(hits: 0)); + + commandLineOptions.Verify(x => x.TryGetOptionArgumentList("ignore-exit-code", out ignoredExitCodes), Times.Once); + } + + private static CollectorExtension CreateCollector( + ICommandLineOptions? commandLineOptions = null, + IFileSystem? fileSystem = null, + IMessageBus? messageBus = null) + { + Mock loggerFactory = new(); + loggerFactory.Setup(factory => factory.CreateLogger(It.IsAny())) + .Returns(new Mock().Object); + + ICommandLineOptions commandLine = commandLineOptions ?? CreateMockCommandLineOptions().Object; + IFileSystem mockedFileSystem = fileSystem ?? CreateMockFileSystem().Object; + + Mock outputDevice = new(); + outputDevice.Setup(device => device.DisplayAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())).Returns(Task.CompletedTask); + + Mock configuration = new(); + configuration.Setup(x => x["TestResultDirectory"]).Returns(SimulatedReportDirectory); + + return new CollectorExtension( + loggerFactory.Object, + commandLine, + outputDevice.Object, + configuration.Object, + mockedFileSystem, + messageBus: messageBus); + } + + private static Mock CreateMockCommandLineOptions() + { + Mock commandLineOptions = new(); + commandLineOptions.Setup(x => x.IsOptionSet(It.IsAny())).Returns(false); + commandLineOptions.Setup(x => x.TryGetOptionArgumentList(It.IsAny(), out It.Ref.IsAny)).Returns(false); + return commandLineOptions; + } + + private static Mock CreateMockMessageBus() + { + Mock messageBus = new(); + messageBus.Setup(bus => bus.PublishAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + return messageBus; + } + + private static Mock CreateMockFileSystem() + { + Mock fileSystem = new(); + fileSystem.Setup(x => x.Exists(It.IsAny())).Returns(true); + fileSystem.Setup(x => x.CreateDirectory(It.IsAny())); + fileSystem.Setup(x => x.WriteAllText(It.IsAny(), It.IsAny())); + return fileSystem; + } + + private static void ConfigureCollectorState( + CollectorExtension collector, + string[] formats, + int? threshold, + List thresholdTypes) + { + SetPrivateField(collector, "_testModulePath", SimulatedTestModulePath); + SetPrivateField(collector, "_platformConfiguration", new TestPlatformConfiguration(SimulatedReportDirectory)); + + var services = new ServiceCollection(); + services.AddSingleton(CreateMockFileSystem().Object); + services.AddSingleton(Mock.Of()); + SetPrivateField(collector, "_serviceProvider", services.BuildServiceProvider()); + + CoverletExtensionConfiguration extensionConfiguration = GetPrivateField(collector, "_configuration")!; + extensionConfiguration.TestModule = SimulatedTestModulePath; + extensionConfiguration.formats = formats; + extensionConfiguration.Threshold = threshold; + extensionConfiguration.ThresholdType = thresholdTypes; + extensionConfiguration.ThresholdStat = ThresholdStatistic.Total; + } + + private static void InvokeInitializeCoverage(CollectorExtension collector) + { + MethodInfo method = typeof(CollectorExtension) + .GetMethod("InitializeCoverage", BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(collector, null); + } + + private static async Task InvokePublishCoverageDataAsync( + CollectorExtension collector, + CoverageResult coverageResult, + IReadOnlyList generatedReports) + { + MethodInfo method = typeof(CollectorExtension) + .GetMethod("PublishCoverageDataAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + await (Task)method.Invoke(collector, [coverageResult, generatedReports, CancellationToken.None])!; + } + + private static async Task InvokeGenerateReportsAsync(CollectorExtension collector, CoverageResult coverageResult) + { + MethodInfo method = typeof(CollectorExtension) + .GetMethod("GenerateReportsAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + await (Task)method.Invoke(collector, [coverageResult, 0, CancellationToken.None])!; + } + + private static double InvokeGetCoveragePercent(Documents module, ThresholdTypeFlags thresholdType) + { + MethodInfo method = typeof(CollectorExtension) + .GetMethod("GetCoveragePercent", BindingFlags.NonPublic | BindingFlags.Static)!; + + return (double)method.Invoke(null, [module, thresholdType])!; + } + + private static bool InvokeIsCoverageThresholdExitCodeIgnored(CollectorExtension collector) + { + MethodInfo method = typeof(CollectorExtension) + .GetMethod("IsCoverageThresholdExitCodeIgnored", BindingFlags.NonPublic | BindingFlags.Instance)!; + + return (bool)method.Invoke(collector, null)!; + } + + private static T? GetPrivateField(CollectorExtension collector, string fieldName) + { + FieldInfo field = typeof(CollectorExtension) + .GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance)!; + + return (T?)field.GetValue(collector); + } + + private static void SetPrivateField(CollectorExtension collector, string fieldName, object? value) + { + FieldInfo field = typeof(CollectorExtension) + .GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance)!; + + field.SetValue(collector, value); + } + + private sealed class TestPlatformConfiguration : IConfiguration + { + private readonly string? _resultDirectory; + + public TestPlatformConfiguration(string? resultDirectory) + { + _resultDirectory = resultDirectory; + } + + public string? this[string key] => key switch + { + "platformOptions:resultDirectory" => _resultDirectory, + "platformOptions:currentWorkingDirectory" => Path.GetDirectoryName(SimulatedTestModulePath), + _ => null, + }; + } + + private static CoverageResult CreateCoverageResult(int hits) + { + var methods = new Methods + { + ["System.Void TestClass::TestMethod()"] = new Method + { + Lines = new Lines { { 1, hits }, { 2, 0 } }, + Branches = + [ + new BranchInfo { Line = 1, Hits = hits, Path = 0, Ordinal = 0 }, + new BranchInfo { Line = 1, Hits = 0, Path = 1, Ordinal = 1 }, + ], + }, + }; + + var classes = new Classes { ["TestClass"] = methods }; + var documents = new Documents { ["TestClass.cs"] = classes }; + + return new CoverageResult + { + Identifier = "test-id", + Modules = new Modules { [SimulatedTestModulePath] = documents }, + Parameters = new CoverageParameters(), + }; + } +} diff --git a/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs b/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs index 40a4269ab..9495b980f 100644 --- a/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs +++ b/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs @@ -29,6 +29,8 @@ public class ConfigurationFileTests : MtpValidationTestBase private const string CoverageCoberturaFileName = "coverage.cobertura.xml"; private const string CoverageLcovFileName = "coverage.info"; + private const string CoverageOpenCoverFileName = "coverage.opencover.xml"; + /// /// Validates that when using coverlet.mtp.appsettings.json, only the minimal exclude filter /// "[coverlet.*]*" is applied (not the extended command-line defaults). @@ -107,7 +109,7 @@ public async Task ConfigurationFile_Format_CanBeChangedViaConfig() string testName = TestContext.Current.TestCase!.TestMethodName!; using var testProject = CreateTestProjectWithConfigFile(testName, configContent: @"{ ""Coverlet"": { - ""Format"": ""json,cobertura,lcov"", + ""Format"": ""json,cobertura,lcov,opencover"", ""IncludeTestAssembly"": false } }"); @@ -151,6 +153,15 @@ public async Task ConfigurationFile_Format_CanBeChangedViaConfig() $"Expected LCOV coverage file but none found in {testProject.OutputDirectory}.\n" + $"Files found: {string.Join(", ", Directory.GetFiles(testProject.OutputDirectory, "*", SearchOption.AllDirectories).Select(Path.GetFileName))}"); + // Verify OpenCover format file was produced + string[] opencoverCoverageFiles = Directory.GetFiles( + testProject.OutputDirectory, + CoverageOpenCoverFileName.Insert(CoverageOpenCoverFileName.LastIndexOf('.'), ".*"), + SearchOption.AllDirectories); + Assert.True(opencoverCoverageFiles.Length > 0, + $"Expected OpenCover coverage file but none found in {testProject.OutputDirectory}.\n" + + $"Files found: {string.Join(", ", Directory.GetFiles(testProject.OutputDirectory, "*", SearchOption.AllDirectories).Select(Path.GetFileName))}"); + // Verify configuration settings via diagnostic log DiagnosticSettings? diagSettings = ParseDiagnosticFile(testProject.OutputDirectory); if (diagSettings is not null) @@ -192,6 +203,7 @@ public async Task ConfigurationFile_Format_CanBeChangedViaConfig() TestContext.Current?.AddAttachment("JSON Coverage", jsonCoverageFiles[0]); TestContext.Current?.AddAttachment("Cobertura Coverage", coberturaCoverageFiles[0]); TestContext.Current?.AddAttachment("LCOV Coverage", lcovCoverageFiles[0]); + TestContext.Current?.AddAttachment("OpenCover Coverage", opencoverCoverageFiles[0]); } /// @@ -603,6 +615,83 @@ public async Task TestConfigJson_TakesPriorityOver_LegacyAppSettings() } } + /// + /// Validates that --config-file command-line option loads a central testconfig.json file. + /// This covers regression scenario from PR #2030 where command-line config file handling was fixed. + /// + [Fact] + public async Task CommandLine_ConfigFile_UsesCentralTestConfigFile() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test requires Windows"); + + // Arrange - local legacy config should be ignored when --config-file points to central config. + string testName = TestContext.Current.TestCase!.TestMethodName!; + using var testProject = CreateTestProjectWithConfigFile(testName, configContent: """ + { + "Coverlet": { + "Format": "cobertura", + "FilePrefix": "LegacyConfig" + } + } + """); + + string centralConfigPath = Path.Combine(testProject.SolutionDirectory, "testconfig.central.json"); + File.WriteAllText(centralConfigPath, """ + { + "platformOptions": { + "Coverlet": { + "exclude": "[*.Tests]*,[xunit*]*", + "excludeByAttribute": "GeneratedCode,ExcludeFromCodeCoverage", + "format": "cobertura,json,lcov,opencover", + "skipAutoProps": true, + "excludeAssembliesWithoutSources": "MissingAll" + } + } + } + """); + + await BuildProject(testProject.SolutionPath); + + // Act + string runArgs = $"--coverlet --config-file \"{centralConfigPath}\" --coverlet-file-prefix CentralConfig"; + var result = await RunTestsWithCoverage(testProject, runArgs, enableDiagnostics: true); + + TestContext.Current?.AddAttachment("Test Output", result.CombinedOutput); + + // Assert - test should pass + Assert.True(result.ExitCode == 0, + $"Expected successful test run (exit code 0) but got {result.ExitCode}.\n\n{result.CombinedOutput}"); + + // Validate central config file prefix and format generated exactly 4 reports. + string[] centralCoverageFiles = Directory.GetFiles( + testProject.OutputDirectory, + "CentralConfig.coverage*", + SearchOption.AllDirectories); + Assert.True(centralCoverageFiles.Length == 4, + $"Expected exactly 4 coverage files with prefix 'CentralConfig.coverage', but found {centralCoverageFiles.Length}.\n" + + $"Files found: {string.Join(", ", Directory.GetFiles(testProject.OutputDirectory, "*", SearchOption.AllDirectories).Select(Path.GetFileName))}"); + + string[] legacyCoverageFiles = Directory.GetFiles( + testProject.OutputDirectory, + "LegacyConfig.coverage*", + SearchOption.AllDirectories); + Assert.Empty(legacyCoverageFiles); + + DiagnosticSettings? diagSettings = ParseDiagnosticFile(testProject.OutputDirectory); + if (diagSettings is null) + { + Assert.Fail("Expected diagnostic file to be generated but no .diag file was found."); + return; + } + + TestContext.Current?.AddAttachment("Diagnostic Log", diagSettings.RawContent); + + Assert.Equal("CentralConfig", diagSettings.FilePrefix); + Assert.True(diagSettings.SkipAutoProps, + $"Expected SkipAutoProps=true from central config file but got {diagSettings.SkipAutoProps}.\n" + + $"Diagnostic content:\n{diagSettings.RawContent}"); + } + private TestProjectInfo CreateTestProjectWithConfigFile(string testName, string configContent) { string artifactsTemp = Path.Combine(RepoRoot, "artifacts", "tmp", BuildConfiguration.ToLowerInvariant()); diff --git a/test/coverlet.core.coverage.tests/Coverage/CoverageTests.CecilAssemblyResolver.cs b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.CecilAssemblyResolver.cs new file mode 100644 index 000000000..a76500506 --- /dev/null +++ b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.CecilAssemblyResolver.cs @@ -0,0 +1,117 @@ +// Copyright (c) Toni Solarin-Sodara +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Coverlet.Core.Abstractions; +using Coverlet.Core.Instrumentation; +using Microsoft.Extensions.DependencyModel; +using Moq; +using Xunit; + +namespace Coverlet.CoreCoverage.Tests +{ + public partial class CoverageTests + { + [Fact] + public void NetCoreSharedFrameworkResolverResolvesCurrentRuntimeAssembly() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + + try + { + string modulePath = Path.Combine(tempDirectory, "copied.project.reference.dll"); + WriteSingleFrameworkRuntimeConfig(Path.Combine(tempDirectory, "testhost.runtimeconfig.json")); + + var logger = new Mock(); + var resolver = new NetCoreSharedFrameworkResolver(modulePath, logger.Object); + AssemblyName textJsonAssembly = typeof(System.Text.Json.JsonSerializer).Assembly.GetName(); + var compilationLibrary = new CompilationLibrary( + "package", + textJsonAssembly.Name, + textJsonAssembly.Version.ToString(), + "sha512-not-relevant", + Enumerable.Empty(), + Enumerable.Empty(), + true); + + var assemblies = new List(); + bool resolved = resolver.TryResolveAssemblyPaths(compilationLibrary, assemblies); + + Assert.True(resolved); + Assert.NotEmpty(assemblies); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + + [Fact] + public void RuntimeConfigurationReaderParsesFrameworksArray() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + + try + { + string runtimeConfigPath = Path.Combine(tempDirectory, "testhost.runtimeconfig.json"); + File.WriteAllText(runtimeConfigPath, + """ + { + "runtimeOptions": { + "tfm": "net8.0", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "8.0.0" + }, + { + "name": "Microsoft.AspNetCore.App", + "version": "8.0.0" + } + ] + } + } + """); + + var reader = new RuntimeConfigurationReader(runtimeConfigPath); + + (string Name, string Version)[] frameworks = reader.GetFrameworks().ToArray(); + + Assert.Equal(2, frameworks.Length); + Assert.Equal("Microsoft.NETCore.App", frameworks[0].Name); + Assert.Equal("8.0.0", frameworks[0].Version); + Assert.Equal("Microsoft.AspNetCore.App", frameworks[1].Name); + Assert.Equal("8.0.0", frameworks[1].Version); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + + private static void WriteSingleFrameworkRuntimeConfig(string runtimeConfigPath) + { + string runtimeVersion = new DirectoryInfo(Path.GetDirectoryName(typeof(object).Assembly.Location)!).Name; + Version runtimeAssemblyVersion = typeof(object).Assembly.GetName().Version!; + + File.WriteAllText(runtimeConfigPath, + $$""" + { + "runtimeOptions": { + "tfm": "net{{runtimeAssemblyVersion.Major}}.{{runtimeAssemblyVersion.Minor}}", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "{{runtimeVersion}}" + } + } + } + """); + } + } +} diff --git a/test/coverlet.core.coverage.tests/Coverage/CoverageTests.IsReachable.cs b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.IsReachable.cs new file mode 100644 index 000000000..4020250fb --- /dev/null +++ b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.IsReachable.cs @@ -0,0 +1,153 @@ +// Copyright (c) Toni Solarin-Sodara +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using System.Linq; +using System.Text; +using Coverlet.Core; +using Coverlet.Core.Abstractions; +using Coverlet.Core.Helpers; +using Coverlet.Core.Instrumentation; +using Coverlet.Core.Symbols; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Moq; +using Xunit; + +namespace Coverlet.CoreCoverage.Tests +{ + public partial class CoverageTests + { + // Live(int) gets a conditional branch appended after its last ret; DeadBranchLine is the line + // of the comment that the injected sequence point points to. + private const string EndToEndSource = @"namespace Coverlet.Reachability +{ + public static class EndToEnd + { + public static int Live(int x) + { + if (x > 0) return 1; + return 0; + // dead branch injected here + } + } +} +"; + + private const int LiveBranchLine = 7; + private const int DeadBranchLine = 9; + + [Fact] + public void Coverage_DeadBranch_IsReachableFalseSurvivesInstrumenterAndCoverage() + { + string directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(directory); + try + { + string module = BuildEndToEndModule(directory); + var logger = new Mock(); + var fileSystem = new FileSystem(); + var sourceRootTranslator = new SourceRootTranslator(logger.Object, fileSystem); + var instrumentationHelper = new InstrumentationHelper(new ProcessExitHandler(), new RetryHelper(), fileSystem, logger.Object, sourceRootTranslator); + var parameters = new CoverageParameters + { + IncludeFilters = [], + IncludeDirectories = [], + ExcludeFilters = [], + ExcludedSourceFiles = [], + ExcludeAttributes = [], + IncludeTestAssembly = true, + ExcludeAssembliesWithoutSources = "None" + }; + + var coverage = new Coverage(module, parameters, logger.Object, instrumentationHelper, fileSystem, sourceRootTranslator, new CecilSymbolHelper()); + CoveragePrepareResult prepareResult = coverage.PrepareModules(); + + // Instrumenter: Branch carries the flag + InstrumenterResult instrumenterResult = Assert.Single(prepareResult.Results); + Branch[] instrumented = [.. instrumenterResult.Documents.Values.SelectMany(d => d.Branches.Values).OrderBy(b => b.Ordinal)]; + Assert.Equal( + new[] { (LiveBranchLine, 0, true), (LiveBranchLine, 1, true), (DeadBranchLine, 0, false), (DeadBranchLine, 1, false) }, + instrumented.Select(b => (b.Number, b.Path, b.IsReachable))); + + // Coverage: BranchInfo carries the flag, and the summary counts only reachable branches + CoverageResult result = coverage.GetCoverageResult(); + Method method = result.Modules.Single().Value.Single().Value.Single().Value.Single().Value; + Assert.Equal( + new[] { (LiveBranchLine, 0, true), (LiveBranchLine, 1, true), (DeadBranchLine, 0, false), (DeadBranchLine, 1, false) }, + method.Branches.OrderBy(b => b.Ordinal).Select(b => (b.Line, b.Path, b.IsReachable))); + + CoverageDetails branchCoverage = CoverageSummary.CalculateBranchCoverage(method.Branches); + Assert.Equal(2, branchCoverage.Total); + Assert.Equal(0, branchCoverage.Covered); + } + finally + { + Directory.Delete(directory, true); + } + } + + /// + /// Compiles and appends a conditional branch that nothing reaches + /// after the last ret of Live(int): + /// ldarg.0 ; brtrue.s TARGET ; ldarg.0 ; pop ; TARGET: ldc.i4.2 ; ret + /// Path 0 gets an inline counter and path 1 a trampoline, so both Branch construction sites + /// in Instrumenter are used. + /// + private static string BuildEndToEndModule(string directory) + { + string modulePath = Path.Combine(directory, "ReachabilityEndToEnd.dll"); + string pdbPath = Path.ChangeExtension(modulePath, ".pdb"); + (byte[] peImage, byte[] pdbImage) = CompileEndToEndSource(Path.Combine(directory, "EndToEnd.cs")); + + using ModuleDefinition module = ModuleDefinition.ReadModule(new MemoryStream(peImage), new ReaderParameters + { + ReadSymbols = true, + SymbolStream = new MemoryStream(pdbImage) + }); + MethodDefinition method = module.GetType("Coverlet.Reachability.EndToEnd").Methods.Single(m => m.Name == "Live"); + ILProcessor il = method.Body.GetILProcessor(); + Instruction first = il.Create(OpCodes.Ldarg_0); + Instruction target = il.Create(OpCodes.Ldc_I4_2); + il.Append(first); + il.Append(il.Create(OpCodes.Brtrue_S, target)); + il.Append(il.Create(OpCodes.Ldarg_0)); + il.Append(il.Create(OpCodes.Pop)); + il.Append(target); + il.Append(il.Create(OpCodes.Ret)); + + Mono.Cecil.Cil.Document sourceDocument = method.DebugInformation.SequencePoints[0].Document; + method.DebugInformation.SequencePoints.Add(new SequencePoint(first, sourceDocument) + { + StartLine = DeadBranchLine, + StartColumn = 7, + EndLine = DeadBranchLine, + EndColumn = 37 + }); + + module.Write(modulePath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new PortablePdbWriterProvider() }); + Assert.True(File.Exists(pdbPath)); + return modulePath; + } + + private static (byte[] PeImage, byte[] PdbImage) CompileEndToEndSource(string fileName) + { + CSharpCompilation compilation = CSharpCompilation.Create( + "ReachabilityEndToEnd", + [CSharpSyntaxTree.ParseText(EndToEndSource, path: fileName, encoding: Encoding.UTF8)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release)); + + using var peStream = new MemoryStream(); + using var pdbStream = new MemoryStream(); + EmitResult emitResult = compilation.Emit(peStream, pdbStream, + options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb, pdbFilePath: Path.ChangeExtension(fileName, ".pdb"))); + Assert.True(emitResult.Success, string.Join(Environment.NewLine, emitResult.Diagnostics)); + return (peStream.ToArray(), pdbStream.ToArray()); + } + } +} diff --git a/test/coverlet.core.tests/Coverage/CoverageSummaryTests.cs b/test/coverlet.core.tests/Coverage/CoverageSummaryTests.cs index c752e70ac..ae60425fb 100644 --- a/test/coverlet.core.tests/Coverage/CoverageSummaryTests.cs +++ b/test/coverlet.core.tests/Coverage/CoverageSummaryTests.cs @@ -353,6 +353,54 @@ public void BuildCoverageSummaryTable_MultipleModules_ContainsAllModuleNamesAndS Assert.Contains("| Average", result); } + [Fact] + public void CalculateBranchCoverage_AllBranchesReachable_CountsAllBranches() + { + var branches = new Branches + { + new BranchInfo { Line = 1, Hits = 1, Offset = 1, Path = 0, Ordinal = 1, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 0, Offset = 1, Path = 1, Ordinal = 2, IsReachable = true } + }; + + CoverageDetails result = CoverageSummary.CalculateBranchCoverage(branches); + + Assert.Equal(2, result.Total); + Assert.Equal(1, result.Covered); + Assert.Equal(50, result.Percent); + } + + [Fact] + public void CalculateBranchCoverage_OneUnreachableBranch_ExcludesUnreachable() + { + var branches = new Branches + { + new BranchInfo { Line = 1, Hits = 1, Offset = 1, Path = 0, Ordinal = 1, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 0, Offset = 1, Path = 1, Ordinal = 2, IsReachable = false } + }; + + CoverageDetails result = CoverageSummary.CalculateBranchCoverage(branches); + + Assert.Equal(1, result.Total); + Assert.Equal(1, result.Covered); + Assert.Equal(100, result.Percent); + } + + [Fact] + public void CalculateBranchCoverage_UnreachableBranchWithHits_NotCounted() + { + var branches = new Branches + { + new BranchInfo { Line = 1, Hits = 1, Offset = 1, Path = 0, Ordinal = 1, IsReachable = false }, + new BranchInfo { Line = 1, Hits = 0, Offset = 1, Path = 1, Ordinal = 2, IsReachable = false } + }; + + CoverageDetails result = CoverageSummary.CalculateBranchCoverage(branches); + + Assert.Equal(0, result.Total); + Assert.Equal(0, result.Covered); + Assert.Equal(100, result.Percent); + } + [Fact] public void BuildCoverageSummaryTable_SingleModule_TwoSectionsSeparatedByNewLine() { diff --git a/test/coverlet.core.tests/Coverage/CoverageTests.IsReachable.cs b/test/coverlet.core.tests/Coverage/CoverageTests.IsReachable.cs new file mode 100644 index 000000000..186a2356b --- /dev/null +++ b/test/coverlet.core.tests/Coverage/CoverageTests.IsReachable.cs @@ -0,0 +1,39 @@ +// Copyright (c) Toni Solarin-Sodara +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Coverlet.Core.Abstractions; +using Coverlet.Core.Instrumentation; +using Moq; +using Xunit; + +namespace Coverlet.Core.Tests +{ + public partial class CoverageTests + { + [Fact] + public void Coverage_BranchWithoutLines_IsReachableCopiedToBranchInfo() + { + // Branches with no line entries reach every BranchInfo construction site in Coverage: + // new document, then existing method, new method in an existing class, and new class. + var document = new Coverlet.Core.Instrumentation.Document { Index = 0, Path = "doc.cs" }; + document.Branches.Add(new BranchKey(1, 0), new Branch { Number = 1, Class = "ClassA", Method = "MethodA", Ordinal = 0, Path = 0, IsReachable = false }); + document.Branches.Add(new BranchKey(1, 1), new Branch { Number = 1, Class = "ClassA", Method = "MethodA", Ordinal = 1, Path = 1, IsReachable = false }); + document.Branches.Add(new BranchKey(2, 2), new Branch { Number = 2, Class = "ClassA", Method = "MethodB", Ordinal = 2, Path = 0, IsReachable = false }); + document.Branches.Add(new BranchKey(3, 3), new Branch { Number = 3, Class = "ClassB", Method = "MethodC", Ordinal = 3, Path = 0, IsReachable = false }); + + var instrumenterResult = new InstrumenterResult { HitsFilePath = "HitsFilePath", ModulePath = "ModulePath" }; + instrumenterResult.Documents.Add("doc.cs", document); + var prepareResult = new CoveragePrepareResult { Results = [instrumenterResult], Parameters = new CoverageParameters() }; + + var coverage = new Coverage(prepareResult, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object); + CoverageResult result = coverage.GetCoverageResult(); + + Classes classes = result.Modules["ModulePath"]["doc.cs"]; + Assert.Equal(new[] { false, false }, classes["ClassA"]["MethodA"].Branches.Select(b => b.IsReachable)); + Assert.False(Assert.Single(classes["ClassA"]["MethodB"].Branches).IsReachable); + Assert.False(Assert.Single(classes["ClassB"]["MethodC"].Branches).IsReachable); + } + } +} diff --git a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs index b95ffb125..c7d0d2d89 100644 --- a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs +++ b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Coverlet.Core.Abstractions; using Coverlet.Core.Helpers; @@ -164,6 +165,26 @@ public void TestInstrument_ClassesWithExcludeAttributeAreExcluded(Type excludedT instrumenterTest.Directory.Delete(true); } + [Fact] + public void TestInstrument_LocalFunction_IsNotExcludedByDefaultCompilerGeneratedAttribute() + { + // Regression test for https://github.com/coverlet-coverage/coverlet/issues/2045: + // local functions are compiled by Roslyn as regular methods decorated with + // [CompilerGeneratedAttribute]. Since that attribute is part of the default + // ExcludeByAttribute list, the local function body must still be instrumented + // rather than being entirely dropped from the coverage report. + InstrumenterTest instrumenterTest = CreateInstrumentor(attributesToIgnore: new string[] { nameof(CompilerGeneratedAttribute) }); + InstrumenterResult result = instrumenterTest.Instrumenter.Instrument(); + + Coverlet.Core.Instrumentation.Document doc = result.Documents.Values.FirstOrDefault(d => Path.GetFileName(d.Path) == "Samples.cs"); + Assert.NotNull(doc); + + bool found = doc.Lines.Values.Any(l => l.Method.Contains("Throw")); + Assert.True(found, "Local function should still be instrumented even though CompilerGeneratedAttribute is in the exclude list"); + + instrumenterTest.Directory.Delete(true); + } + [Theory] [InlineData(typeof(ClassExcludedByAttrWithoutAttributeNameSuffix), nameof(TestSDKAutoGeneratedCode))] [InlineData(typeof(ClassExcludedByAttrWithoutAttributeNameSuffix), "Microsoft.VisualStudio.TestPlatform.TestSDKAutoGeneratedCode")] diff --git a/test/coverlet.core.tests/Reporters/CoberturaReporterTests.cs b/test/coverlet.core.tests/Reporters/CoberturaReporterTests.cs index 961e4bebc..1f60d0525 100644 --- a/test/coverlet.core.tests/Reporters/CoberturaReporterTests.cs +++ b/test/coverlet.core.tests/Reporters/CoberturaReporterTests.cs @@ -489,6 +489,103 @@ public void Report_UseSourceLink_FilenamePassesThroughUnchanged() Assert.Equal(sourceUrl, fileName); } + [Fact] + public void Report_UnreachableBranch_ExcludedFromConditionCoverage() + { + var result = new CoverageResult + { + Identifier = Guid.NewGuid().ToString() + }; + + var lines = new Lines + { + { 1, 3 }, + { 2, 0 } + }; + + var branches = new Branches + { + new BranchInfo { Line = 1, Hits = 3, Offset = 10, EndOffset = 20, Path = 0, Ordinal = 0, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 0, Offset = 10, EndOffset = 30, Path = 1, Ordinal = 1, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 0, Offset = 40, EndOffset = 50, Path = 0, Ordinal = 2, IsReachable = false }, + new BranchInfo { Line = 1, Hits = 0, Offset = 40, EndOffset = 60, Path = 1, Ordinal = 3, IsReachable = false } + }; + + var methods = new Methods(); + string methodString = "System.Void Coverlet.Core.Reporters.Tests.CoberturaReporterTests::TestMethod()"; + methods.Add(methodString, new Method()); + methods[methodString].Lines = lines; + methods[methodString].Branches = branches; + + var classes = new Classes + { + { "Coverlet.Core.Reporters.Tests.CoberturaReporterTests", methods } + }; + + var documents = new Documents + { + { RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\doc.cs" : @"/doc.cs", classes } + }; + + result.Modules = new Modules + { + { "module", documents } + }; + result.Parameters = new CoverageParameters(); + + string report = new CoberturaReporter().Report(result, new Mock().Object); + + XElement line = XDocument.Load(new StringReader(report)).Descendants("method").Single().Descendants("line").Single(l => l.Attribute("number").Value == "1"); + Assert.Equal("50% (1/2)", line.Attribute("condition-coverage").Value); + XElement condition = Assert.Single(line.Element("conditions").Elements("condition")); + Assert.Equal("10", condition.Attribute("number").Value); + } + + [Fact] + public void Report_LineWithOnlyUnreachableBranches_IsNotABranchLine() + { + var lines = new Lines + { + { 1, 3 }, + { 2, 1 } + }; + + var branches = new Branches + { + new BranchInfo { Line = 1, Hits = 3, Offset = 10, EndOffset = 20, Path = 0, Ordinal = 0, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 0, Offset = 10, EndOffset = 30, Path = 1, Ordinal = 1, IsReachable = true }, + new BranchInfo { Line = 2, Hits = 1, Offset = 40, EndOffset = 50, Path = 0, Ordinal = 2, IsReachable = false }, + new BranchInfo { Line = 2, Hits = 0, Offset = 40, EndOffset = 60, Path = 1, Ordinal = 3, IsReachable = false } + }; + + var methods = new Methods(); + string methodString = "System.Void Coverlet.Core.Reporters.Tests.CoberturaReporterTests::TestMethod()"; + methods.Add(methodString, new Method()); + methods[methodString].Lines = lines; + methods[methodString].Branches = branches; + + var result = new CoverageResult + { + Identifier = Guid.NewGuid().ToString(), + Parameters = new CoverageParameters(), + Modules = new Modules + { + { "module", new Documents { { RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\doc.cs" : @"/doc.cs", new Classes { { "Coverlet.Core.Reporters.Tests.CoberturaReporterTests", methods } } } } } + } + }; + + string report = new CoberturaReporter().Report(result, new Mock().Object); + + XElement[] reportLines = [.. XDocument.Load(new StringReader(report)).Descendants("method").Single().Descendants("line")]; + XElement live = reportLines.Single(l => l.Attribute("number").Value == "1"); + Assert.Equal("True", live.Attribute("branch").Value); + Assert.Equal("50% (1/2)", live.Attribute("condition-coverage").Value); + XElement dead = reportLines.Single(l => l.Attribute("number").Value == "2"); + Assert.Equal("False", dead.Attribute("branch").Value); + Assert.Null(dead.Attribute("condition-coverage")); + Assert.Null(dead.Element("conditions")); + } + [Fact] public void Report_DeterministicReport_FilenameFromTranslator() { diff --git a/test/coverlet.core.tests/Reporters/LcovReporterTests.cs b/test/coverlet.core.tests/Reporters/LcovReporterTests.cs index 78e5551f5..bc3cf9acc 100644 --- a/test/coverlet.core.tests/Reporters/LcovReporterTests.cs +++ b/test/coverlet.core.tests/Reporters/LcovReporterTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Linq; using Coverlet.Core.Abstractions; using Coverlet.Core.Reporters; using Moq; @@ -58,5 +59,44 @@ public void TestReport() Assert.NotEmpty(report); Assert.Equal("SF:doc.cs", report.Split(Environment.NewLine)[0]); } + + [Fact] + public void TestReport_UnreachableBranches_RecordsAgreeWithTotals() + { + var lines = new Lines + { + { 1, 3 } + }; + + var branches = new Branches + { + new BranchInfo { Line = 1, Hits = 3, Offset = 10, EndOffset = 20, Path = 0, Ordinal = 0, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 0, Offset = 10, EndOffset = 30, Path = 1, Ordinal = 1, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 2, Offset = 40, EndOffset = 50, Path = 0, Ordinal = 2, IsReachable = false }, + new BranchInfo { Line = 1, Hits = 0, Offset = 40, EndOffset = 60, Path = 1, Ordinal = 3, IsReachable = false } + }; + + var methods = new Methods(); + string methodString = "System.Void Coverlet.Core.Tests.Reporters.Sample::TestMethod()"; + methods.Add(methodString, new Method()); + methods[methodString].Lines = lines; + methods[methodString].Branches = branches; + + var result = new CoverageResult + { + Parameters = new CoverageParameters(), + Identifier = Guid.NewGuid().ToString(), + Modules = new Modules + { + { "module", new Documents { { "doc.cs", new Classes { { "Coverlet.Core.Tests.Reporters.Sample", methods } } } } } + } + }; + + string[] report = new LcovReporter().Report(result, new Mock().Object).Split(Environment.NewLine); + + Assert.Equal(new[] { "BRDA:1,10,0,3", "BRDA:1,10,1,0" }, report.Where(l => l.StartsWith("BRDA:", StringComparison.Ordinal))); + Assert.Contains("BRF:2", report); + Assert.Contains("BRH:1", report); + } } } diff --git a/test/coverlet.core.tests/Reporters/OpenCoverReporterTests.cs b/test/coverlet.core.tests/Reporters/OpenCoverReporterTests.cs index b78a5b9dd..f8aaf5412 100644 --- a/test/coverlet.core.tests/Reporters/OpenCoverReporterTests.cs +++ b/test/coverlet.core.tests/Reporters/OpenCoverReporterTests.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Linq; using System.Text; using System.Xml.Linq; using Coverlet.Core.Abstractions; @@ -34,6 +35,50 @@ public void TestReport() Assert.DoesNotContain(doc.Descendants().Attributes("nPathComplexity"), v => v.Value != "4"); } + [Fact] + public void TestReport_UnreachableBranches_BranchPointsAgreeWithSummary() + { + var lines = new Lines + { + { 1, 3 } + }; + + var branches = new Branches + { + new BranchInfo { Line = 1, Hits = 3, Offset = 10, EndOffset = 20, Path = 0, Ordinal = 0, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 0, Offset = 10, EndOffset = 30, Path = 1, Ordinal = 1, IsReachable = true }, + new BranchInfo { Line = 1, Hits = 2, Offset = 40, EndOffset = 50, Path = 0, Ordinal = 2, IsReachable = false }, + new BranchInfo { Line = 1, Hits = 0, Offset = 40, EndOffset = 60, Path = 1, Ordinal = 3, IsReachable = false } + }; + + var methods = new Methods(); + string methodString = "System.Void Coverlet.Core.Tests.Reporters.Sample::TestMethod()"; + methods.Add(methodString, new Method()); + methods[methodString].Lines = lines; + methods[methodString].Branches = branches; + + var result = new CoverageResult + { + Parameters = new CoverageParameters(), + Identifier = Guid.NewGuid().ToString(), + Modules = new Modules + { + { "module", new Documents { { "doc.cs", new Classes { { "Coverlet.Core.Tests.Reporters.Sample", methods } } } } } + } + }; + + XDocument doc = XDocument.Parse(new OpenCoverReporter().Report(result, new Mock().Object)); + XElement method = doc.Descendants("Method").Single(); + + Assert.Equal(new[] { "10", "10" }, method.Descendants("BranchPoint").Select(b => b.Attribute("offset").Value)); + XElement summary = method.Element("Summary"); + Assert.Equal("2", summary.Attribute("numBranchPoints").Value); + Assert.Equal("1", summary.Attribute("visitedBranchPoints").Value); + XElement sequencePoint = method.Descendants("SequencePoint").Single(); + Assert.Equal("2", sequencePoint.Attribute("bec").Value); + Assert.Equal("1", sequencePoint.Attribute("bev").Value); + } + [Fact] public void TestFilesHaveUniqueIdsOverMultipleModules() { diff --git a/test/coverlet.core.tests/Samples/Samples.cs b/test/coverlet.core.tests/Samples/Samples.cs index 58ec519b8..4e35cdd7d 100644 --- a/test/coverlet.core.tests/Samples/Samples.cs +++ b/test/coverlet.core.tests/Samples/Samples.cs @@ -451,4 +451,24 @@ public bool IsLowerInSimpleIf(char c) return false; } } + + // Sample for https://github.com/coverlet-coverage/coverlet/issues/2045 + // Local functions are compiled by Roslyn as regular methods on the declaring type, + // decorated with [CompilerGeneratedAttribute]. Since that attribute is part of the + // default ExcludeByAttribute list, the local function body must still be instrumented. + public class ClassWithLocalFunction + { + public static void TestCapacity(uint capacity) + { + if (capacity < 16) + { + Throw(capacity); + } + + static void Throw(uint capacity) + { + throw new ArgumentException($"Capacity must be >= 16. Actual value was {capacity}."); + } + } + } } \ No newline at end of file diff --git a/test/coverlet.core.tests/Symbols/BranchPointReachabilityTests.cs b/test/coverlet.core.tests/Symbols/BranchPointReachabilityTests.cs new file mode 100644 index 000000000..eedd03bd7 --- /dev/null +++ b/test/coverlet.core.tests/Symbols/BranchPointReachabilityTests.cs @@ -0,0 +1,366 @@ +// Copyright (c) Toni Solarin-Sodara +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Coverlet.Core.Instrumentation; +using Coverlet.Core.Symbols; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace Coverlet.Core.Tests.Symbols +{ + /// + /// Tests for . The samples are compiled at test time, in Debug and + /// in Release, so every method has its real IL shape, real instruction offsets and real sequence points. + /// A branch path is reachable exactly when its branch instruction is reachable from the method entry. + /// + public class BranchPointReachabilityTests + { + private const string SampleSource = @"using System; + +namespace Coverlet.Reachability +{ + public static class Samples + { + public static int GuardThrow(string s) + { + if (s == null) throw new ArgumentNullException(nameof(s)); + return s.Length; + } + + public static int EarlyReturn(string s) + { + if (s == null) return -1; + return s.Length; + } + + public static int IfElse(int x) + { + int r; + if (x > 0) r = 1; + else r = 2; + return r; + } + + public static int Switch(int x) + { + switch (x) + { + case 0: return 10; + case 1: return 11; + case 2: return 12; + default: return -1; + } + } + + public static int TryCatch(string s) + { + try + { + return int.Parse(s); + } + catch (FormatException) + { + if (s.Length > 3) return 1; + return 0; + } + } + + public static int NestedTryCatch(string s) + { + try + { + return int.Parse(s); + } + catch (FormatException) + { + try + { + return 100 / (s.Length - 1); + } + catch (DivideByZeroException) + { + if (s.Length > 3) return 1; + return 0; + } + } + } + } +} +"; + + private static readonly ConcurrentDictionary s_samples = new(); + + [Theory] + [InlineData(OptimizationLevel.Debug)] + [InlineData(OptimizationLevel.Release)] + public void GuardClauseWithThrow_BothPathsReachable(OptimizationLevel optimization) + { + IReadOnlyList points = GetBranchPoints(optimization, "GuardThrow"); + + AssertSingleBranch(points, (0, true), (1, true)); + } + + [Theory] + [InlineData(OptimizationLevel.Debug)] + [InlineData(OptimizationLevel.Release)] + public void IfWithEarlyReturn_BothPathsReachable(OptimizationLevel optimization) + { + IReadOnlyList points = GetBranchPoints(optimization, "EarlyReturn"); + + AssertSingleBranch(points, (0, true), (1, true)); + } + + [Theory] + [InlineData(OptimizationLevel.Debug)] + [InlineData(OptimizationLevel.Release)] + public void IfElse_BothPathsReachable(OptimizationLevel optimization) + { + IReadOnlyList points = GetBranchPoints(optimization, "IfElse"); + + AssertSingleBranch(points, (0, true), (1, true)); + } + + [Theory] + [InlineData(OptimizationLevel.Debug)] + [InlineData(OptimizationLevel.Release)] + public void Switch_AllCasesReachable(OptimizationLevel optimization) + { + IReadOnlyList points = GetBranchPoints(optimization, "Switch"); + + AssertSingleBranch(points, (0, true), (1, true), (2, true), (3, true)); + } + + [Theory] + [InlineData(OptimizationLevel.Debug)] + [InlineData(OptimizationLevel.Release)] + public void BranchInsideCatchBlock_BothPathsReachable(OptimizationLevel optimization) + { + MethodDefinition method = GetSampleMethod(optimization, "TryCatch"); + IReadOnlyList points = new CecilSymbolHelper().GetBranchPoints(method); + + AssertSingleBranch(points, (0, true), (1, true)); + + // The branch is in the handler, which is entered only through the exception handler table + ExceptionHandler handler = Assert.Single(method.Body.ExceptionHandlers); + Assert.True(points[0].Offset >= handler.HandlerStart.Offset); + Assert.True(points[0].Offset < handler.HandlerEnd.Offset); + } + + [Theory] + [InlineData(OptimizationLevel.Debug)] + [InlineData(OptimizationLevel.Release)] + public void BranchInsideCatchBlockNestedInCatchBlock_BothPathsReachable(OptimizationLevel optimization) + { + MethodDefinition method = GetSampleMethod(optimization, "NestedTryCatch"); + IReadOnlyList points = new CecilSymbolHelper().GetBranchPoints(method); + + AssertSingleBranch(points, (0, true), (1, true)); + + // The inner try block starts inside the outer handler, so the inner handler is reachable + // only after the outer handler is + Assert.Equal(2, method.Body.ExceptionHandlers.Count); + ExceptionHandler inner = method.Body.ExceptionHandlers[0]; + ExceptionHandler outer = method.Body.ExceptionHandlers[1]; + Assert.True(inner.TryStart.Offset >= outer.HandlerStart.Offset); + Assert.True(points[0].Offset >= inner.HandlerStart.Offset); + Assert.True(points[0].Offset < inner.HandlerEnd.Offset); + } + + [Fact] + public void BranchesInDeadCode_PathsUnreachable_BranchInLiveCode_PathsReachable() + { + // IL_0000: ldarg.0 + // IL_0001: brtrue.s IL_0005 reachable branch + // IL_0003: ldc.i4.0 + // IL_0004: ret + // IL_0005: ldc.i4.1 + // IL_0006: ret + // IL_0007: ldarg.0 + // IL_0008: brtrue.s IL_000c nothing reaches this branch + // IL_000a: ldc.i4.2 + // IL_000b: ret + // IL_000c: ldc.i4.3 + // IL_000d: ret + // IL_000e: ldarg.0 + // IL_000f: switch (IL_001e, IL_0020) nothing reaches this switch + // IL_001c: ldc.i4.4 + // IL_001d: ret + // IL_001e: ldc.i4.5 + // IL_001f: ret + // IL_0020: ldc.i4.6 + // IL_0021: ret + var module = ModuleDefinition.CreateModule("DeadBranchSample", ModuleKind.Dll); + var type = new TypeDefinition("Coverlet.Reachability", "Dead", Mono.Cecil.TypeAttributes.Public | Mono.Cecil.TypeAttributes.Abstract | Mono.Cecil.TypeAttributes.Sealed, module.TypeSystem.Object); + module.Types.Add(type); + var method = new MethodDefinition("DeadBranch", Mono.Cecil.MethodAttributes.Public | Mono.Cecil.MethodAttributes.Static, module.TypeSystem.Int32); + method.Parameters.Add(new ParameterDefinition("x", Mono.Cecil.ParameterAttributes.None, module.TypeSystem.Int32)); + type.Methods.Add(method); + + ILProcessor il = method.Body.GetILProcessor(); + Instruction liveTarget = il.Create(OpCodes.Ldc_I4_1); + Instruction deadTarget = il.Create(OpCodes.Ldc_I4_3); + il.Append(il.Create(OpCodes.Ldarg_0)); + il.Append(il.Create(OpCodes.Brtrue_S, liveTarget)); + il.Append(il.Create(OpCodes.Ldc_I4_0)); + il.Append(il.Create(OpCodes.Ret)); + il.Append(liveTarget); + il.Append(il.Create(OpCodes.Ret)); + il.Append(il.Create(OpCodes.Ldarg_0)); + il.Append(il.Create(OpCodes.Brtrue_S, deadTarget)); + il.Append(il.Create(OpCodes.Ldc_I4_2)); + il.Append(il.Create(OpCodes.Ret)); + il.Append(deadTarget); + il.Append(il.Create(OpCodes.Ret)); + Instruction case0 = il.Create(OpCodes.Ldc_I4_5); + Instruction case1 = il.Create(OpCodes.Ldc_I4_6); + il.Append(il.Create(OpCodes.Ldarg_0)); + il.Append(il.Create(OpCodes.Switch, new[] { case0, case1 })); + il.Append(il.Create(OpCodes.Ldc_I4_4)); + il.Append(il.Create(OpCodes.Ret)); + il.Append(case0); + il.Append(il.Create(OpCodes.Ret)); + il.Append(case1); + il.Append(il.Create(OpCodes.Ret)); + + // Write the module and read it back so that every instruction has its real offset + using var stream = new MemoryStream(); + module.Write(stream); + stream.Position = 0; + MethodDefinition reloaded = ModuleDefinition.ReadModule(stream).GetType("Coverlet.Reachability.Dead").Methods.Single(m => m.Name == "DeadBranch"); + + IReadOnlyList points = new CecilSymbolHelper().GetBranchPoints(reloaded); + + Assert.Equal( + new[] { (0x01, 0, true), (0x01, 1, true), (0x08, 0, false), (0x08, 1, false), (0x0f, 0, false), (0x0f, 1, false), (0x0f, 2, false) }, + points.Select(p => (p.Offset, p.Path, p.IsReachable))); + } + + [Fact] + public void BranchAfterJmp_PathsUnreachable() + { + // jmp leaves the method, so nothing after it is reachable. Cecil classifies jmp as a call. + // IL_0000: jmp Target(int32) + // IL_0005: ldarg.0 + // IL_0006: brtrue.s IL_000a + // IL_0008: ldc.i4.0 + // IL_0009: ret + // IL_000a: ldc.i4.1 + // IL_000b: ret + var module = ModuleDefinition.CreateModule("JmpSample", ModuleKind.Dll); + var type = new TypeDefinition("Coverlet.Reachability", "Jmp", Mono.Cecil.TypeAttributes.Public | Mono.Cecil.TypeAttributes.Abstract | Mono.Cecil.TypeAttributes.Sealed, module.TypeSystem.Object); + module.Types.Add(type); + var target = new MethodDefinition("Target", Mono.Cecil.MethodAttributes.Public | Mono.Cecil.MethodAttributes.Static, module.TypeSystem.Int32); + target.Parameters.Add(new ParameterDefinition("x", Mono.Cecil.ParameterAttributes.None, module.TypeSystem.Int32)); + ILProcessor targetIl = target.Body.GetILProcessor(); + targetIl.Append(targetIl.Create(OpCodes.Ldc_I4_0)); + targetIl.Append(targetIl.Create(OpCodes.Ret)); + type.Methods.Add(target); + var method = new MethodDefinition("Jumper", Mono.Cecil.MethodAttributes.Public | Mono.Cecil.MethodAttributes.Static, module.TypeSystem.Int32); + method.Parameters.Add(new ParameterDefinition("x", Mono.Cecil.ParameterAttributes.None, module.TypeSystem.Int32)); + type.Methods.Add(method); + + ILProcessor il = method.Body.GetILProcessor(); + Instruction branchTarget = il.Create(OpCodes.Ldc_I4_1); + il.Append(il.Create(OpCodes.Jmp, target)); + il.Append(il.Create(OpCodes.Ldarg_0)); + il.Append(il.Create(OpCodes.Brtrue_S, branchTarget)); + il.Append(il.Create(OpCodes.Ldc_I4_0)); + il.Append(il.Create(OpCodes.Ret)); + il.Append(branchTarget); + il.Append(il.Create(OpCodes.Ret)); + + using var stream = new MemoryStream(); + module.Write(stream); + stream.Position = 0; + MethodDefinition reloaded = ModuleDefinition.ReadModule(stream).GetType("Coverlet.Reachability.Jmp").Methods.Single(m => m.Name == "Jumper"); + + IReadOnlyList points = new CecilSymbolHelper().GetBranchPoints(reloaded); + + Assert.Equal(new[] { (0x06, 0, false), (0x06, 1, false) }, points.Select(p => (p.Offset, p.Path, p.IsReachable))); + } + + [Fact] + public void CoveragePrepareResult_SerializationRoundTrip_KeepsIsReachable() + { + // msbuild and the collector pass the instrumentation state between processes this way + var document = new Coverlet.Core.Instrumentation.Document { Index = 0, Path = "doc.cs" }; + document.Branches.Add(new BranchKey(1, 0), new Branch { Number = 1, Class = "Class", Method = "Method", Ordinal = 0, Path = 0, IsReachable = true }); + document.Branches.Add(new BranchKey(1, 1), new Branch { Number = 1, Class = "Class", Method = "Method", Ordinal = 1, Path = 1, IsReachable = false }); + var instrumenterResult = new InstrumenterResult(); + instrumenterResult.Documents.Add("doc.cs", document); + var prepareResult = new CoveragePrepareResult { Results = [instrumenterResult] }; + + CoveragePrepareResult roundTrip = CoveragePrepareResult.Deserialize(CoveragePrepareResult.Serialize(prepareResult)); + + Assert.Equal(new[] { true, false }, roundTrip.Results[0].Documents["doc.cs"].Branches.Values.OrderBy(b => b.Ordinal).Select(b => b.IsReachable)); + } + + [Fact] + public void CoveragePrepareResult_DeserializeWithoutIsReachable_BranchIsReachable() + { + // A state file written before IsReachable existed has no IsReachable element + var document = new Coverlet.Core.Instrumentation.Document { Index = 0, Path = "doc.cs" }; + document.Branches.Add(new BranchKey(1, 0), new Branch { Number = 1, Class = "Class", Method = "Method", Ordinal = 0, Path = 0, IsReachable = false }); + var instrumenterResult = new InstrumenterResult(); + instrumenterResult.Documents.Add("doc.cs", document); + var prepareResult = new CoveragePrepareResult { Results = [instrumenterResult] }; + + string xml = new StreamReader(CoveragePrepareResult.Serialize(prepareResult)).ReadToEnd(); + string withoutField = System.Text.RegularExpressions.Regex.Replace(xml, @"<(\w+:)?IsReachable>false", string.Empty); + Assert.NotEqual(xml, withoutField); + + CoveragePrepareResult roundTrip = CoveragePrepareResult.Deserialize(new MemoryStream(Encoding.UTF8.GetBytes(withoutField))); + + Assert.True(Assert.Single(roundTrip.Results[0].Documents["doc.cs"].Branches.Values).IsReachable); + } + + private static void AssertSingleBranch(IReadOnlyList points, params (int Path, bool IsReachable)[] expected) + { + Assert.Equal(expected, points.Select(p => (p.Path, p.IsReachable))); + Assert.Single(points.Select(p => p.Offset).Distinct()); + } + + private static IReadOnlyList GetBranchPoints(OptimizationLevel optimization, string methodName) + => new CecilSymbolHelper().GetBranchPoints(GetSampleMethod(optimization, methodName)); + + private static MethodDefinition GetSampleMethod(OptimizationLevel optimization, string methodName) + { + ModuleDefinition module = s_samples.GetOrAdd(optimization, level => + { + (byte[] peImage, byte[] pdbImage) = Compile(SampleSource, "Samples.cs", "ReachabilitySamples", level); + return ModuleDefinition.ReadModule(new MemoryStream(peImage), new ReaderParameters + { + ReadSymbols = true, + SymbolStream = new MemoryStream(pdbImage) + }); + }); + return module.GetType("Coverlet.Reachability.Samples").Methods.Single(m => m.Name == methodName); + } + + private static (byte[] PeImage, byte[] PdbImage) Compile(string source, string fileName, string assemblyName, OptimizationLevel optimization) + { + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName, + [CSharpSyntaxTree.ParseText(source, path: fileName, encoding: Encoding.UTF8)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: optimization)); + + using var peStream = new MemoryStream(); + using var pdbStream = new MemoryStream(); + EmitResult emitResult = compilation.Emit(peStream, pdbStream, + options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb, pdbFilePath: Path.ChangeExtension(fileName, ".pdb"))); + Assert.True(emitResult.Success, string.Join(Environment.NewLine, emitResult.Diagnostics)); + return (peStream.ToArray(), pdbStream.ToArray()); + } + } +} From ec345d4ce9d928142efc5744927d339f9d42e31d Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 30 Oct 2026 16:05:04 +0100 Subject: [PATCH 15/18] =?UTF-8?q?=F0=9F=92=AC=20update=20changelog=20for?= =?UTF-8?q?=2011.0.0=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the 11.0.0 changes: .NET 11 support, the .NET 9 target removal, branch reachability, dependency bumps and the local-function and shared-framework fixes. --- CHANGELOG.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 743c7ba82..87969c336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [11.0.0] - 2026-11-10 -This release adds .NET 11 support, removes the dedicated .NET 9 target, and makes the MTP package-validation tests prepare their own Codebelt.Coverlet.MTP package during local builds, including Visual Studio Test Explorer runs. +This major release adds .NET 11 support, removes the dedicated .NET 9 target, and incorporates upstream improvements to branch coverage and local-function instrumentation. MTP package-validation tests also prepare their own Codebelt.Coverlet.MTP package during local builds, including Visual Studio Test Explorer runs. ### Added +- Add control-flow analysis that identifies branches unreachable from method entry, including through exception handlers, with compatibility for existing serialized instrumentation state, +- Add regression coverage for MTP central configuration files, report formats, coverage thresholds, shared-framework resolution, and branch reachability, - Add automatic preparation of the Codebelt.Coverlet.MTP package for the MTP package-validation tests during local builds, skipped for IDE design-time builds, with isolated output under `artifacts/validation` per configuration and framework. ### Changed -- Target .NET 10 and .NET 11 alongside .NET Standard 2.0, removing the dedicated .NET 9 target, +- Target .NET 10 and .NET 11 alongside .NET Standard 2.0, with runtime dependency versions selected per target framework, +- Exclude unreachable branches from branch coverage totals and from Cobertura, LCOV, and OpenCover branch details, +- Update Microsoft Testing Platform to 2.5.1, Microsoft.NET.Test.Sdk to 18.11.0, xunit.v3.mtp-v2 to 4.0.2, xunit.runner.visualstudio to 4.0.1, Moq to 4.21.0, and LinqKit.Microsoft.EntityFrameworkCore to 10.1.1, +- Update NuGet.Packaging and NuGet.Versioning to 7.9.0, retaining a NuGet.Versioning 6.14.3 override for .NET Standard 2.0, - Change the validation tests to read the package folder from MSBuild metadata instead of a hard-coded `artifacts/package/` path, with missing-package guidance that points to `-p:PrepareValidationPackage=true` or an existing package, - Disable automatic package preparation in GitHub Actions so validation consumes the downloaded release package, while allowing opt-out outside GitHub Actions with `-p:PrepareValidationPackage=false`, - Document the automatic packing behavior and opt-out in `CONTRIBUTING.md`, replacing the manual `dotnet pack` step. +### Removed + +- Remove the dedicated .NET 9 target, +- Remove the direct System.Reflection.Metadata reference from the core test project, which uses the framework-provided assembly. + +### Fixed + +- Instrument local functions when CompilerGeneratedAttribute or GeneratedCodeAttribute is excluded, while continuing to honor other configured exclusion attributes, +- Resolve shared-framework assemblies when runtime directory names contain version suffixes, +- Align pattern-matching branch assertions with the selected SDK's compiled output. + ## [10.1.1] - 2026-10-20 This is a patch release that fixes module unload mutex collisions between unrelated coverage sessions and updates the Microsoft runtime pins and the `System.Security.Cryptography.Pkcs` override used by coverlet.core, with no new capabilities. @@ -112,7 +128,7 @@ This is a minor release focused on the Codebelt.Coverlet.MTP fork with narrowed - .vscode/settings.json configuration, - .devcontainer legacy configuration. -[10.1.2]: https://github.com/codebeltnet/coverlet/compare/v10.1.1...vNext +[11.0.0]: https://github.com/codebeltnet/coverlet/compare/v10.1.1...v11.0.0 [10.1.1]: https://github.com/codebeltnet/coverlet/compare/v10.1.0...v10.1.1 [10.1.0]: https://github.com/codebeltnet/coverlet/compare/v10.0.1...v10.1.0 [10.0.1]: https://github.com/codebeltnet/coverlet/compare/v10.0.0...v10.0.1 From 543c3c53f4650e9b706161ca86b7598c29e900bd Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 31 Oct 2026 20:01:07 +0100 Subject: [PATCH 16/18] =?UTF-8?q?=F0=9F=93=9D=20align=20quickstart=20depen?= =?UTF-8?q?dency=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the README installation example consistent with the Microsoft Testing Platform and xUnit versions selected by the upstream sync. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 85af61d80..bac198112 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ Example test project: - - + + From c9a93894f05e4d37e31ddcf0839904ef1643c2e5 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 31 Oct 2026 20:01:17 +0100 Subject: [PATCH 17/18] =?UTF-8?q?=F0=9F=92=AC=20document=20upstream=20sync?= =?UTF-8?q?=20history=20and=20fork=20differences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the original Coverlet marker, verified Codebelt tag targets, inherited upstream history, selective ports, exclusions, behavioral differences, and historical CI outcomes. Link the ledger from the README and expose it in the solution items so readers can audit what each sync contains. --- README.md | 2 + UPSTREAM.md | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++ coverlet.slnx | 1 + 3 files changed, 129 insertions(+) create mode 100644 UPSTREAM.md diff --git a/README.md b/README.md index bac198112..829f81448 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ See [Coverlet.MTP.Integration.md](Documentation/Coverlet.MTP.Integration.md) for This fork is based on [coverlet-coverage/coverlet](https://github.com/coverlet-coverage/coverlet). The intent is not to replace Coverlet or rewrite its instrumentation internals; the fork exists to provide explicit target-framework support and NuGet dependency groups for the Microsoft Testing Platform package. +See [UPSTREAM.md](UPSTREAM.md) for the dated synchronization history, source commits, validation results, omitted changes, and intentional differences from upstream. Release outcomes are summarized in [CHANGELOG.md](CHANGELOG.md). + For Coverlet drivers outside this repository's scope, such as the console tool, VSTest collector, or MSBuild integration, use the upstream Coverlet packages and documentation. ## License and notices diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 000000000..26d386320 --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,126 @@ +# Upstream relationship and synchronization record + +This repository is a selective fork of [coverlet-coverage/coverlet](https://github.com/coverlet-coverage/coverlet), maintained as `Codebelt.Coverlet.MTP`. Coverlet supplies the instrumentation engine and Microsoft Testing Platform integration. The Codebelt variant focuses on explicit target-framework support, package dependencies, and its development and release experience. + +The compatibility goal is to preserve upstream behavior within the retained surface. Meaningful behavior differences are listed below; a synchronization is not a blanket merge of upstream history. This document records repository state, which can include work that has not yet been released. See [CHANGELOG.md](CHANGELOG.md) for release outcomes, [README.md](README.md) for installation and usage, and [AGENTS.md](AGENTS.md) for synchronization rules. + +## Current reviewed state + +| Item | Recorded state | +| --- | --- | +| Upstream | [`coverlet-coverage/coverlet`, `master`](https://github.com/coverlet-coverage/coverlet/tree/master) | +| Latest reviewed upstream commit | [`2e0a852d3a0d9669d9c19445e366c69ccaaf6424`](https://github.com/coverlet-coverage/coverlet/commit/2e0a852d3a0d9669d9c19445e366c69ccaaf6424), dated 2026-10-27 | +| Latest sync operation | 2026-10-30; record and CI verified on 2026-10-31 | +| Ported changes verified at | [`ec345d4ce9d928142efc5744927d339f9d42e31d`](https://github.com/codebeltnet/coverlet/commit/ec345d4ce9d928142efc5744927d339f9d42e31d) on [PR #6](https://github.com/codebeltnet/coverlet/pull/6) | +| Integration state at verification | Committed on the PR branch; PR open; 11.0.0 release entry is a draft | +| Validation | [CI run 41077811675](https://github.com/codebeltnet/coverlet/actions/runs/41077811675) succeeded: build, release-version guard, nine test jobs, packing, and six package-validation jobs across .NET 10/11 and Linux, Windows, and macOS. Publication was skipped for the PR. | + +## Original marker and release tags + +Tag targets below were checked against this repository's remote on 2026-10-31. The `-coverlet` marker is the original starting point supplied for the fork; it must not be confused with the later Codebelt tag of the same version. These tags identify repository commits, rather than proving that all upstream changes through a similarly named upstream release were imported. + +| Tag | Commit | Date evidence | Meaning | +| --- | --- | --- | --- | +| [`v10.0.1-coverlet`](https://github.com/codebeltnet/coverlet/tree/v10.0.1-coverlet) | [`7fbbe4e72b06b0a6fe3932f65944c7627ecf6b17`](https://github.com/coverlet-coverage/coverlet/commit/7fbbe4e72b06b0a6fe3932f65944c7627ecf6b17) | Source commit dated 2026-05-18; tag creation date is not recorded by this lightweight tag | Original upstream starting marker. | +| [`v10.0.1`](https://github.com/codebeltnet/coverlet/tree/v10.0.1) | [`9766806`](https://github.com/codebeltnet/coverlet/commit/97668061b72e03668de55d6cca39a559c1d3ff10) | Integration commit dated 2026-09-20 | First Codebelt release boundary, including [PR #1](https://github.com/codebeltnet/coverlet/pull/1) and the versioning/release guards in [PR #2](https://github.com/codebeltnet/coverlet/pull/2). | +| [`v10.1.0`](https://github.com/codebeltnet/coverlet/tree/v10.1.0) | [`91c8882`](https://github.com/codebeltnet/coverlet/commit/91c888280969bb23e95bbc471126714915994b2c) | Integration commit dated 2026-09-28 | Selective MTP/configuration and instrumentation ports in [PR #3](https://github.com/codebeltnet/coverlet/pull/3). | +| [`v10.1.1`](https://github.com/codebeltnet/coverlet/tree/v10.1.1) | [`5bd8648`](https://github.com/codebeltnet/coverlet/commit/5bd86483a104328783255960116c1fb87ce2b35d) | Integration commit dated 2026-10-20 | Dependency alignment and Codebelt tracker-mutex maintenance in [PR #4](https://github.com/codebeltnet/coverlet/pull/4). | + +Upstream history continued after the original marker before the first Codebelt release. The shared ancestor of the current fork and upstream is [`da5c649`](https://github.com/coverlet-coverage/coverlet/commit/da5c649126c74985fc20c496bc898f143d18e21b), dated 2026-09-17: it includes 53 upstream commits after `7fbbe4e`. Thus `7fbbe4e` is the starting marker, while `da5c649` is the inherited upstream boundary used for later comparisons. The first Codebelt release then narrowed that inherited tree. The chronology does not establish separate sync-operation dates for those 53 commits. + +## Intentional differences from upstream + +These are the meaningful differences at the reviewed snapshots. They include existing fork fixes as well as packaging choices; they are not all changes from the latest sync. + +| Area | Difference and reason | Evidence | +| --- | --- | --- | +| Package surface | Publish one package, `Codebelt.Coverlet.MTP`, retaining the Coverlet MTP assembly, namespace, and discovery-hook identities. Console, VSTest collector, and legacy MSBuild packages are outside this fork's surface. | [MTP project](src/coverlet.MTP/coverlet.MTP.csproj), [scope](README.md#scope) | +| Target frameworks and dependencies | Retain `netstandard2.0;net10.0;net11.0`, with .NET 10/11 runtime dependency versions selected per TFM and explicit compatibility overrides for .NET Standard 2.0. | [Central versions](Directory.Packages.props), [core project](src/coverlet.core/coverlet.core.csproj) | +| Tooling and versioning | Use the repository SDK selection, Microsoft Testing Platform runner, MinVer `v*` tags, Codebelt reusable workflows, and guarded package publication. Local package-validation builds prepare an isolated package; CI validates its downloaded package. | [SDK and runner](global.json), [CI](.github/workflows/ci-pipeline.yml), [contributing guide](CONTRIBUTING.md) | +| Restoring instrumented files | Stage the backup beside the target before replacement, rather than copying over the loaded file in place. Retry handling also covers access-denied failures. This addresses loaded-assembly corruption and locked-file restoration. | [Restoration](src/coverlet.core/Helpers/InstrumentationHelper.cs), [filesystem operations](src/coverlet.core/Helpers/FileSystem.cs), [retry handling](src/coverlet.core/Helpers/RetryHelper.cs), [fork initialization](https://github.com/codebeltnet/coverlet/commit/2515033182429c537ee25bd413ee1aaf55164619) | +| Shared-framework resolution | Parse runtime directory versions when selecting a matching framework. Version suffixes or unrelated nonnumeric directories must not prevent resolution; stable framework requests continue to select stable versions. | [Resolver](src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs), [regression tests](test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs), [fork fix](https://github.com/codebeltnet/coverlet/commit/55d2aa1567475976a15418bc162d8e8562e64007) | +| Tracker unload synchronization | Use the full hit-file name, including the session identifier, for the mutex name so unrelated sessions do not collide through an assembly-name prefix. | [Tracker](src/coverlet.core/Instrumentation/ModuleTrackerTemplate.cs), [maintenance PR #4](https://github.com/codebeltnet/coverlet/pull/4) | +| In-process diagnostics | Catch failures while obtaining an assembly name for diagnostics so that the diagnostic lookup cannot abort coverage flushing. | [In-process handler](src/coverlet.MTP/InProcess/CoverletInProcessHandler.cs), [fork initialization](https://github.com/codebeltnet/coverlet/commit/2515033182429c537ee25bd413ee1aaf55164619) | +| Test expectations | Pattern-matching branch assertions follow the selected SDK's emitted IL while retaining equality with the equivalent logical OR expression and requiring both reported paths to be covered. | [Symbol tests](test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs), [coverage test](test/coverlet.core.coverage.tests/Coverage/CoverageTests.PatternMatchingOr.cs), [adaptation](https://github.com/codebeltnet/coverlet/commit/f6f030c66bdea899a70612a618749446edde159d) | + +The `netstandard2.0` embedded tracker in `src/coverlet.template` is retained from upstream. Branch reachability and local-function instrumentation described below are upstream changes, rather than independently introduced Codebelt coverage rules. + +## Synchronization history + +### 2026-10-30: selective core and MTP update + +Reviewed upstream changes from the previously inspected snapshot [`be3d696`](https://github.com/coverlet-coverage/coverlet/commit/be3d696) through [`2e0a852`](https://github.com/coverlet-coverage/coverlet/commit/2e0a852d3a0d9669d9c19445e366c69ccaaf6424). The shared ancestry used to check already-ported changes was [`da5c649126c74985fc20c496bc898f143d18e21b`](https://github.com/coverlet-coverage/coverlet/commit/da5c649126c74985fc20c496bc898f143d18e21b). Source commits were ported selectively; their hashes need not appear as ancestors in this fork. + +| Upstream source | Disposition | Result in this fork | +| --- | --- | --- | +| [`61e3350`, PR #2038](https://github.com/coverlet-coverage/coverlet/commit/61e335032fae02bfbd56392fdf5ca4be1aa5ad67) | Ported tests | Cover central `--config-file` selection and OpenCover report output. Preserve the fork's generated-project TFM and diagnostic/result directories. Included in [`06be507`](https://github.com/codebeltnet/coverlet/commit/06be507d84ddda8b1abf508ae503eeb3aa4b8b34). | +| [`ecd6fd5`, PR #2046](https://github.com/coverlet-coverage/coverlet/commit/ecd6fd5533e77880ab36748edb4f892df642a7dd) | Ported fix and regression test | Instrument local functions when compiler-generated attributes are excluded, while honoring other configured exclusions. Code in [`362dddf`](https://github.com/codebeltnet/coverlet/commit/362dddff57ba9070b0ca1356e0256eb30755e75f); tests in [`06be507`](https://github.com/codebeltnet/coverlet/commit/06be507d84ddda8b1abf508ae503eeb3aa4b8b34). | +| [`d9e4357`, PR #2051](https://github.com/coverlet-coverage/coverlet/commit/d9e43575632585bf48c8e4b0247880f460192768) | Ported tests | Add MTP threshold/report-message checks and shared-framework resolver checks. Included in [`06be507`](https://github.com/codebeltnet/coverlet/commit/06be507d84ddda8b1abf508ae503eeb3aa4b8b34). | +| [`39f6b6f`, PR #2056](https://github.com/coverlet-coverage/coverlet/commit/39f6b6fa9dd9761a6e9a8d9ffb31a44fc342162d) | Ported feature and regression tests | Compute branch reachability from method control flow, carry it through instrumentation/serialization, and exclude unreachable branches from coverage totals and Cobertura, LCOV, and OpenCover details. Older serialized instrumentation state defaults branches to reachable. Code in [`362dddf`](https://github.com/codebeltnet/coverlet/commit/362dddff57ba9070b0ca1356e0256eb30755e75f); tests in [`06be507`](https://github.com/codebeltnet/coverlet/commit/06be507d84ddda8b1abf508ae503eeb3aa4b8b34). | +| [`3142be5`, PR #2044](https://github.com/coverlet-coverage/coverlet/commit/3142be50ab2a46721f7d0bef424a38d2b83c4017), [`710f301`, PR #2050](https://github.com/coverlet-coverage/coverlet/commit/710f30144157ef19933f3f0ec48464874205e264), [`2e0a852`, PR #2059](https://github.com/coverlet-coverage/coverlet/commit/2e0a852d3a0d9669d9c19445e366c69ccaaf6424) | Adapted dependency updates | Update retained test/MTP/NuGet dependencies and the .NET Standard NuGet.Versioning override. Preserve this fork's SDK and .NET 10/11 runtime pins. The .NET 10 runtime pin already matched upstream. Included in [`a6f6dfb`](https://github.com/codebeltnet/coverlet/commit/a6f6dfb43618f46580c98cbcb8a49c4943a1d205). | +| [`a924cc3`](https://github.com/coverlet-coverage/coverlet/commit/a924cc3714eb9a4a02abb777231503440ae53a27) | Already present | The validation project's `System.Security.Cryptography.Pkcs` reference was already included by [maintenance PR #4](https://github.com/codebeltnet/coverlet/pull/4). | +| [`b5ba919`, PR #2055](https://github.com/coverlet-coverage/coverlet/commit/b5ba9197f032e22627836d899aa3956a50cd5017) | Omitted legacy configuration | `LEGACY` build switches and their package selections do not apply to the retained target layout. The compatible NuGet.Versioning update from #2050 was ported separately. | +| [`b330a1f`, PR #2043](https://github.com/coverlet-coverage/coverlet/commit/b330a1f) and [`45e98f9`, PR #2039](https://github.com/coverlet-coverage/coverlet/commit/45e98f9) | Omitted tooling outside the retained surface | This fork does not retain the upstream SBOM package or CodeQL workflow. | +| [`221483a`, PR #2049](https://github.com/coverlet-coverage/coverlet/commit/221483a) | Omitted console documentation | Global-tool installation/PATH guidance concerns the upstream console driver. | +| [`8b7654e`](https://github.com/coverlet-coverage/coverlet/commit/8b7654e), [`870b835`, PR #2057](https://github.com/coverlet-coverage/coverlet/commit/870b835), and release/build-file portions of the commits above | Omitted or translated | Upstream issue-template, release-plan, changelog, versioning, and pipeline changes were not imported. Ported release outcomes are recorded in the root [CHANGELOG.md](CHANGELOG.md). | + +Validation: dependency restore, Release solution build, and package creation succeeded. Package inspection confirmed `Codebelt.Coverlet.MTP` and the three retained TFM/dependency groups. The [CI run at `ec345d4`](https://github.com/codebeltnet/coverlet/actions/runs/41077811675), verified on 2026-10-31, passed the full retained test matrix and package validation. This is evidence for the PR branch; it does not claim a published 11.0.0 package or test execution on .NET Standard 2.0. + +### 2026-10-20: v10.1.1 dependency alignment and Codebelt maintenance + +The range [`v10.1.0...v10.1.1`](https://github.com/codebeltnet/coverlet/compare/v10.1.0...v10.1.1) was integrated by [PR #4](https://github.com/codebeltnet/coverlet/pull/4), destination commit [`5bd8648`](https://github.com/codebeltnet/coverlet/commit/5bd86483a104328783255960116c1fb87ce2b35d), which is also the `v10.1.1` tag target. This was a mixture of dependency alignment and fork-specific fixes, rather than another full upstream merge. + +| Change | Provenance and disposition | +| --- | --- | +| .NET 10/9 runtime pins changed from 10.0.12/9.0.20 to 10.0.13/9.0.21 | Aligns with the versions in upstream [`710f301`, PR #2050](https://github.com/coverlet-coverage/coverlet/commit/710f30144157ef19933f3f0ec48464874205e264), adapted to the fork's per-TFM version properties. This records matching changes; the integration commit does not carry an upstream cherry-pick trailer. | +| Add `System.Security.Cryptography.Pkcs` to the validation project | Matches upstream [`a924cc3`](https://github.com/coverlet-coverage/coverlet/commit/a924cc3714eb9a4a02abb777231503440ae53a27), using the fork's central runtime version. | +| Raise the core .NET Standard PKCS override from 8.0.1 to 8.0.2 | Codebelt compatibility/dependency maintenance recorded in the integration diff. | +| Use the full hit-file name in the unload mutex | Codebelt behavior fix with a regression test for collisions between session filenames sharing an assembly prefix. This remains an intentional difference from the reviewed upstream snapshot. | +| Read dependency versions in package assertions and change CI runner selections | Codebelt testing/tooling maintenance; excluded from claims about imported upstream coverage features. | + +Historical validation: the [PR #4 head CI run](https://github.com/codebeltnet/coverlet/actions/runs/39473328299) completed successfully. Its source state was [`0733a28`](https://github.com/codebeltnet/coverlet/commit/0733a287b7645eb3ae2561fac735b0cf85d6a8a8); the tagged integration commit is listed separately above. + +### 2026-09-28: v10.1.0 MTP configuration and instrumentation fixes + +Reconstructed from [PR #3](https://github.com/codebeltnet/coverlet/pull/3), destination and `v10.1.0` tag commit [`91c8882`](https://github.com/codebeltnet/coverlet/commit/91c888280969bb23e95bbc471126714915994b2c), and the range [`v10.0.1...v10.1.0`](https://github.com/codebeltnet/coverlet/compare/v10.0.1...v10.1.0). The operation date comes from the integration commit; the release date in the changelog is separate. + +| Upstream source | Ported result | +| --- | --- | +| [`23578d4`, PR #2030](https://github.com/coverlet-coverage/coverlet/commit/23578d4) | Honor the MTP `--config-file` command-line argument. | +| [`d212822`, PR #2032](https://github.com/coverlet-coverage/coverlet/commit/d212822) | Resolve shared-framework assemblies absent from `compileLibraries`, with regression coverage. | +| [`c6d3894`, PR #2033](https://github.com/coverlet-coverage/coverlet/commit/c6d3894) | Add regression coverage for async state machines with compiler-generated attribute exclusions. | + +Upstream release/version files were excluded. Historical validation: the [PR #3 head CI run](https://github.com/codebeltnet/coverlet/actions/runs/36483200835) completed successfully at [`b9ea944`](https://github.com/codebeltnet/coverlet/commit/b9ea944b6d7961278e3ca227bb94cb2ee6e3ec75). This identifies the tested PR source state separately from the tagged integration commit. + +### 2026-09-20: v10.0.1 inherited upstream state and Codebelt baseline + +[PR #1](https://github.com/codebeltnet/coverlet/pull/1), destination commit [`2515033`](https://github.com/codebeltnet/coverlet/commit/2515033182429c537ee25bd413ee1aaf55164619), established the narrowed package surface and Codebelt tooling on top of upstream [`da5c649`](https://github.com/coverlet-coverage/coverlet/commit/da5c649126c74985fc20c496bc898f143d18e21b). [PR #2](https://github.com/codebeltnet/coverlet/pull/2), commit [`9766806`](https://github.com/codebeltnet/coverlet/commit/97668061b72e03668de55d6cca39a559c1d3ff10), then added version/trust validation and release guards; `v10.0.1` points to that second commit. + +The inherited upstream range is [`7fbbe4e...da5c649`](https://github.com/coverlet-coverage/coverlet/compare/7fbbe4e72b06b0a6fe3932f65944c7627ecf6b17...da5c649126c74985fc20c496bc898f143d18e21b). The retained implementation includes these source changes; they were inherited through ancestry, not newly cherry-picked on 2026-09-20: + +| Upstream source | Retained behavior or test coverage | +| --- | --- | +| [`5654ffe`, #1938](https://github.com/coverlet-coverage/coverlet/commit/5654ffe) | Handle field references when detecting delegate-cache branches. | +| [`3a241bc`, #1940](https://github.com/coverlet-coverage/coverlet/commit/3a241bc) | Regression coverage for `DoesNotReturn` handling in async state machines. | +| [`c502253`, #1946](https://github.com/coverlet-coverage/coverlet/commit/c502253) and [`7f565ef`, #1958](https://github.com/coverlet-coverage/coverlet/commit/7f565ef) | MTP dynamic assembly exclusion filters and the ReportGenerator baseline exclusion. | +| [`6dbe06d`, #1941](https://github.com/coverlet-coverage/coverlet/commit/6dbe06d) | Auto-property skipping and record coverage improvements. | +| [`c962ca2`, #1967](https://github.com/coverlet-coverage/coverlet/commit/c962ca2) and [`1c2fc5d`, #2027](https://github.com/coverlet-coverage/coverlet/commit/1c2fc5d) | Instrumentation preflight checks and the later correction to unresolvable-dependency handling. | +| [`b68bf4f`, #1977](https://github.com/coverlet-coverage/coverlet/commit/b68bf4f) and [`330b1af`, #1979](https://github.com/coverlet-coverage/coverlet/commit/330b1af) | Pattern-matching branch detection and regression coverage. SDK-specific assertion updates are listed under intentional differences above. | +| [`f549c8b`, #1980](https://github.com/coverlet-coverage/coverlet/commit/f549c8b) | .NET Framework assembly-resolution improvements on Windows. | +| [`79dff97`, #1985](https://github.com/coverlet-coverage/coverlet/commit/79dff97) | Embed a .NET Standard tracker to preserve instrumentation compatibility with .NET Framework. | +| [`68e4db0`, #1987](https://github.com/coverlet-coverage/coverlet/commit/68e4db0), [`856637d`, #1988](https://github.com/coverlet-coverage/coverlet/commit/856637d), and [`e623ddc`, #2010](https://github.com/coverlet-coverage/coverlet/commit/e623ddc) | Tracker flush/unload handling and the corresponding MTP handler/registry changes. | +| [`eeb4310`, #2007](https://github.com/coverlet-coverage/coverlet/commit/eeb4310) | Fail explicitly when an instrumentation resource stream is unavailable. | +| [`4faeea2`, #2019](https://github.com/coverlet-coverage/coverlet/commit/4faeea2) | MTP coverage thresholds, exit-code handling, and coverage-message publishing. | +| [`4da2c9f`, #1936](https://github.com/coverlet-coverage/coverlet/commit/4da2c9f) and [`f9e6c86`, #2008](https://github.com/coverlet-coverage/coverlet/commit/f9e6c86) | MTP validation and internal regression-test infrastructure, adapted to the retained project set. | + +The 53-commit range also contains upstream dependency, documentation, benchmark, console, legacy-driver, and automation changes. Inherited history does not mean those surfaces remain available: PR #1 removed the console/collector/MSBuild projects and associated examples, broad documentation, benchmarks, upstream automation, and Nerdbank versioning. It established Codebelt package naming, per-TFM dependencies, the `netstandard2.0;net9.0;net10.0` layout used at that time, and Codebelt workflows. Existing fork restoration/diagnostic adaptations are listed above. [The full Codebelt baseline comparison](https://github.com/codebeltnet/coverlet/compare/da5c649126c74985fc20c496bc898f143d18e21b...97668061b72e03668de55d6cca39a559c1d3ff10) shows that narrowing and those adaptations. + +Historical validation: the [PR #1 head CI run](https://github.com/codebeltnet/coverlet/actions/runs/35518499258) succeeded at [`8c58545`](https://github.com/codebeltnet/coverlet/commit/8c585456041b54b73fa51dae615f97e88ec0f410). The subsequent [PR #2 head run](https://github.com/codebeltnet/coverlet/actions/runs/35523365224), at [`5c3fd90`](https://github.com/codebeltnet/coverlet/commit/5c3fd90164c410a6c4093b39bc61ed839b754ae1), failed during checkout before compilation or tests. No successful CI result is claimed here for that exact tag state. Later release validations are recorded separately. + +### Original upstream marker: v10.0.1-coverlet + +[`v10.0.1-coverlet`](https://github.com/codebeltnet/coverlet/tree/v10.0.1-coverlet) points to upstream commit [`7fbbe4e`](https://github.com/coverlet-coverage/coverlet/commit/7fbbe4e72b06b0a6fe3932f65944c7627ecf6b17), whose source commit date is 2026-05-18. It records the original starting point, not a Codebelt release or the later shared comparison base. The lightweight tag does not establish when the fork was created or when the tag was applied. Original upstream history remains available in Git. + +## Keeping this record current + +Every sync must update this document in the same change set, as required by [AGENTS.md](AGENTS.md#upstream-sync-workflow). Record the operation date, exact upstream snapshot and comparison boundary, source-to-destination mapping, adaptations and exclusions, intentional behavior differences, and actual validation outcomes. Clearly mark deferred work or pending validation and update those statuses when completed. Preserve previous dated entries. Releases remain documented in `CHANGELOG.md`; this ledger tracks provenance and decisions. diff --git a/coverlet.slnx b/coverlet.slnx index efdf89746..d5df2e132 100644 --- a/coverlet.slnx +++ b/coverlet.slnx @@ -14,6 +14,7 @@ + From 0b04b05d6d996ac945869d0f9e5e96224246d309 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 31 Oct 2026 20:01:23 +0100 Subject: [PATCH 18/18] =?UTF-8?q?=F0=9F=92=AC=20require=20dated=20upstream?= =?UTF-8?q?=20synchronization=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require UPSTREAM.md updates alongside every sync, with operation dates, source and destination evidence, exclusions, adaptations, and actual validation outcomes. Keep meaningful fork differences explicit and preserve the distinct roles of the README, changelog, and maintenance guidance. --- AGENTS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 14cbbac78..889ace99e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This fork is intentionally narrower than `coverlet-coverage/coverlet`. Preserve When synchronizing from `coverlet-coverage/coverlet`, keep the sync selective: -1. Fetch upstream without tags to avoid importing upstream release tags into this fork: +1. Read `UPSTREAM.md` before selecting changes. It records the reviewed upstream snapshot, completed ports, exclusions, and intentional fork differences. Fetch upstream without tags to avoid importing upstream release tags into this fork: ```powershell git fetch --no-tags https://github.com/coverlet-coverage/coverlet.git master:refs/remotes/coverlet-upstream/master @@ -23,8 +23,11 @@ When synchronizing from `coverlet-coverage/coverlet`, keep the sync selective: 4. Do not reintroduce upstream surfaces removed by this fork, including legacy console, collector, msbuild packages, legacy Azure Pipelines, broad upstream documentation, or `version.json`. 5. Preserve `Codebelt.Coverlet.MTP` package identity, `Coverlet.MTP` assembly identity, and the multi-targeted `netstandard2.0;net10.0;net11.0` layout. 6. Keep versioning on MinVer `v*` tags and the existing release workflow guards. Do not restore upstream Nerdbank.GitVersioning files. -7. Adapt release notes to this fork's root `CHANGELOG.md`; describe only changes that were actually ported. -8. If an upstream tag was accidentally fetched and points at an upstream commit, delete that local tag before release preparation: +7. Update `UPSTREAM.md` in the same change set as every upstream sync, including a no-change review. Record the operation date (`YYYY-MM-DD`), upstream repository/branch and exact reviewed commit, comparison base, source commits or PRs, what was ported or adapted, what was already present, and what was omitted or deferred with reasons. Link destination commits or the PR when available. Record validation actually performed, its target frameworks and outcomes, and any unresolved work. Distinguish reviewed, ported, tested, and released states; a reviewed upstream tip does not mean every preceding commit was merged. Update the entry when validation or integration completes, and preserve earlier entries. + When reconstructing earlier operations, verify release-tag targets and distinguish inherited upstream ancestry, selective ports, and Codebelt-only maintenance. Keep the original upstream marker (`v10.0.1-coverlet`) distinct from the later shared comparison base. State whether a date comes from a source commit, integration commit, or recorded operation; do not invent tag creation dates, synchronization dates, or validation results. Do not move tags to make the history appear aligned. +8. Keep meaningful production behavior differences from upstream documented in `UPSTREAM.md`, with a reason and source or regression-test link. Preserve upstream behavior by default; the target-framework focus and Codebelt packaging/tooling do not authorize unrelated features or silent coverage-semantic changes. +9. Adapt release notes to this fork's root `CHANGELOG.md`; describe only changes that were actually ported. Keep `README.md` focused on orientation and usage, with a link to `UPSTREAM.md`; keep the detailed sync ledger in `UPSTREAM.md` and the maintenance rules here. +10. If an upstream tag was accidentally fetched and points at an upstream commit, delete that local tag before release preparation: ```powershell git tag -d v