diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8eee83066 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# Repository guidance + +This fork is intentionally narrower than `coverlet-coverage/coverlet`. Preserve the Codebelt package surface unless a task explicitly says otherwise. + +## Upstream sync workflow + +When synchronizing from `coverlet-coverage/coverlet`, keep the sync selective: + +1. Fetch upstream without tags to avoid importing upstream release tags into this fork: + + ```powershell + git fetch --no-tags https://github.com/coverlet-coverage/coverlet.git master:refs/remotes/coverlet-upstream/master + ``` + +2. Compare from the shared base: + + ```powershell + git merge-base HEAD coverlet-upstream/master + git log --oneline --left-right --cherry-pick HEAD...coverlet-upstream/master + ``` + +3. Port only changes that apply to the retained package surface: `src\coverlet.core`, `src\coverlet.MTP`, and the directly related tests. +4. Do not reintroduce upstream surfaces removed by this fork, including legacy console, collector, msbuild packages, legacy Azure Pipelines, broad upstream documentation, or `version.json`. +5. Preserve `Codebelt.Coverlet.MTP` package identity, `Coverlet.MTP` assembly identity, and the multi-targeted `netstandard2.0;net9.0;net10.0` layout. +6. Keep versioning on MinVer `v*` tags and the existing release workflow guards. Do not restore upstream Nerdbank.GitVersioning files. +7. Adapt release notes to this fork's root `CHANGELOG.md`; describe only changes that were actually ported. +8. If an upstream tag was accidentally fetched and points at an upstream commit, delete that local tag before release preparation: + + ```powershell + git tag -d v + ``` + +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 e4ae87a34..1982c2289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [10.1.0] - 2026-09-27 + +This release synchronizes the Codebelt.Coverlet.MTP fork with upstream coverlet 10.1.0 fixes that apply to the narrowed MTP/core package surface while preserving the fork's multi-targeted package layout. + +### Fixed + +- Honor the Microsoft Testing Platform `--config-file` argument in coverlet.MTP instead of falling back directly to legacy `coverlet.mtp.appsettings.json` discovery. +- Resolve shared-framework assemblies that are missing from `compileLibraries` by falling back to `NetCoreSharedFrameworkResolver`, preventing silently empty coverage for modules affected by newer SDK dependency context behavior. +- Add regression coverage for `ExcludeByAttribute=CompilerGeneratedAttribute` so async state-machine methods remain instrumented. + ## [10.0.1] - 2026-09-20 This is a minor release focused on the Codebelt.Coverlet.MTP fork with narrowed scope, enhanced coverage analysis, improved testing infrastructure, and build reliability improvements. @@ -74,4 +84,5 @@ This is a minor release focused on the Codebelt.Coverlet.MTP fork with narrowed - .vscode/settings.json configuration, - .devcontainer legacy configuration. +[10.1.0]: https://github.com/codebeltnet/coverlet/compare/v10.0.1...v10.1.0 [10.0.1]: https://github.com/codebeltnet/coverlet/compare/v10.0.0...v10.0.1 diff --git a/coverlet.slnx b/coverlet.slnx index b4c224b34..efdf89746 100644 --- a/coverlet.slnx +++ b/coverlet.slnx @@ -4,6 +4,7 @@ + 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. 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.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs index 525962bbc..e0c450fd5 100644 --- a/test/coverlet.core.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs +++ b/test/coverlet.core.coverage.tests/Coverage/CoverageTests.AsyncAwait.cs @@ -865,5 +865,49 @@ public void AsyncAwait_Issue1337_TryCatchFinallyWithRealAsyncInFinally_NoPhantom File.Delete(path); } } + + [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); + } + } } } diff --git a/test/coverlet.core.coverage.tests/Coverage/InstrumenterHelper.cs b/test/coverlet.core.coverage.tests/Coverage/InstrumenterHelper.cs index 222f9c1d9..7bdf377f7 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, diff --git a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs index 711090b0a..c94a31a24 100644 --- a/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs +++ b/test/coverlet.core.tests/Instrumentation/InstrumenterTests.cs @@ -809,6 +809,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() {