From 23578d4412a15c34b380111b3c1fd823ab845197 Mon Sep 17 00:00:00 2001 From: Alex Thornton Date: Mon, 21 Sep 2026 01:11:26 -0700 Subject: [PATCH 01/21] Use --config-file CLI arg in coverlet.MTP (#2030) When determining the configuration to be used in each test module execution, we're now using the `--config-file` CLI argument, loading configuration from the file it refers to (when that argument is present), rather than falling back to the legacy file search. --- src/coverlet.MTP/Collector/CollectorExtension.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/coverlet.MTP/Collector/CollectorExtension.cs b/src/coverlet.MTP/Collector/CollectorExtension.cs index f42690c53..c431b4995 100644 --- a/src/coverlet.MTP/Collector/CollectorExtension.cs +++ b/src/coverlet.MTP/Collector/CollectorExtension.cs @@ -124,8 +124,18 @@ Task ITestHostProcessLifetimeHandler.BeforeTestHostProcessStartAsync(Cancellatio return Task.CompletedTask; } - // Load configuration file settings (coverlet.mtp.appsettings.json) - CoverletMTPSettings? configFileSettings = LoadConfigurationFileSettings(_testModulePath!); + CoverletMTPSettings? configFileSettings; + + if (_commandLineOptions.TryGetOptionArgumentList("--config-file", out string[]? configFilePaths)) + { + // If a config file was passed via the command-line, we'll use that. + configFileSettings = TestConfigParser.ParseFromFile(configFilePaths[0], _testModulePath!, _fileSystem); + } + else + { + // If not, we'll try loading up a legacy one (coverlet.mtp.appsettings.json) instead. + configFileSettings = LoadConfigurationFileSettings(_testModulePath!); + } // Create merged configuration: command-line options take precedence over config file. // testAssemblyName is passed so the dynamic exclude-filter logic can omit the test assembly itself. From d212822a04353310665caaeba9471e70b19dfbc0 Mon Sep 17 00:00:00 2001 From: Yuriy Tumanov <57435526+Eljees@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:28:49 +0300 Subject: [PATCH 02/21] Fix silently empty coverage for shared-framework assemblies missing from compileLibraries (#2032) TryWithCustomResolverOnDotNetCore in CecilAssemblyResolver only resolves assemblies by looking them up in DependencyContext.CompileLibraries (built from the instrumented module's *.deps.json). Starting with the .NET SDK 10.0.4xx feature band, transitively shared Microsoft.Extensions.* assemblies (e.g. Microsoft.Extensions.Logging, referenced only via a transitive FrameworkReference to Microsoft.AspNetCore.App, not directly on the instrumented project) are no longer enumerated in compileLibraries at all. When a method has a default parameter of such a shared-framework enum type, Cecil's resolver misses it and throws CecilAssemblyResolutionException while instrumenting -- and that failure silently drops the entire module out of the coverage report (coverage.cobertura.xml ends up with an empty ) even though `dotnet test` itself is green, with no diagnostic pointing at the real cause. NetCoreSharedFrameworkResolver already exists in the same file and can resolve these assemblies directly from the shared-framework directory via runtimeconfig.json, but it was only ever consulted as a constructor fallback, never re-tried when the compileLibraries lookup specifically misses by name. This change adds that fallback: on a compileLibraries miss, ask NetCoreSharedFrameworkResolver directly before giving up. Verified against a live repro matching the reporter's stack trace (CecilAssemblyResolutionException -> Mono.Cecil.MetadataBuilder.GetConstantType) on dotnet SDK 10.0.400/10.0.401 (the same feature band as the report). Added TestInstrument_NetstandardAwareAssemblyResolver_MissingFromCompileLibrariesFallsBackToSharedFramework, modeled on the neighboring _SiblingRuntimeConfigCanResolveSharedFrameworkAssembly test: a module with no adjacent .deps.json forces the compileLibraries lookup to miss, and the test asserts System.Text.Json still resolves via NetCoreSharedFrameworkResolver. 5/5 green on net10.0 (net8.0 leg not run in this environment: the SDK image only ships the 10.0.11 runtime, an environment gap, not a test issue). Fixes #2026 Signed-off-by: Eljees <3.14hell@gmail.com> --- .../Instrumentation/CecilAssemblyResolver.cs | 23 +++++++++ .../Instrumentation/InstrumenterTests.cs | 50 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs b/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs index 8efd013b8..6df84950b 100644 --- a/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs +++ b/src/coverlet.core/Instrumentation/CecilAssemblyResolver.cs @@ -400,6 +400,29 @@ internal AssemblyDefinition TryWithCustomResolverOnDotNetCore(AssemblyNameRefere return asm.Value; } + // The assembly can be missing from every *.deps.json's compileLibraries yet still be + // physically present in a shared framework (e.g. Microsoft.AspNetCore.App). This happens + // when the FrameworkReference that pulls it in is declared transitively - on a referenced + // project/package - rather than directly on the instrumented module; recent SDKs + // (observed starting with the 10.0.4xx feature band) stop listing such assemblies in + // compileLibraries even though NetCoreSharedFrameworkResolver (already part of + // _compositeResolver, see constructor) can still find them on disk. Ask it directly by + // name before giving up. + try + { + var sharedFrameworkLibrary = new CompilationLibrary("package", name.Name, "0.0.0.0", null, [name.Name], [], false); + string sharedFrameworkPath = sharedFrameworkLibrary.ResolveReferencePaths(_compositeResolver.Value).FirstOrDefault(); + if (!string.IsNullOrEmpty(sharedFrameworkPath)) + { + _logger.LogVerbose($"'{name}' not listed in any compileLibraries, resolved via shared framework fallback: '{sharedFrameworkPath}'"); + return AssemblyDefinition.ReadAssembly(sharedFrameworkPath, new ReaderParameters() { AssemblyResolver = this }); + } + } + catch (Exception ex) + { + _logger.LogVerbose($"TryWithCustomResolverOnDotNetCore shared framework fallback exception: {ex}"); + } + throw new CecilAssemblyResolutionException($"AssemblyResolutionException for '{name}'. Try to add true to test projects or pass '/p:CopyLocalLockFileAssemblies=true' option to the 'dotnet test' command-line", new AssemblyResolutionException(name)); } } diff --git a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs index 729b69a98..87d23ce6a 100644 --- a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs +++ b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs @@ -798,6 +798,56 @@ public void TestInstrument_NetstandardAwareAssemblyResolver_SiblingRuntimeConfig } } + [Fact] + public void TestInstrument_NetstandardAwareAssemblyResolver_MissingFromCompileLibrariesFallsBackToSharedFramework() + { + // Regression test for https://github.com/coverlet-coverage/coverlet/issues/2026: an assembly + // can be completely absent from every *.deps.json's compileLibraries (e.g. when the + // FrameworkReference that pulls it in is declared transitively - on a referenced + // project/package - rather than directly on the instrumented module; recent SDKs, observed + // starting with the 10.0.4xx feature band, stop listing such assemblies there) yet still be + // physically present in a shared framework directory (e.g. Microsoft.AspNetCore.App). + // Before the fix, TryWithCustomResolverOnDotNetCore gave up the moment the compileLibraries + // lookup missed, without ever asking NetCoreSharedFrameworkResolver directly - even though + // that resolver (already part of _compositeResolver, see constructor) could find it on disk. + string tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDirectory); + try + { + // No *.deps.json is written here on purpose: TryWithCustomResolverOnDotNetCore's + // Directory.GetFiles(..., "*.deps.json") scan then finds nothing, so its "libraries" + // dictionary stays empty and the initial lookup misses - exactly the state a caller sees + // when their assembly isn't listed in any compileLibraries at all. + string modulePath = Path.Combine(tempDirectory, "module.without.deps.json.dll"); + + string runtimeVersion = new DirectoryInfo(Path.GetDirectoryName(typeof(object).Assembly.Location)!).Name; + string runtimeConfigFile = Path.Combine(tempDirectory, "testhost.runtimeconfig.json"); + File.WriteAllText(runtimeConfigFile, + "{\n" + + " \"runtimeOptions\": {\n" + + " \"tfm\": \"net8.0\",\n" + + " \"framework\": {\n" + + " \"name\": \"Microsoft.NETCore.App\",\n" + + $" \"version\": \"{runtimeVersion}\"\n" + + " }\n" + + " }\n" + + "}\n"); + + var netstandardResolver = new NetstandardAwareAssemblyResolver(modulePath, _mockLogger.Object); + AssemblyName textJsonAssembly = typeof(System.Text.Json.JsonSerializer).Assembly.GetName(); + + AssemblyDefinition asm = netstandardResolver.TryWithCustomResolverOnDotNetCore( + new AssemblyNameReference(textJsonAssembly.Name, textJsonAssembly.Version)); + + Assert.NotNull(asm); + Assert.Equal(textJsonAssembly.Name, asm.Name.Name); + } + finally + { + Directory.Delete(tempDirectory, true); + } + } + [Fact] public void TestReachabilityHelper() { From c6d3894d2e8546e2fded76eed709a415edcc220a Mon Sep 17 00:00:00 2001 From: Yuriy Tumanov <57435526+Eljees@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:36:43 +0300 Subject: [PATCH 03/21] Add regression test for #1727 (ExcludeByAttribute=CompilerGeneratedAttribute drops async state machine) (#2033) The underlying bug was already fixed by 3a241bc (#1940) for the related #1843, but no existing test exercises the exact configuration reported in #1727 (ExcludeAttributes=[CompilerGeneratedAttribute] on a constructorless class). Adds that missing coverage; no src/ changes. Fixes #1727 --- .../Coverage/CoverageTests.AsyncAwait.cs | 44 +++++++++++++++++++ .../Coverage/InstrumenterHelper.cs | 5 ++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/test/coverlet.core.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs index 525962bbc..2bb1e276f 100644 --- a/test/coverlet.core.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs +++ b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs @@ -102,6 +102,50 @@ public void AsyncAwait_Issue_669_1() } } + [Fact] + public void AsyncAwait_Issue_1727_ExcludeByCompilerGeneratedAttribute_StateMachineStillInstrumented() + { + // Issue #1727: with CompilerGeneratedAttribute + // (the coverlet setting commonly recommended for ASP.NET projects to silence Razor/ + // top-level-statement noise), every async method's compiler-generated state machine + // class -- which the compiler always stamps with [CompilerGenerated] -- used to be + // excluded outright, so async methods vanished from the coverage report with no error. + // Fixed by #1940 (IsCompilerGeneratedStateMachineType check in Instrumenter.IsTypeExcluded): + // state machine types now ignore CompilerGeneratedAttribute/GeneratedCodeAttribute + // specifically, while still honoring any other user-configured exclusion attribute. + // This test reruns the ConfigureAwait() scenario from the "AsyncAwait" test above with + // that exclude attribute set and expects the SAME lines covered as the unfiltered run + // (a plain, Moq-free sample is used deliberately: reusing a Moq-backed sample class, + // e.g. Issue_669_1, from a second test in the same process trips an unrelated Castle + // DynamicProxy "Invalid assembly public key" collision -- TestInstrumentationHelper.Run + // reloads a fresh copy of the test assembly per call, and Moq's proxy-type cache does + // not expect the same mocked interface to be reloaded from two different assembly + // copies within one test host process). + string path = Path.GetTempFileName(); + try + { + FunctionExecutor.Run(async (string[] pathSerialize) => + { + CoveragePrepareResult coveragePrepareResult = await TestInstrumentationHelper.Run(async instance => + { + int res = await (Task)instance.ConfigureAwait(); + }, + persistPrepareResultToFile: pathSerialize[0], + excludeAttributes: new[] { "CompilerGeneratedAttribute" }); + + return 0; + }, [path]); + + TestInstrumentationHelper.GetCoverageResult(path) + .Document("Instrumentation.AsyncAwait.cs") + .AssertLinesCovered(BuildConfiguration.Debug, (89, 1), (90, 1)); + } + finally + { + File.Delete(path); + } + } + [Fact] public void AsyncAwait_Issue_669_2() { diff --git a/test/coverlet.core.coverage.tests/Coverage/InstrumenterHelper.cs b/test/coverlet.core.coverage.tests/Coverage/InstrumenterHelper.cs index 526fdc5eb..a99cc9eca 100644 --- a/test/coverlet.core.coverage.tests/Coverage/InstrumenterHelper.cs +++ b/test/coverlet.core.coverage.tests/Coverage/InstrumenterHelper.cs @@ -77,7 +77,8 @@ public static async Task Run(Func callM string persistPrepareResultToFile = null, bool disableRestoreModules = false, bool skipAutoProps = false, - string assemblyLocation = null) + string assemblyLocation = null, + string[] excludeAttributes = null) { if (persistPrepareResultToFile is null) { @@ -112,7 +113,7 @@ public static async Task Run(Func callM "[coverlet.*]*" }).ToArray(), ExcludedSourceFiles = Array.Empty(), - ExcludeAttributes = Array.Empty(), + ExcludeAttributes = excludeAttributes ?? Array.Empty(), IncludeTestAssembly = true, SingleHit = false, MergeWith = string.Empty, From 10a8051216eeaebb661624eef348ee768693fed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller?= Date: Sun, 27 Sep 2026 00:39:37 +0200 Subject: [PATCH 04/21] Prepare release: update version + changelog (#2034) * update version + changelog * update change log file * update changelog --- Documentation/Changelog.md | 19 +++++++++++++++++-- Documentation/ReleasePlan.md | 2 +- version.json | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Documentation/Changelog.md b/Documentation/Changelog.md index 8b14cf30d..9f0c59c07 100644 --- a/Documentation/Changelog.md +++ b/Documentation/Changelog.md @@ -7,23 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## Release date 2026-09-27 +### Packages +coverlet.msbuild 10.1.0 +coverlet.console 10.1.0 +coverlet.collector 10.1.0 +coverlet.MTP 10.1.0 + ### Improvements - Publish Microsoft.Testing.Platform coverage messages from coverlet.MTP [#2019](https://github.com/coverlet-coverage/coverlet/pull/2019) - Implement dynamic exclusion filters for assemblies (Coverlet.MTP) [#1946](https://github.com/coverlet-coverage/coverlet/pull/1946) - Replace legacy .sln files with modern .slnx format [#1966](https://github.com/coverlet-coverage/coverlet/pull/1966) - coverlet.console: add trace diagnostics and actionable warnings for instrumentation/hit/empty-result failures [#2005](https://github.com/coverlet-coverage/coverlet/pull/2005) +- Relax auto-property skip logic and improve coverage for records [#1941](https://github.com/coverlet-coverage/coverlet/pull/1941) ### Fixed - Fix coverlet.MTP does not collect coverage on the .NET Framework portion of a large project [#1980](https://github.com/coverlet-coverage/coverlet/pull/1980) [#1967](https://github.com/coverlet-coverage/coverlet/pull/1967) - Fix Regression in branch coverage for lambda expressions [#1938](https://github.com/coverlet-coverage/coverlet/pull/1938) - Fix When using "is" with "or" in pattern matching, branch coverage is lower than normal [#1979](https://github.com/coverlet-coverage/coverlet/pull/1979) -- Fix silent zero coverage on .NET Framework since 8.0.0 [#1985](https://github.com/coverlet-coverage/coverlet/pull/1985) -- Fix Race condition between ProcessExit hit-file write and out-of-proc coverage read causes EndOfStreamException [#1987](https://github.com/coverlet-coverage/coverlet/pull/1987) [#1988](https://github.com/coverlet-coverage/coverlet/pull/1988) +- Fix silent zero coverage on .NET Framework since 8.0.0 [#1985](https://github.com/coverlet-coverage/coverlet/pull/1985) by +- Fix Race condition between ProcessExit hit-file write and out-of-proc coverage read causes EndOfStreamException [#1987](https://github.com/coverlet-coverage/coverlet/pull/1987) [#1988](https://github.com/coverlet-coverage/coverlet/pull/1988) by - Fix Regression TypeInitializationException when targeting .NET Framework - Could not load type 'System.Collections.Concurrent.ConcurrentBag [#2010](https://github.com/coverlet-coverage/coverlet/pull/2010) +- Fix use --config-file CLI arg in coverlet.MTP [#2030](https://github.com/coverlet-coverage/coverlet/pull/2030) by +- Fix silently empty coverage for shared-framework assemblies missing from compileLibraries [#2032](https://github.com/coverlet-coverage/coverlet/pull/2032) by ## Release date 2026-05-18 +### Packages +coverlet.msbuild 10.0.1 +coverlet.console 10.0.1 +coverlet.collector 10.0.1 +coverlet.MTP 10.0.1 ### Improvements diff --git a/Documentation/ReleasePlan.md b/Documentation/ReleasePlan.md index 71e8afe6e..17ae32ce3 100644 --- a/Documentation/ReleasePlan.md +++ b/Documentation/ReleasePlan.md @@ -50,7 +50,7 @@ In the following example the benchmark repository refit (\version.json` and `\src\legacy\version.json` (remove `-preview.{height}` and adjust version) +1. Update projects version file `\version.json` (remove `-preview.{height}` and adjust version) Do a PR and merge to master. diff --git a/version.json b/version.json index 92a03f70b..d40e0cd35 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "10.0.2-preview.{height}", + "version": "10.1.0", "publicReleaseRefSpec": [ "^refs/heads/master$" ], From be3d696f8b5c0b9d539da9dea109a98d9800117d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller?= Date: Sun, 27 Sep 2026 15:50:23 +0200 Subject: [PATCH 05/21] adapt version + docs (#2035) --- Documentation/ReleasePlan.md | 4 ++-- version.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation/ReleasePlan.md b/Documentation/ReleasePlan.md index 17ae32ce3..61b62e7b8 100644 --- a/Documentation/ReleasePlan.md +++ b/Documentation/ReleasePlan.md @@ -25,9 +25,9 @@ We release 4 components as NuGet packages: Before creating a new release it makes sense to test the new release against a benchmark repository. This can help to determine bugs that haven't been found by the unit/integration tests. Therefore, coverage of the latest release is compared with our nightly build. -In the following example the benchmark repository refit () is used which already uses coverlet for coverage. +In the following example the benchmark repository kiota () is used which already uses coverlet for coverage. -1. Clone the benchmark repository () +1. Clone the benchmark repository () 2. Check if latest coverlet version is used by the project, otherwise add coverlet to the project (). 3. Create coverage report for latest coverlet version: diff --git a/version.json b/version.json index d40e0cd35..cbb52996d 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "10.1.0", + "version": "10.1.1-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/master$" ], From 8b7654e2c0330748ce1f885ef61153bee24d954e Mon Sep 17 00:00:00 2001 From: Bert Date: Tue, 29 Sep 2026 11:12:49 +0200 Subject: [PATCH 06/21] Update 01-bug_report.md Update "To Reproduce" and "Actual behavior" text. --- .github/ISSUE_TEMPLATE/01-bug_report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01-bug_report.md b/.github/ISSUE_TEMPLATE/01-bug_report.md index caf3042e5..1fed14019 100644 --- a/.github/ISSUE_TEMPLATE/01-bug_report.md +++ b/.github/ISSUE_TEMPLATE/01-bug_report.md @@ -11,13 +11,13 @@ assignees: '' Please share a clear and concise description of the problem. **To Reproduce** -Please include minimal steps to reproduce the problem if possible. E.g.: the smallest possible code snippet; or a small project, with steps to run it. If possible include text as text rather than screenshots (so it shows up in searches). +Please provide the shortest steps to reproduce the problem, such as a small code snippet or project and instructions to run it. Include text directly instead of screenshots when possible so it can be searched. For code issues, a minimal .NET project is especially helpful; you can attach it as a ZIP archive. **Expected behavior** Provide a description of the expected behavior. **Actual behavior** -Provide a description of the actual behavior observed. If applicable please include any error messages, exception stacktraces or memory dumps. +Describe the behavior you observed, including any relevant error messages, stack traces, or memory dumps. If possible, generate diagnostic logs using the CLI options and attach them to the issue. **Configuration (please complete the following information):** Please provide more information on your .NET configuration: From 61e335032fae02bfbd56392fdf5ca4be1aa5ad67 Mon Sep 17 00:00:00 2001 From: Bert Date: Thu, 1 Oct 2026 09:44:19 +0200 Subject: [PATCH 07/21] Add regression test for cli option --config-file (#2038) Extend MTP config-file coverage to include OpenCover diagnostics and add a Windows regression test for central config loading, legacy config ignoring, and report count/prefix validation. --- .../ConfigurationFileTests.cs | 91 ++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs b/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs index fac2065c1..e52df33bf 100644 --- a/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs +++ b/test/coverlet.MTP.validation.tests/ConfigurationFileTests.cs @@ -28,6 +28,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). @@ -106,7 +108,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 } }"); @@ -150,6 +152,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) @@ -191,6 +202,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]); } /// @@ -602,6 +614,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()); From 3142be50ab2a46721f7d0bef424a38d2b83c4017 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:59:02 +0200 Subject: [PATCH 08/21] Bump Moq from 4.20.72 to 4.21.0 (#2044) --- updated-dependencies: - dependency-name: Moq dependency-version: 4.21.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: Moq dependency-version: 4.21.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: Moq dependency-version: 4.21.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: Moq dependency-version: 4.21.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- .../coverlet.integration.tests.csproj | 2 +- .../coverlet.tests.projectsample.aspmvcrazor.tests.csproj | 2 +- .../coverlet.tests.projectsample.aspnet8.tests.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1153f6c17..4b3edc850 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ 4.0.1 4.0.0 2.4.1 - 4.20.72 + 4.21.0 10.0.12 diff --git a/test/coverlet.integration.tests/coverlet.integration.tests.csproj b/test/coverlet.integration.tests/coverlet.integration.tests.csproj index f89291adb..48fcf6257 100644 --- a/test/coverlet.integration.tests/coverlet.integration.tests.csproj +++ b/test/coverlet.integration.tests/coverlet.integration.tests.csproj @@ -21,7 +21,7 @@ - + diff --git a/test/coverlet.tests.projectsample.aspmvcrazor.tests/coverlet.tests.projectsample.aspmvcrazor.tests.csproj b/test/coverlet.tests.projectsample.aspmvcrazor.tests/coverlet.tests.projectsample.aspmvcrazor.tests.csproj index 62d17d6fe..a34fd81cf 100644 --- a/test/coverlet.tests.projectsample.aspmvcrazor.tests/coverlet.tests.projectsample.aspmvcrazor.tests.csproj +++ b/test/coverlet.tests.projectsample.aspmvcrazor.tests/coverlet.tests.projectsample.aspmvcrazor.tests.csproj @@ -11,7 +11,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/coverlet.tests.projectsample.aspnet8.tests/coverlet.tests.projectsample.aspnet8.tests.csproj b/test/coverlet.tests.projectsample.aspnet8.tests/coverlet.tests.projectsample.aspnet8.tests.csproj index 2e22a4056..e2a1fce20 100644 --- a/test/coverlet.tests.projectsample.aspnet8.tests/coverlet.tests.projectsample.aspnet8.tests.csproj +++ b/test/coverlet.tests.projectsample.aspnet8.tests/coverlet.tests.projectsample.aspnet8.tests.csproj @@ -11,7 +11,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive From b330a1fa401703d8ef9f215487e5c852e606c756 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:00:24 +0200 Subject: [PATCH 09/21] Bump Microsoft.Sbom.Targets from 4.1.5 to 4.1.13 (#2043) --- updated-dependencies: - dependency-name: Microsoft.Sbom.Targets dependency-version: 4.1.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4b3edc850..86dcba919 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + From 45e98f98c4e00f0896b241b79745e5c46d9703af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:03:48 +0200 Subject: [PATCH 10/21] Bump github/codeql-action from 4.37.9 to 4.38.2 (#2039) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.9 to 4.38.2. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.9...v4.38.2) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.38.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 01fd1f160..a8747846a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,7 +49,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.9 + uses: github/codeql-action/init@v4.38.2 with: languages: ${{ matrix.language }} build-mode: "manual" @@ -77,6 +77,6 @@ jobs: dotnet build coverlet.slnx --configuration Debug - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.9 + uses: github/codeql-action/analyze@v4.38.2 with: category: "/language:${{matrix.language}}" From ecd6fd5533e77880ab36748edb4f892df642a7dd Mon Sep 17 00:00:00 2001 From: Bert Date: Sat, 3 Oct 2026 16:37:28 +0200 Subject: [PATCH 11/21] fix regression issue 2045 (#2046) InstrumentType now detects local functions via IInstrumentationHelper.IsLocalMethod, and for those methods ignores CompilerGeneratedAttribute/GeneratedCodeAttribute when deciding exclusion (reusing the existing IsCompilerGeneratedOrGeneratedCodeAttribute helper), while still honoring any other user-configured exclude attribute (e.g. ExcludeFromCodeCoverage) applied directly to a local function. --- .../Instrumentation/Instrumenter.cs | 15 +++++++++++-- .../Instrumentation/InstrumenterTests.cs | 21 +++++++++++++++++++ test/coverlet.core.tests/Samples/Samples.cs | 20 ++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/coverlet.core/Instrumentation/Instrumenter.cs b/src/coverlet.core/Instrumentation/Instrumenter.cs index 2d3e3c41c..126cb00ef 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); } diff --git a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs index 87d23ce6a..b91af28b8 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/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 From 221483a7727c8aab4bed6c4d1f8aa73f0c5c7539 Mon Sep 17 00:00:00 2001 From: Bert Date: Tue, 13 Oct 2026 12:53:57 +0200 Subject: [PATCH 12/21] fix URL and add note for PATH update (#2049) --- Documentation/GlobalTool.md | 3 +++ README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Documentation/GlobalTool.md b/Documentation/GlobalTool.md index 33f8d6ce3..17139a2d2 100644 --- a/Documentation/GlobalTool.md +++ b/Documentation/GlobalTool.md @@ -280,6 +280,9 @@ dotnet tool install --global coverlet.console --version 10.0.2 dotnet tool install --global coverlet.console --source c:\local_source --prerelease ``` +> [!IMPORTANT] +> The option `--global` requires the update PATH to point to the .NET tools location. Make sure your shell’s PATH includes that location on Windows or Linux (see [Global tools](https://learn.microsoft.com/en-us/dotnet/core/tools/troubleshoot-usage-issues#global-tools)). + ## SourceLink Coverlet supports [SourceLink](https://github.com/dotnet/sourcelink) custom debug information contained in PDBs. When you specify the `--use-source-link` flag, Coverlet will generate results that contain the URL to the source files in your source control instead of local file paths. diff --git a/README.md b/README.md index b1c8f7bf5..797e2cdf0 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ See [documentation](Documentation/GlobalTool.md) for advanced usage. #### Requirements (coverlet.console) -* .NET global tools rely on a .NET Core runtime installed on your machine +* .NET global tools rely on a .NET Core runtime installed on your machine * .NET Coverlet global tool requires *.NET 8.0 or above* > [!WARNING] From 710f30144157ef19933f3f0ec48464874205e264 Mon Sep 17 00:00:00 2001 From: Bert Date: Wed, 14 Oct 2026 09:54:03 +0200 Subject: [PATCH 13/21] Update centralized package versions (#2050) * Update centralized package versions Bump centrally managed NuGet, test, xUnit, Microsoft Testing Platform, and LinqKit.Microsoft.EntityFrameworkCore package versions in Directory.Packages.props. * Update .NET SDK and package dependencies - Refresh .NET SDK, runtime, and package patch versions. - Align legacy builds with SDK 9.0.319. - Centralize System.Text.Json versioning. - Add AsyncInterfaces to MTP validation tests. --- Directory.Packages.props | 16 ++++++++-------- eng/build-legacy.yml | 6 +++--- eng/build.yml | 8 ++++---- global.json | 2 +- src/coverlet.core/coverlet.core.csproj | 8 ++++---- src/legacy/global-legacy.json | 2 +- .../coverlet.MTP.validation.tests.csproj | 1 + .../coverlet.integration.template.csproj | 2 +- 8 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 86dcba919..7774db2f0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,14 +11,14 @@ 17.11.48 5.9.0 - 7.6.0 + 7.9.0 - 18.10.1 - 4.0.1 - 4.0.0 - 2.4.1 + 18.11.0 + 4.0.2 + 4.0.1 + 2.5.1 4.21.0 - 10.0.12 + 10.0.13 @@ -65,8 +65,8 @@ - - + + diff --git a/eng/build-legacy.yml b/eng/build-legacy.yml index 349075fd0..d953a6840 100644 --- a/eng/build-legacy.yml +++ b/eng/build-legacy.yml @@ -4,13 +4,13 @@ steps: - task: UseDotNet@2 inputs: - version: 8.0.421 - displayName: Install .NET Core SDK 8.0.421 + version: 8.0.426 + displayName: Install .NET Core SDK 8.0.426 - task: UseDotNet@2 inputs: useGlobalJson: true - displayName: Install .NET Core SDK 9.0.315 (global.json) + displayName: Install .NET Core SDK 9.0.319 (global.json) # create artifact/package folder - pwsh: | diff --git a/eng/build.yml b/eng/build.yml index c30881039..bdaa881dc 100644 --- a/eng/build.yml +++ b/eng/build.yml @@ -1,13 +1,13 @@ steps: - task: UseDotNet@2 inputs: - version: 8.0.421 - displayName: Install .NET Core SDK 8.0.421 + version: 8.0.426 + displayName: Install .NET Core SDK 8.0.426 - task: UseDotNet@2 inputs: - version: 9.0.315 - displayName: Install .NET Core SDK 9.0.315 + version: 9.0.319 + displayName: Install .NET Core SDK 9.0.319 - task: UseDotNet@2 inputs: diff --git a/global.json b/global.json index 0777cf772..9e9a6858a 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.401", + "version": "10.0.402", "rollForward": "latestFeature" }, "test": { diff --git a/src/coverlet.core/coverlet.core.csproj b/src/coverlet.core/coverlet.core.csproj index 1b763abb9..c1b70e44b 100644 --- a/src/coverlet.core/coverlet.core.csproj +++ b/src/coverlet.core/coverlet.core.csproj @@ -22,9 +22,9 @@ - - - + + + @@ -42,7 +42,7 @@ - + diff --git a/src/legacy/global-legacy.json b/src/legacy/global-legacy.json index 5dda255ab..9b8e8b377 100644 --- a/src/legacy/global-legacy.json +++ b/src/legacy/global-legacy.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "9.0.315" + "version": "9.0.319" } } 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 c8220dd28..78aa7f315 100644 --- a/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj +++ b/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj @@ -23,6 +23,7 @@ + diff --git a/test/coverlet.integration.template/coverlet.integration.template.csproj b/test/coverlet.integration.template/coverlet.integration.template.csproj index 803e400d9..ec0c06d77 100644 --- a/test/coverlet.integration.template/coverlet.integration.template.csproj +++ b/test/coverlet.integration.template/coverlet.integration.template.csproj @@ -21,7 +21,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From a924cc3714eb9a4a02abb777231503440ae53a27 Mon Sep 17 00:00:00 2001 From: Bert Date: Wed, 14 Oct 2026 12:18:23 +0200 Subject: [PATCH 14/21] Add System.Security.Cryptography.Pkcs package reference Added System.Security.Cryptography.Pkcs package reference. --- .../coverlet.MTP.validation.tests.csproj | 1 + 1 file changed, 1 insertion(+) 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 78aa7f315..6a0cd0496 100644 --- a/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj +++ b/test/coverlet.MTP.validation.tests/coverlet.MTP.validation.tests.csproj @@ -24,6 +24,7 @@ + From d9e43575632585bf48c8e4b0247880f460192768 Mon Sep 17 00:00:00 2001 From: Bert Date: Thu, 15 Oct 2026 12:08:53 +0200 Subject: [PATCH 15/21] add CollectorExtensionThresholdCoverageTests.cs (#2051) * add CollectorExtensionThresholdCoverageTests.cs * add CoverageTests.CecilAssemblyResolver.cs --- ...ollectorExtensionThresholdCoverageTests.cs | 371 ++++++++++++++++++ .../CoverageTests.CecilAssemblyResolver.cs | 117 ++++++ 2 files changed, 488 insertions(+) create mode 100644 test/coverlet.MTP.tests/Collector/CollectorExtensionThresholdCoverageTests.cs create mode 100644 test/coverlet.core.coverage.tests/Coverage/CoverageTests.CecilAssemblyResolver.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.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}}" + } + } + } + """); + } + } +} From b5ba9197f032e22627836d899aa3956a50cd5017 Mon Sep 17 00:00:00 2001 From: Bert Date: Sun, 18 Oct 2026 15:39:34 +0200 Subject: [PATCH 16/21] use specific nuget packages versions for LEGACY (#2055) --- Directory.Packages.props | 43 ++++++++++++++------------ src/coverlet.core/coverlet.core.csproj | 2 +- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7774db2f0..8e5b44ca4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,8 @@ 17.11.48 - 5.9.0 + 5.9.0 + 5.3.0 7.9.0 @@ -18,7 +19,8 @@ 4.0.1 2.5.1 4.21.0 - 10.0.13 + 10.0.13 + 9.0.21 @@ -27,23 +29,23 @@ - + - - - - + + + + - + - + - + @@ -63,7 +65,8 @@ - + + @@ -72,20 +75,20 @@ - - - - - + + + + + - + - - - + + + diff --git a/src/coverlet.core/coverlet.core.csproj b/src/coverlet.core/coverlet.core.csproj index c1b70e44b..537657e78 100644 --- a/src/coverlet.core/coverlet.core.csproj +++ b/src/coverlet.core/coverlet.core.csproj @@ -42,7 +42,7 @@ - + From 39f6b6fa9dd9761a6e9a8d9ffb31a44fc342162d Mon Sep 17 00:00:00 2001 From: Peter Shrosbree Date: Tue, 20 Oct 2026 03:03:31 -0700 Subject: [PATCH 17/21] Compute branch reachability from the control-flow graph (supersedes #2047) (#2056) * Compute branch reachability from the control-flow graph Add the IsReachable property introduced by Bertk in #2047 to BranchPoint and BranchInfo. Compute branch reachability by walking each method's control-flow graph once, from the method entry. The walk follows branch targets, stops at return, throw and jmp, and enters the filter and handler of each exception handler whose try entry is reached. Every path of a branch gets the reachability of its branch instruction. Carry the flag through Instrumentation.Branch ([DataMember], default true), both places in Instrumenter that create a Branch, and the four BranchInfo initializers in Coverage. DataContractSerializer does not run field initializers, so Branch sets IsReachable to true in an OnDeserializing callback; otherwise an instrumentation state file without the field reads back with every branch unreachable. Branch coverage totals leave out unreachable branches, as in #2047. So do the per-branch details: Cobertura conditions, LCOV BRDA records and OpenCover branch points, so that each report's details agree with its totals. A Cobertura line whose branches are all unreachable is no longer marked as a branch line. The tests compile C# samples with Roslyn in Debug and in Release, so the branch points come from real IL with real offsets. Methods built with Cecil cover a branch and a switch that nothing reaches, and a branch after jmp. An end-to-end test in CoverageTests checks that the flag survives Instrumenter and Coverage. Refs #2036. Supersedes #2047. Co-authored-by: Bert * Move the disk-backed reachability test to coverlet.core.coverage.tests Coverage_DeadBranch_IsReachableFalseSurvivesInstrumenterAndCoverage writes a compiled module and its PDB to a temporary directory. .github/copilot-instructions.md asks unit tests not to use the file system, so move the test and the helper that builds its module to coverlet.core.coverage.tests, as the Copilot review on #2056 suggested. Coverage_BranchWithoutLines_IsReachableCopiedToBranchInfo uses mocks and stays in coverlet.core.tests. coverlet.core.coverage.tests does not reference coverlet.core.tests, so the moved test compiles its sample with its own copy of the Roslyn step, and BranchPointReachabilityTests.Compile is private again. --------- Co-authored-by: Bert --- Documentation/Changelog.md | 4 + src/coverlet.core/Coverage.cs | 8 +- src/coverlet.core/CoverageResult.cs | 7 + src/coverlet.core/CoverageSummary.cs | 7 +- .../Instrumentation/Instrumenter.cs | 6 +- .../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 ++++- .../Coverage/CoverageTests.IsReachable.cs | 153 ++++++++ .../Coverage/CoverageSummaryTests.cs | 48 +++ .../Coverage/CoverageTests.IsReachable.cs | 39 ++ .../Reporters/CoberturaReporterTests.cs | 97 +++++ .../Reporters/LcovReporterTests.cs | 40 ++ .../Reporters/OpenCoverReporterTests.cs | 45 +++ .../Symbols/BranchPointReachabilityTests.cs | 366 ++++++++++++++++++ 18 files changed, 926 insertions(+), 17 deletions(-) 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/Documentation/Changelog.md b/Documentation/Changelog.md index 9f0c59c07..263cf43a9 100644 --- a/Documentation/Changelog.md +++ b/Documentation/Changelog.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Improvements + +- Add `IsReachable` to branch points, computed from the method's control-flow graph, and exclude unreachable branches from branch-coverage totals and from branch details in Cobertura, LCOV and OpenCover reports (follow-up to [#2047](https://github.com/coverlet-coverage/coverlet/pull/2047)) + ## Release date 2026-09-27 ### Packages coverlet.msbuild 10.1.0 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 fe0620db1..99164256f 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 126cb00ef..a51e62bb0 100644 --- a/src/coverlet.core/Instrumentation/Instrumenter.cs +++ b/src/coverlet.core/Instrumentation/Instrumenter.cs @@ -1039,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 } ); @@ -1111,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; 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/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/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 870b8357be345f8eaca3e148c3af176f6289e542 Mon Sep 17 00:00:00 2001 From: Bert Date: Thu, 22 Oct 2026 10:21:20 +0200 Subject: [PATCH 18/21] Update Changelog.md document (#2057) --- Documentation/Changelog.md | 10 +++++++++- eng/publish-coverlet-result-files.yml | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Documentation/Changelog.md b/Documentation/Changelog.md index 263cf43a9..8df07eb86 100644 --- a/Documentation/Changelog.md +++ b/Documentation/Changelog.md @@ -9,7 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements -- Add `IsReachable` to branch points, computed from the method's control-flow graph, and exclude unreachable branches from branch-coverage totals and from branch details in Cobertura, LCOV and OpenCover reports (follow-up to [#2047](https://github.com/coverlet-coverage/coverlet/pull/2047)) +- Add `IsReachable` to branch points, computed from the method's control-flow graph, and exclude unreachable branches from branch-coverage totals and from branch details in Cobertura, LCOV and OpenCover reports [#2056](https://github.com/coverlet-coverage/coverlet/pull/2056) by + +### Fixed + +- Fix Regression coverlet.MTP does not instrument local functions [#2046](https://github.com/coverlet-coverage/coverlet/pull/2046) + +## Maintenance + +- Update NuGet package versions [#2050](https://github.com/coverlet-coverage/coverlet/pull/2050) ## Release date 2026-09-27 ### Packages diff --git a/eng/publish-coverlet-result-files.yml b/eng/publish-coverlet-result-files.yml index ce44db33c..8ea3f7cef 100644 --- a/eng/publish-coverlet-result-files.yml +++ b/eng/publish-coverlet-result-files.yml @@ -29,6 +29,7 @@ steps: !**/coverlet.core.coverage.tests/debug/testgen*.log !**/coverlet.core.coverage.tests/**/*.html !**/testgen_*.log + !**/tmp/**/*.trx TargetFolder: '$(Build.SourcesDirectory)/artifacts/TestLogs' - task: CopyFiles@2 From 2e0a852d3a0d9669d9c19445e366c69ccaaf6424 Mon Sep 17 00:00:00 2001 From: Bert Date: Tue, 27 Oct 2026 09:33:59 +0100 Subject: [PATCH 19/21] update SDK and runtime versions (#2059) --- Directory.Packages.props | 12 ++++++------ eng/build-legacy.yml | 6 +++--- eng/build.yml | 8 ++++---- global.json | 2 +- src/coverlet.core/coverlet.core.csproj | 8 ++++---- src/legacy/global-legacy.json | 2 +- .../coverlet.core.coverage.tests.csproj | 2 +- test/coverlet.core.tests/coverlet.core.tests.csproj | 10 ++-------- 8 files changed, 22 insertions(+), 28 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e5b44ca4..42b941104 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,14 +19,14 @@ 4.0.1 2.5.1 4.21.0 - 10.0.13 - 9.0.21 + 10.0.14 + 9.0.22 - + @@ -68,8 +68,8 @@ - - + + @@ -92,4 +92,4 @@ - + \ No newline at end of file diff --git a/eng/build-legacy.yml b/eng/build-legacy.yml index d953a6840..5723713d9 100644 --- a/eng/build-legacy.yml +++ b/eng/build-legacy.yml @@ -4,13 +4,13 @@ steps: - task: UseDotNet@2 inputs: - version: 8.0.426 - displayName: Install .NET Core SDK 8.0.426 + version: 8.0.427 + displayName: Install .NET Core SDK 8.0.427 - task: UseDotNet@2 inputs: useGlobalJson: true - displayName: Install .NET Core SDK 9.0.319 (global.json) + displayName: Install .NET Core SDK 9.0.320 (global.json) # create artifact/package folder - pwsh: | diff --git a/eng/build.yml b/eng/build.yml index bdaa881dc..3546e0083 100644 --- a/eng/build.yml +++ b/eng/build.yml @@ -1,13 +1,13 @@ steps: - task: UseDotNet@2 inputs: - version: 8.0.426 - displayName: Install .NET Core SDK 8.0.426 + version: 8.0.427 + displayName: Install .NET Core SDK 8.0.427 - task: UseDotNet@2 inputs: - version: 9.0.319 - displayName: Install .NET Core SDK 9.0.319 + version: 9.0.320 + displayName: Install .NET Core SDK 9.0.320 - task: UseDotNet@2 inputs: diff --git a/global.json b/global.json index 9e9a6858a..ee98200a6 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.402", + "version": "10.0.403", "rollForward": "latestFeature" }, "test": { diff --git a/src/coverlet.core/coverlet.core.csproj b/src/coverlet.core/coverlet.core.csproj index 537657e78..f8bd277be 100644 --- a/src/coverlet.core/coverlet.core.csproj +++ b/src/coverlet.core/coverlet.core.csproj @@ -1,4 +1,4 @@ - + Library @@ -22,9 +22,9 @@ - - - + + + diff --git a/src/legacy/global-legacy.json b/src/legacy/global-legacy.json index 9b8e8b377..a81108fc8 100644 --- a/src/legacy/global-legacy.json +++ b/src/legacy/global-legacy.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "9.0.319" + "version": "9.0.320" } } 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 5b70fc5dd..d5192a361 100644 --- a/test/coverlet.core.coverage.tests/coverlet.core.coverage.tests.csproj +++ b/test/coverlet.core.coverage.tests/coverlet.core.coverage.tests.csproj @@ -22,7 +22,7 @@ - + diff --git a/test/coverlet.core.tests/coverlet.core.tests.csproj b/test/coverlet.core.tests/coverlet.core.tests.csproj index 2b38b6d58..63fe1ac69 100644 --- a/test/coverlet.core.tests/coverlet.core.tests.csproj +++ b/test/coverlet.core.tests/coverlet.core.tests.csproj @@ -16,6 +16,8 @@ + + @@ -34,14 +36,6 @@ - - - - - - - - From 1ddc5ab8bb25e437ebd3207769023e88c4a035e5 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sun, 1 Nov 2026 04:04:44 +0100 Subject: [PATCH 20/21] =?UTF-8?q?=F0=9F=92=AC=20document=20ancestry-preser?= =?UTF-8?q?ving=20downstream=20synchronization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make downstream-only merge ancestry the maintenance policy while keeping Codebelt file integration selective. Record every incoming source decision, the unchanged runtime tree, current validation, and the merge policy needed to preserve the upstream parents. Expose the reviewed upstream boundary to developers without changing the package or release process. --- AGENTS.md | 11 ++++++++--- CHANGELOG.md | 1 + README.md | 2 ++ UPSTREAM.md | 45 +++++++++++++++++++++++++++++++++++++++------ 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 889ace99e..b0c6020c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This fork is intentionally narrower than `coverlet-coverage/coverlet`. Preserve ## Upstream sync workflow -When synchronizing from `coverlet-coverage/coverlet`, keep the sync selective: +Synchronization flows only from `coverlet-coverage/coverlet` into `codebeltnet/coverlet`. Never push branches, tags, or pull requests to upstream. Preserve upstream ancestry while keeping file-level integration selective: 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: @@ -12,18 +12,20 @@ When synchronizing from `coverlet-coverage/coverlet`, keep the sync selective: git fetch --no-tags https://github.com/coverlet-coverage/coverlet.git master:refs/remotes/coverlet-upstream/master ``` -2. Compare from the shared base: +2. Start an isolated synchronization branch from this fork's default branch after integrating any baseline PRs. Preserve existing work in its original worktree. Capture the fork base and exact upstream tip, then compare from the shared base: ```powershell git merge-base HEAD coverlet-upstream/master git log --oneline --left-right --cherry-pick HEAD...coverlet-upstream/master ``` + Review every incoming commit as adopted, adapted, already present, or excluded. Use a normal `git merge --no-ff --no-commit ` and resolve paths individually, including automatic merge results. Do not use an `ours` strategy, blanket `-X ours`, or whole-tree restoration. Keep excluded surfaces excluded even though their commits become ancestors. If the tip is already an ancestor, record a no-change review instead of manufacturing a merge. + 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;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. 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. +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, adopted, recorded in ancestry, tested, and released states; an upstream commit recorded as an ancestor does not mean every change in it was adopted. 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. @@ -33,4 +35,7 @@ When synchronizing from `coverlet-coverage/coverlet`, keep the sync selective: git tag -d v ``` +11. Preserve both parents of the upstream merge: the fork baseline first and the pinned upstream tip second. Integrate the synchronization PR into this fork using **Create a merge commit**, never squash or rebase. The fork permits merge commits and does not require linear history; retain its other PR, deletion, and force-push protections. Require the retained Linux/Windows/macOS CI matrix before integration, and verify the package identity, framework groups, MinVer calculation, and release guards. +12. After integration, fetch this fork's default branch without tags and verify `git merge-base --is-ancestor origin/master` succeeds and `git rev-list --left-right --count origin/master...` reports zero on the upstream side. Update the ledger with the actual destination commit and CI results. This is synchronization to a pinned snapshot, not authorization to release or publish a package. + Before running tests, ask for explicit approval. Build-only validation is acceptable when it does not execute tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 87969c336..5517cc865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This major release adds .NET 11 support, removes the dedicated .NET 9 target, an ### Changed - Target .NET 10 and .NET 11 alongside .NET Standard 2.0, with runtime dependency versions selected per target framework, +- Preserve reviewed upstream history through merge ancestry while retaining selective Codebelt file integration; the 2026-11-01 synchronization through `2e0a852` changes no production code, dependencies, or package layout, - 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, diff --git a/README.md b/README.md index 829f81448..11cc1328b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ 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. +Reviewed upstream history is retained through [`2e0a852`](https://github.com/coverlet-coverage/coverlet/commit/2e0a852d3a0d9669d9c19445e366c69ccaaf6424) (2026-10-27). The [synchronization record](UPSTREAM.md) explains the adopted fixes, intentional Codebelt differences, and validation. + ## Scope This repository produces one public NuGet package: `Codebelt.Coverlet.MTP`. It preserves the implementation assembly name and namespace used by the MTP extension (`coverlet.MTP.dll`, `Coverlet.MTP`, and `Coverlet.MTP.TestingPlatformBuilderHook`) so existing Microsoft Testing Platform discovery remains compatible. diff --git a/UPSTREAM.md b/UPSTREAM.md index 26d386320..bf49c182f 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -2,7 +2,7 @@ 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. +The compatibility goal is to preserve upstream behavior within the retained surface. Meaningful behavior differences are listed below. Synchronization preserves upstream ancestry while reviewing file changes selectively; recording a commit as an ancestor does not adopt every change in it. 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 @@ -10,10 +10,11 @@ The compatibility goal is to preserve upstream behavior within the retained surf | --- | --- | | 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. | +| Latest sync operation | 2026-11-01; normal upstream merge preserving the existing Codebelt tree | +| Codebelt baseline | [`6e72c0c11523bf3d6baf3ca25c2a457ce547674e`](https://github.com/codebeltnet/coverlet/commit/6e72c0c11523bf3d6baf3ca25c2a457ce547674e), integrated by [PR #6](https://github.com/codebeltnet/coverlet/pull/6) on 2026-11-01 | +| Upstream ancestry merge | [`d587f8ca285f0bf147fb933e14ed48dfff8a7490`](https://github.com/codebeltnet/coverlet/commit/d587f8ca285f0bf147fb933e14ed48dfff8a7490); first parent is the Codebelt baseline, second parent is the reviewed upstream tip | +| Integration state at verification | Ancestry merge committed on the synchronization branch; destination integration and new CI pending. The 11.0.0 release entry remains a draft. | +| Validation | The ancestry merge has the exact baseline tree; upstream-side behind count is zero on the sync branch. The baseline's [CI run 41232211145](https://github.com/codebeltnet/coverlet/actions/runs/41232211145) passed all retained test/package jobs. Current-operation validation is recorded below. | ## Original marker and release tags @@ -26,7 +27,7 @@ Tag targets below were checked against this repository's remote on 2026-10-31. T | [`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. +Upstream history continued after the original marker before the first Codebelt release. Before the 2026-11-01 ancestry merge, the shared comparison base was [`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 earlier comparisons. The first Codebelt release then narrowed that inherited tree. The chronology does not establish separate sync-operation dates for those 53 commits. The ancestry merge advances the shared base to the exact reviewed tip without changing these historical boundaries or release tags. ## Intentional differences from upstream @@ -47,6 +48,38 @@ The `netstandard2.0` embedded tracker in `src/coverlet.template` is retained fro ## Synchronization history +### 2026-11-01: retain reviewed upstream ancestry without runtime changes + +Operation date: 2026-11-01. Source: `coverlet-coverage/coverlet`, `master`, pinned at [`2e0a852d3a0d9669d9c19445e366c69ccaaf6424`](https://github.com/coverlet-coverage/coverlet/commit/2e0a852d3a0d9669d9c19445e366c69ccaaf6424). Fetching without tags confirmed that this is still the previously reviewed upstream tip. The starting fork baseline is [`6e72c0c11523bf3d6baf3ca25c2a457ce547674e`](https://github.com/codebeltnet/coverlet/commit/6e72c0c11523bf3d6baf3ca25c2a457ce547674e), after integrating the green [PR #6](https://github.com/codebeltnet/coverlet/pull/6); its tree was verified identical to the tested head `0b04b05d6d996ac945869d0f9e5e96224246d309`. The pre-merge comparison base is `da5c649126c74985fc20c496bc898f143d18e21b`. + +Normal merge [`d587f8ca285f0bf147fb933e14ed48dfff8a7490`](https://github.com/codebeltnet/coverlet/commit/d587f8ca285f0bf147fb933e14ed48dfff8a7490) has the fork baseline as its first parent and the pinned upstream tip as its second parent. It records all 19 incoming commits as ancestors. Its complete tree is identical to its first parent's tree: applicable changes were already selectively ported, and this operation introduces no production, test, dependency, build, or package-layout changes. The following decisions cover every source commit, including the deliberately excluded changes now recorded in ancestry. + +| Upstream source | Disposition in this merge | +| --- | --- | +| [`23578d4`, #2030](https://github.com/coverlet-coverage/coverlet/commit/23578d4), [`d212822`, #2032](https://github.com/coverlet-coverage/coverlet/commit/d212822), [`c6d3894`, #2033](https://github.com/coverlet-coverage/coverlet/commit/c6d3894) | Already present: MTP configuration selection, shared-framework fallback, and async exclusion regression coverage, with Codebelt resolver and test-host adaptations retained. | +| [`10a8051`, #2034](https://github.com/coverlet-coverage/coverlet/commit/10a8051), [`be3d696`, #2035](https://github.com/coverlet-coverage/coverlet/commit/be3d696) | Exclude upstream release/versioning files; retain MinVer, Codebelt release guards, root changelog, and focused documentation. | +| [`8b7654e`](https://github.com/coverlet-coverage/coverlet/commit/8b7654e) | Exclude upstream issue-template edits, as in the earlier selective review. | +| [`61e3350`, #2038](https://github.com/coverlet-coverage/coverlet/commit/61e3350) | Already present: central config-file and OpenCover tests. Retain Codebelt package preparation and test TFMs; omit the additional BCL compatibility reference because these tests target .NET 10/11. | +| [`3142be5`, #2044](https://github.com/coverlet-coverage/coverlet/commit/3142be5) | Already present: Moq 4.21.0. | +| [`b330a1f`, #2043](https://github.com/coverlet-coverage/coverlet/commit/b330a1f), [`45e98f9`, #2039](https://github.com/coverlet-coverage/coverlet/commit/45e98f9) | Exclude SBOM and upstream CodeQL tooling outside the retained fork surface. | +| [`ecd6fd5`, #2046](https://github.com/coverlet-coverage/coverlet/commit/ecd6fd5) | Already present: local-function instrumentation fix and regression test. | +| [`221483a`, #2049](https://github.com/coverlet-coverage/coverlet/commit/221483a) | Exclude documentation for the removed console/global-tool driver. | +| [`710f301`, #2050](https://github.com/coverlet-coverage/coverlet/commit/710f301) | Applicable updates already adapted; preserve per-TFM runtime pins and .NET Standard overrides. Exclude removed drivers, benchmarks, and pipeline dependencies. | +| [`a924cc3`](https://github.com/coverlet-coverage/coverlet/commit/a924cc3) | Already present: validation-project PKCS reference; remove the duplicate produced by automatic merging. | +| [`d9e4357`, #2051](https://github.com/coverlet-coverage/coverlet/commit/d9e4357) | Already present: threshold/report diagnostics and resolver tests. | +| [`b5ba919`, #2055](https://github.com/coverlet-coverage/coverlet/commit/b5ba919) | Exclude legacy configurations and target-specific test dependency overrides; retain the already-adapted .NET Standard NuGet.Versioning 6.14.3 override and current test dependencies. | +| [`39f6b6f`, #2056](https://github.com/coverlet-coverage/coverlet/commit/39f6b6f) | Already present: branch reachability, serialization compatibility, reporters, and regression coverage. | +| [`870b835`, #2057](https://github.com/coverlet-coverage/coverlet/commit/870b835) | Exclude upstream changelog/release-plan files; retain the earlier translated release outcomes in the root changelog. | +| [`2e0a852`, #2059](https://github.com/coverlet-coverage/coverlet/commit/2e0a852) | Applicable .NET 10 dependency updates already present. Preserve the fork's .NET 11 SDK/runtime pins, .NET Standard overrides, and target layout; exclude legacy build/runtime files. | + +Resolution review covered all 19 conflicted paths and all automatic changes. Removed legacy projects, pipelines, documentation, CodeQL workflow, and `version.json` remain absent. Automatic merging also duplicated the already-ported async regression method and introduced duplicate/legacy dependency references; these were resolved individually to the existing baseline. All intentional behavior differences in the table above remain intact, as does the embedded tracker. + +The maintainer authorized completing this downstream-only workflow after the merge-policy and validation prerequisites were explained on 2026-11-01. All writes are confined to `codebeltnet/coverlet`. The fork's merge policy is to allow merge commits and remove the incompatible linear-history requirement while retaining the other PR, deletion, and force-push protections. The synchronization PR must use a merge commit; squash/rebase would lose the new upstream ancestry. Existing release tags are preserved; no release or package publication is requested. + +Local validation on Windows used the repository SDK `11.0.100-rc.3.26522.148`. `dotnet build coverlet.slnx -c Release -p:PrepareValidationPackage=false` passed with zero warnings/errors, and `dotnet pack src/coverlet.MTP/coverlet.MTP.csproj -c Release --no-build` succeeded. The resulting `Codebelt.Coverlet.MTP.10.1.2-alpha.0.3.nupkg` has the retained assembly/discovery identity, three TFM/dependency groups, .NET 10/11 runtime pins, and .NET Standard compatibility overrides; no removed driver assets returned. MinVer uses the existing `v10.1.1` boundary and produces a prerelease version, not its `0.0.0-alpha` fallback. All local tag names and targets were compared with the pre-operation snapshot and are unchanged. + +Approved local test commands use `dotnet test test//.csproj -c Release -f --no-build -p:PrepareValidationPackage=false -- --report-trx --report-trx-filename ..trx`. Core tests passed on each framework (369 passed, 9 skipped); coverage tests passed on each (98 passed, none skipped); MTP tests passed on each (433 passed, 7 skipped). Package-validation tests and the fork's Linux/Windows/macOS PR CI are still in progress or pending; their results and the destination integration will be recorded before completion. .NET Standard 2.0 was built and packed, not executed as a test target. No release or package publication was performed. + ### 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. From a15ddad62c5ab64cbe47b87c2359e95dcd390d6f Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sun, 1 Nov 2026 04:15:50 +0100 Subject: [PATCH 21/21] =?UTF-8?q?=F0=9F=92=AC=20record=20downstream=20sync?= =?UTF-8?q?=20validation=20and=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the successful local and cross-platform validation, preserve the initial shared-directory test failure and isolated rerun evidence, and link the downstream integration PR. Clarify how a ledger committed within its own PR records the destination without inventing a future commit hash. --- AGENTS.md | 2 +- UPSTREAM.md | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b0c6020c0..91893cd0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,6 @@ Synchronization flows only from `coverlet-coverage/coverlet` into `codebeltnet/c ``` 11. Preserve both parents of the upstream merge: the fork baseline first and the pinned upstream tip second. Integrate the synchronization PR into this fork using **Create a merge commit**, never squash or rebase. The fork permits merge commits and does not require linear history; retain its other PR, deletion, and force-push protections. Require the retained Linux/Windows/macOS CI matrix before integration, and verify the package identity, framework groups, MinVer calculation, and release guards. -12. After integration, fetch this fork's default branch without tags and verify `git merge-base --is-ancestor origin/master` succeeds and `git rev-list --left-right --count origin/master...` reports zero on the upstream side. Update the ledger with the actual destination commit and CI results. This is synchronization to a pinned snapshot, not authorization to release or publish a package. +12. After integration, fetch this fork's default branch without tags and verify `git merge-base --is-ancestor origin/master` succeeds and `git rev-list --left-right --count origin/master...` reports zero on the upstream side. Record the destination commit or merged PR and CI results in the ledger; the PR provides the authoritative merge state and destination commit when the ledger is part of that PR. This is synchronization to a pinned snapshot, not authorization to release or publish a package. Before running tests, ask for explicit approval. Build-only validation is acceptable when it does not execute tests. diff --git a/UPSTREAM.md b/UPSTREAM.md index bf49c182f..7adeffee9 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -13,8 +13,8 @@ The compatibility goal is to preserve upstream behavior within the retained surf | Latest sync operation | 2026-11-01; normal upstream merge preserving the existing Codebelt tree | | Codebelt baseline | [`6e72c0c11523bf3d6baf3ca25c2a457ce547674e`](https://github.com/codebeltnet/coverlet/commit/6e72c0c11523bf3d6baf3ca25c2a457ce547674e), integrated by [PR #6](https://github.com/codebeltnet/coverlet/pull/6) on 2026-11-01 | | Upstream ancestry merge | [`d587f8ca285f0bf147fb933e14ed48dfff8a7490`](https://github.com/codebeltnet/coverlet/commit/d587f8ca285f0bf147fb933e14ed48dfff8a7490); first parent is the Codebelt baseline, second parent is the reviewed upstream tip | -| Integration state at verification | Ancestry merge committed on the synchronization branch; destination integration and new CI pending. The 11.0.0 release entry remains a draft. | -| Validation | The ancestry merge has the exact baseline tree; upstream-side behind count is zero on the sync branch. The baseline's [CI run 41232211145](https://github.com/codebeltnet/coverlet/actions/runs/41232211145) passed all retained test/package jobs. Current-operation validation is recorded below. | +| Destination integration | [PR #7](https://github.com/codebeltnet/coverlet/pull/7) into `codebeltnet/coverlet:master` using a merge commit; its merge record supplies the authoritative destination commit and integration state. The 11.0.0 release entry remains a draft. | +| Validation | [CI run 41267824916](https://github.com/codebeltnet/coverlet/actions/runs/41267824916) at `1ddc5ab8bb25e437ebd3207769023e88c4a035e5` passed build, release-version guard, all nine test jobs, packing, and all six package-validation jobs across .NET 10/11 and Linux, Windows, and macOS. Publication was skipped. The final documentation follow-up is also gated by the checks on PR #7. | ## Original marker and release tags @@ -59,12 +59,12 @@ Normal merge [`d587f8ca285f0bf147fb933e14ed48dfff8a7490`](https://github.com/cod | [`23578d4`, #2030](https://github.com/coverlet-coverage/coverlet/commit/23578d4), [`d212822`, #2032](https://github.com/coverlet-coverage/coverlet/commit/d212822), [`c6d3894`, #2033](https://github.com/coverlet-coverage/coverlet/commit/c6d3894) | Already present: MTP configuration selection, shared-framework fallback, and async exclusion regression coverage, with Codebelt resolver and test-host adaptations retained. | | [`10a8051`, #2034](https://github.com/coverlet-coverage/coverlet/commit/10a8051), [`be3d696`, #2035](https://github.com/coverlet-coverage/coverlet/commit/be3d696) | Exclude upstream release/versioning files; retain MinVer, Codebelt release guards, root changelog, and focused documentation. | | [`8b7654e`](https://github.com/coverlet-coverage/coverlet/commit/8b7654e) | Exclude upstream issue-template edits, as in the earlier selective review. | -| [`61e3350`, #2038](https://github.com/coverlet-coverage/coverlet/commit/61e3350) | Already present: central config-file and OpenCover tests. Retain Codebelt package preparation and test TFMs; omit the additional BCL compatibility reference because these tests target .NET 10/11. | +| [`61e3350`, #2038](https://github.com/coverlet-coverage/coverlet/commit/61e3350) | Already present: central config-file and OpenCover tests. Retain Codebelt package preparation and test TFMs. | | [`3142be5`, #2044](https://github.com/coverlet-coverage/coverlet/commit/3142be5) | Already present: Moq 4.21.0. | | [`b330a1f`, #2043](https://github.com/coverlet-coverage/coverlet/commit/b330a1f), [`45e98f9`, #2039](https://github.com/coverlet-coverage/coverlet/commit/45e98f9) | Exclude SBOM and upstream CodeQL tooling outside the retained fork surface. | | [`ecd6fd5`, #2046](https://github.com/coverlet-coverage/coverlet/commit/ecd6fd5) | Already present: local-function instrumentation fix and regression test. | | [`221483a`, #2049](https://github.com/coverlet-coverage/coverlet/commit/221483a) | Exclude documentation for the removed console/global-tool driver. | -| [`710f301`, #2050](https://github.com/coverlet-coverage/coverlet/commit/710f301) | Applicable updates already adapted; preserve per-TFM runtime pins and .NET Standard overrides. Exclude removed drivers, benchmarks, and pipeline dependencies. | +| [`710f301`, #2050](https://github.com/coverlet-coverage/coverlet/commit/710f301) | Applicable updates already adapted; preserve per-TFM runtime pins and .NET Standard overrides. Exclude removed drivers, benchmarks, and pipeline dependencies; omit the additional BCL compatibility reference in validation tests because these target .NET 10/11. | | [`a924cc3`](https://github.com/coverlet-coverage/coverlet/commit/a924cc3) | Already present: validation-project PKCS reference; remove the duplicate produced by automatic merging. | | [`d9e4357`, #2051](https://github.com/coverlet-coverage/coverlet/commit/d9e4357) | Already present: threshold/report diagnostics and resolver tests. | | [`b5ba919`, #2055](https://github.com/coverlet-coverage/coverlet/commit/b5ba919) | Exclude legacy configurations and target-specific test dependency overrides; retain the already-adapted .NET Standard NuGet.Versioning 6.14.3 override and current test dependencies. | @@ -78,7 +78,18 @@ The maintainer authorized completing this downstream-only workflow after the mer Local validation on Windows used the repository SDK `11.0.100-rc.3.26522.148`. `dotnet build coverlet.slnx -c Release -p:PrepareValidationPackage=false` passed with zero warnings/errors, and `dotnet pack src/coverlet.MTP/coverlet.MTP.csproj -c Release --no-build` succeeded. The resulting `Codebelt.Coverlet.MTP.10.1.2-alpha.0.3.nupkg` has the retained assembly/discovery identity, three TFM/dependency groups, .NET 10/11 runtime pins, and .NET Standard compatibility overrides; no removed driver assets returned. MinVer uses the existing `v10.1.1` boundary and produces a prerelease version, not its `0.0.0-alpha` fallback. All local tag names and targets were compared with the pre-operation snapshot and are unchanged. -Approved local test commands use `dotnet test test//.csproj -c Release -f --no-build -p:PrepareValidationPackage=false -- --report-trx --report-trx-filename ..trx`. Core tests passed on each framework (369 passed, 9 skipped); coverage tests passed on each (98 passed, none skipped); MTP tests passed on each (433 passed, 7 skipped). Package-validation tests and the fork's Linux/Windows/macOS PR CI are still in progress or pending; their results and the destination integration will be recorded before completion. .NET Standard 2.0 was built and packed, not executed as a test target. No release or package publication was performed. +Approved local test commands use `dotnet test test//.csproj -c Release -f --no-build -p:PrepareValidationPackage=false -- --report-trx --report-trx-filename ..trx`. + +| Project | .NET 10 | .NET 11 | +| --- | --- | --- | +| `coverlet.core.tests` | 369 passed, 9 skipped | 369 passed, 9 skipped | +| `coverlet.core.coverage.tests` | 98 passed, none skipped | 98 passed, none skipped | +| `coverlet.MTP.tests` | 433 passed, 7 skipped | 433 passed, 7 skipped | +| `coverlet.MTP.validation.tests` | 45 passed, none skipped | 45 passed, none skipped on isolated full rerun | + +The initial concurrent .NET 10/11 package-validation runs had one .NET 11 restore failure in `ConfigurationFile_IncludeFilters_CanBeConfigured` (44 passed, 1 failed). The unchanged [test harness](test/coverlet.MTP.validation.tests/TestInfrastructure/MtpValidationTestBase.cs) recreates solution directories under a configuration/test name shared by both frameworks; one run can delete files used by the other. After the .NET 10 run completed, a full .NET 11 rerun passed all 45 tests without any code, dependency, or assertion changes. Run package-validation framework legs sequentially within a worktree, as CI already does by using separate matrix jobs. .NET Standard 2.0 was built and packed, not executed as a test target. No release or package publication was performed. + +Cross-platform validation: [CI run 41267824916](https://github.com/codebeltnet/coverlet/actions/runs/41267824916), source `1ddc5ab8bb25e437ebd3207769023e88c4a035e5`, completed successfully on 2026-11-01. All 18 validation/build/package jobs succeeded; `publish-nuget` was skipped for the PR. This verifies the retained tests and packaged consumer behavior on .NET 10/11 across Linux, Windows, and macOS. A documentation-only follow-up records these outcomes and remains subject to the same PR checks. Integration is through [PR #7](https://github.com/codebeltnet/coverlet/pull/7) into this fork's `master` using a merge commit; that PR records the actual destination commit and merged state. The reviewed tip is an ancestor of the PR branch, and GitHub's comparison reports zero commits behind upstream. Destination ancestry is checked again after integration. No production behavior, dependency, test assertion, or release tag was changed by this synchronization. ### 2026-10-30: selective core and MTP update