From 4a202575ddaae0e3c8e6f72fe46174a91f5ebae9 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Mon, 8 Jun 2026 16:13:14 -0700 Subject: [PATCH 1/4] Look for analyzers in the new analyzer section assets file --- .../ResolvePackageAssets.cs | 146 ++++ ...rosoft.PackageDependencyResolution.targets | 12 + .../GivenAResolvePackageAssetsTask.cs | 675 ++++++++++++++++++ 3 files changed, 833 insertions(+) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs index 84324d55e852..b922c04e65a3 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs @@ -134,6 +134,11 @@ public sealed class ResolvePackageAssets : TaskBase, IMultiThreadableTask /// public string CompilerApiVersion { get; set; } + /// + /// Consume analyzer assets from project.assets.json instead of scanning package files. + /// + public bool RestoreEnableAnalyzerAssets { get; set; } + /// /// Check that there is at least one package dependency in the RID graph that is not in the RID-agnostic graph. /// Used as a heuristic to detect invalid RIDs. @@ -487,6 +492,7 @@ internal byte[] HashSettings() } writer.Write(ProjectLanguage ?? ""); writer.Write(CompilerApiVersion ?? ""); + writer.Write(RestoreEnableAnalyzerAssets); writer.Write(ProjectPath); // we want to ensure uniqueness of results, so even though `any` is No RID for purposes of Task logic, // we continue to treat it distinctly for hashing @@ -927,6 +933,146 @@ public int GetHashCode((string, NuGetVersion) library) } private void WriteAnalyzers() + { + // Honor the feature-flag decision that restore actually made (and persisted into the assets file), + // not the raw MSBuild property. The restore-time value already reflects the TFM gate from + // NuGet.targets, which is not imported during build; trusting the raw property here would run the + // metadata path for a project whose assets file has no analyzer group, silently dropping analyzers. + if (_lockFile.PackageSpec?.RestoreMetadata?.RestoreEnableAnalyzerAssets == true) + { + WriteAnalyzerAssets(); + return; + } + + WriteAnalyzerPackageFiles(); + } + + private void WriteAnalyzerAssets() + { + // The "analyzers" group lists every analyzer assembly in the package (all languages and compiler + // versions), with Include/Exclude/PrivateAssets already applied by restore. Each asset carries + // "codeLanguage" and (when applicable) "compilerApiVersion" metadata, mirroring content files, so + // the SDK selects the applicable analyzers directly from that metadata rather than parsing paths. + string projectCodeLanguage = NuGetUtils.GetLockFileLanguageName(_task.ProjectLanguage); + bool hasProjectCompilerVersion = TryGetCompilerVersion(_task.CompilerApiVersion, out Version projectCompilerVersion); + + foreach (LockFileTargetLibrary library in _compileTimeTarget.Libraries) + { + if (!library.IsPackage() || library.AnalyzerAssets.Count == 0) + { + continue; + } + + Version maxApplicableVersion = null; + List<(string Path, Version Version)> compilerVersionSpecificAssets = null; + + foreach (LockFileItem asset in library.AnalyzerAssets) + { + if (asset.IsPlaceholderFile()) + { + continue; + } + + if (!IsApplicableAnalyzerLanguage(asset, projectCodeLanguage)) + { + _task.Log.LogMessage(MessageImportance.Low, $"Excluding analyzer '{asset.Path}' from package '{library.Name}' because its code language does not apply to the project language '{projectCodeLanguage}'."); + continue; + } + + if (hasProjectCompilerVersion + && asset.Properties.TryGetValue(LockFileItem.CompilerApiVersionProperty, out string assetCompilerApiVersion) + && TryGetCompilerVersion(assetCompilerApiVersion, out Version assetCompilerVersion)) + { + if (assetCompilerVersion > projectCompilerVersion) + { + // The analyzer targets a newer compiler than the current one; skip it. + _task.Log.LogMessage(MessageImportance.Low, $"Excluding analyzer '{asset.Path}' from package '{library.Name}' because its compiler version '{assetCompilerApiVersion}' is newer than the project compiler version '{_task.CompilerApiVersion}'."); + continue; + } + + compilerVersionSpecificAssets ??= new List<(string, Version)>(); + compilerVersionSpecificAssets.Add((asset.Path, assetCompilerVersion)); + + if (maxApplicableVersion == null || assetCompilerVersion > maxApplicableVersion) + { + maxApplicableVersion = assetCompilerVersion; + } + } + else + { + // Either the analyzer is compiler-version-agnostic, or the project compiler version is + // unknown (in which case every analyzer variant is treated as version-agnostic). Always apply. + _task.Log.LogMessage(MessageImportance.Low, $"Including analyzer '{asset.Path}' from package '{library.Name}'."); + WriteItem(_packageResolver.ResolvePackageAssetPath(library, asset.Path), library); + } + } + + if (compilerVersionSpecificAssets != null) + { + // Among the compiler-version-specific analyzers, only the highest applicable version is used. + foreach ((string path, Version version) in compilerVersionSpecificAssets) + { + if (version.Equals(maxApplicableVersion)) + { + _task.Log.LogMessage(MessageImportance.Low, $"Including analyzer '{path}' from package '{library.Name}' for compiler version '{maxApplicableVersion}'."); + WriteItem(_packageResolver.ResolvePackageAssetPath(library, path), library); + } + else + { + _task.Log.LogMessage(MessageImportance.Low, $"Excluding analyzer '{path}' from package '{library.Name}' because a higher applicable compiler version '{maxApplicableVersion}' is available."); + } + } + } + } + } + + private static bool IsApplicableAnalyzerLanguage(LockFileItem asset, string projectCodeLanguage) + { + // Language-agnostic analyzers (no language segment) apply to every project. Otherwise the analyzer + // applies only when its code language matches the project's language. + if (!asset.Properties.TryGetValue(LockFileContentFile.CodeLanguageProperty, out string codeLanguage) + || string.IsNullOrEmpty(codeLanguage) + || string.Equals(codeLanguage, "any", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return projectCodeLanguage != null + && string.Equals(codeLanguage, projectCodeLanguage, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetCompilerVersion(string compilerApiVersion, out Version version) + { + version = null; + + if (string.IsNullOrEmpty(compilerApiVersion)) + { + return false; + } + + int versionStart = -1; + for (int i = 0; i < compilerApiVersion.Length; i++) + { + if (char.IsDigit(compilerApiVersion[i])) + { + versionStart = i; + break; + } + } + + if (versionStart < 0) + { + return false; + } + +#if NET + return Version.TryParse(compilerApiVersion.AsSpan(versionStart), out version); +#else + return Version.TryParse(compilerApiVersion.Substring(versionStart), out version); +#endif + } + + private void WriteAnalyzerPackageFiles() { AnalyzerResolver resolver = new(this); diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets index a1b3e49023d4..074788b78e7a 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets @@ -56,6 +56,17 @@ Copyright (c) .NET Foundation. All rights reserved. true + + + false @@ -269,6 +280,7 @@ Copyright (c) .NET Foundation. All rights reserved. ProjectPath="$(MSBuildProjectFullPath)" ProjectLanguage="$(Language)" CompilerApiVersion="$(CompilerApiVersion)" + RestoreEnableAnalyzerAssets="$(RestoreEnableAnalyzerAssets)" EmitAssetsLogMessages="$(EmitAssetsLogMessages)" TargetFramework="$(TargetFramework)" RuntimeIdentifier="$(RuntimeIdentifier)" diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs index 2d12dd1aa893..f280ac354462 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs @@ -76,6 +76,120 @@ public void ItDoesNotHashDesignTimeBuild() because: $"{nameof(task.DesignTimeBuild)} should not be included in hash."); } + [TestMethod] + public void It_reads_analyzer_assets_from_lock_file_when_enabled() + { + ExecuteAnalyzerAssetsTest( + restoreEnableAnalyzerAssets: true, + includeAnalyzerAssetsGroup: true, + assert: (analyzers, expectedAnalyzerPath) => + { + analyzers.Should().Equal(expectedAnalyzerPath); + }); + } + + [TestMethod] + public void It_does_not_fall_back_to_package_file_scanning_when_analyzer_assets_are_enabled() + { + ExecuteAnalyzerAssetsTest( + restoreEnableAnalyzerAssets: true, + includeAnalyzerAssetsGroup: false, + assert: (analyzers, expectedAnalyzerPath) => + { + analyzers.Should().BeEmpty(); + }); + } + + [TestMethod] + public void It_preserves_package_file_analyzer_scanning_when_analyzer_assets_are_disabled() + { + ExecuteAnalyzerAssetsTest( + restoreEnableAnalyzerAssets: false, + includeAnalyzerAssetsGroup: false, + assert: (analyzers, expectedAnalyzerPath) => + { + analyzers.Should().Equal(expectedAnalyzerPath); + }); + } + + [TestMethod] + public void It_applies_language_selection_to_analyzer_assets_from_the_group() + { + // NuGet lists analyzers for every language in the group; the SDK selects the language-appropriate + // ones, so a C# project must not pick up the VB analyzer. + string[] selected = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: null, + analyzerPaths: new[] + { + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/vb/VisualBasicAnalyzer.dll", + }); + + selected.Should().Equal("analyzers/dotnet/cs/CSharpAnalyzer.dll"); + } + + [TestMethod] + public void It_applies_compiler_version_selection_to_analyzer_assets_from_the_group() + { + // The group lists every compiler-version variant; the SDK picks the highest version that is still + // applicable to the current compiler (roslyn3.9 -> roslyn3.8, never roslyn4.0). + string[] selected = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: "roslyn3.9", + analyzerPaths: new[] + { + "analyzers/dotnet/roslyn3.8/cs/OldAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/NewAnalyzer.dll", + }); + + selected.Should().Equal("analyzers/dotnet/roslyn3.8/cs/OldAnalyzer.dll"); + } + + [TestMethod] + public void It_ignores_analyzer_metadata_and_uses_legacy_scanning_when_feature_flag_is_disabled() + { + // An F# analyzer is excluded by the metadata-based selection for a C# project (its codeLanguage is + // "fs"), but the legacy path-based scan includes it (it is not a VB analyzer). This asserts that the + // metadata-based selection only applies when the feature flag is enabled. + string[] expected = new[] { "analyzers/dotnet/fs/FSharpAnalyzer.dll" }; + + string[] selectedWithFlag = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: null, + analyzerPaths: expected, + restoreEnableAnalyzerAssets: true); + + // Feature flag on: metadata-based selection excludes the F# analyzer for a C# project. + selectedWithFlag.Should().BeEmpty(); + + string[] selectedWithoutFlag = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: null, + analyzerPaths: expected, + restoreEnableAnalyzerAssets: false); + + // Feature flag off: legacy path scanning is used and the F# analyzer is included. + selectedWithoutFlag.Should().Equal(expected); + } + + [TestMethod] + public void It_honors_the_restore_decision_from_the_assets_file_over_the_msbuild_property() + { + // The TFM gate (.NET 11+) lives in NuGet.targets, which is not imported during build, so the raw + // MSBuild property can be true for a project that restore actually gated off. The SDK must honor the + // value restore persisted into the assets file (here: disabled, no analyzer group) and fall back to + // legacy package-file scanning instead of silently dropping all analyzers. + ExecuteAnalyzerAssetsTest( + restoreEnableAnalyzerAssets: false, // assets file: restore gated the feature off + includeAnalyzerAssetsGroup: false, + restoreEnableAnalyzerAssetsTaskProperty: true, // raw MSBuild property (ungated) is true + assert: (analyzers, expectedAnalyzerPath) => + { + analyzers.Should().Equal(expectedAnalyzerPath); + }); + } + [TestMethod] public void It_does_not_error_on_duplicate_package_names() { @@ -116,6 +230,567 @@ public void It_does_not_error_on_duplicate_package_names() new CacheWriter(task); // Should not error } + [TestMethod] + public void It_applies_language_agnostic_analyzer_assets_to_every_language() + { + // An analyzer with no language segment (codeLanguage "any") applies to projects of any language. + string[] paths = + { + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/vb/VisualBasicAnalyzer.dll", + }; + + ResolveGroupAnalyzers("C#", compilerApiVersion: null, analyzerPaths: paths) + .Should().Equal("analyzers/dotnet/NeutralAnalyzer.dll", "analyzers/dotnet/cs/CSharpAnalyzer.dll"); + + ResolveGroupAnalyzers("VB", compilerApiVersion: null, analyzerPaths: paths) + .Should().Equal("analyzers/dotnet/NeutralAnalyzer.dll", "analyzers/dotnet/vb/VisualBasicAnalyzer.dll"); + } + + [TestMethod] + public void It_applies_language_selection_for_fsharp_projects() + { + // F# projects select fs and language-agnostic analyzers, but not cs/vb analyzers. + string[] selected = ResolveGroupAnalyzers( + projectLanguage: "F#", + compilerApiVersion: null, + analyzerPaths: new[] + { + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/fs/FSharpAnalyzer.dll", + }); + + selected.Should().Equal( + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/fs/FSharpAnalyzer.dll"); + } + + [TestMethod] + public void It_applies_every_compiler_version_variant_when_the_project_compiler_version_is_unknown() + { + // F# projects (and others) have no resolved compiler API version. With an unknown project compiler + // version, every compiler-version variant is treated as version-agnostic and applied. + string[] selected = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: null, + analyzerPaths: new[] + { + "analyzers/dotnet/roslyn3.8/cs/OldAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/NewAnalyzer.dll", + }); + + selected.Should().Equal( + "analyzers/dotnet/roslyn3.8/cs/OldAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/NewAnalyzer.dll"); + } + + [TestMethod] + public void It_applies_all_analyzers_sharing_the_highest_applicable_compiler_version() + { + // When several analyzers share the highest applicable compiler version, all of them are applied. + string[] selected = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: "roslyn3.9", + analyzerPaths: new[] + { + "analyzers/dotnet/roslyn3.8/cs/FirstAnalyzer.dll", + "analyzers/dotnet/roslyn3.8/cs/SecondAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/TooNewAnalyzer.dll", + }); + + selected.Should().Equal( + "analyzers/dotnet/roslyn3.8/cs/FirstAnalyzer.dll", + "analyzers/dotnet/roslyn3.8/cs/SecondAnalyzer.dll"); + } + + [TestMethod] + public void It_ignores_placeholder_analyzer_assets_in_the_group() + { + // A '_._' placeholder (an analyzer excluded by restore, e.g. via PrivateAssets) is ignored and never + // surfaced as an analyzer. + string[] selected = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: null, + analyzerPaths: new[] + { + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/cs/_._", + }); + + selected.Should().Equal("analyzers/dotnet/cs/CSharpAnalyzer.dll"); + } + + [TestMethod] + public void It_reads_analyzer_assets_for_the_current_target_framework_in_a_multi_targeted_project() + { + // In a multi-targeted project the analyzers group is written per target framework. The task must read + // the group for the target framework it is building (here net9.0), not another target's group. + string testRoot = Path.Combine(Path.GetTempPath(), "rpa-analyzers-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testRoot); + + try + { + string objDir = Path.Combine(testRoot, "obj"); + string packagesDir = Path.Combine(testRoot, "packages"); + Directory.CreateDirectory(objDir); + + string packageDirectory = Path.Combine(packagesDir, AnalyzerPackageName.ToLowerInvariant(), AnalyzerPackageVersion); + foreach (string relativePath in new[] { "analyzers/dotnet/cs/Net8Analyzer.dll", "analyzers/dotnet/cs/Net9Analyzer.dll" }) + { + string fullPath = Path.Combine(packageDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); + File.WriteAllText(fullPath, string.Empty); + } + + File.WriteAllText( + Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.{AnalyzerPackageVersion}.nupkg.sha512"), + "abc123"); + File.WriteAllText( + Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.nuspec"), + $"{AnalyzerPackageName}{AnalyzerPackageVersion}"); + + string projectPath = Path.Combine(testRoot, "test.csproj"); + string projectAssetsJsonPath = Path.Combine(objDir, "project.assets.json"); + File.WriteAllText( + projectAssetsJsonPath, + CreateMultiTargetedAnalyzerAssetsJson(projectPath, packagesDir, objDir)); + + var task = new ResolvePackageAssets + { + BuildEngine = new MockBuildEngine(), + ProjectAssetsCacheFile = Path.Combine(objDir, "project.assets.cache"), + ProjectAssetsFile = projectAssetsJsonPath, + ProjectPath = projectPath, + TargetFramework = "net9.0", + ProjectLanguage = "C#", + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DefaultImplicitPackages = "Microsoft.NETCore.App", + DisablePackageAssetsCache = true, + RestoreEnableAnalyzerAssets = true, + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(testRoot) + }; + + task.Execute().Should().BeTrue(); + + string packagePrefix = packageDirectory + Path.DirectorySeparatorChar; + task.Analyzers + .Select(a => a.ItemSpec.StartsWith(packagePrefix, StringComparison.OrdinalIgnoreCase) + ? a.ItemSpec.Substring(packagePrefix.Length).Replace(Path.DirectorySeparatorChar, '/') + : a.ItemSpec) + .Should().Equal("analyzers/dotnet/cs/Net9Analyzer.dll"); + } + finally + { + try { Directory.Delete(testRoot, true); } catch { } + } + } + + private static string CreateMultiTargetedAnalyzerAssetsJson(string projectPath, string packagesPath, string outputPath) + { + return $$""" + { + "version": 5, + "targets": { + "net8.0": { + "{{AnalyzerPackageName}}/{{AnalyzerPackageVersion}}": { + "type": "package", + "analyzers": { + "analyzers/dotnet/cs/Net8Analyzer.dll": { "codeLanguage": "cs" } + } + } + }, + "net9.0": { + "{{AnalyzerPackageName}}/{{AnalyzerPackageVersion}}": { + "type": "package", + "analyzers": { + "analyzers/dotnet/cs/Net9Analyzer.dll": { "codeLanguage": "cs" } + } + } + } + }, + "libraries": { + "{{AnalyzerPackageName}}/{{AnalyzerPackageVersion}}": { + "sha512": "abc123", + "type": "package", + "path": "{{AnalyzerPackageName.ToLowerInvariant()}}/{{AnalyzerPackageVersion}}", + "files": [ + "analyzers/dotnet/cs/Net8Analyzer.dll", + "analyzers/dotnet/cs/Net9Analyzer.dll", + "{{AnalyzerPackageName.ToLowerInvariant()}}.{{AnalyzerPackageVersion}}.nupkg.sha512", + "{{AnalyzerPackageName.ToLowerInvariant()}}.nuspec" + ] + } + }, + "projectFileDependencyGroups": { + "net8.0": ["{{AnalyzerPackageName}} >= {{AnalyzerPackageVersion}}"], + "net9.0": ["{{AnalyzerPackageName}} >= {{AnalyzerPackageVersion}}"] + }, + "packageFolders": { "{{JsonEscape(packagesPath)}}": {} }, + "project": { + "version": "1.0.0", + "restore": { + "restoreEnableAnalyzerAssets": true, + "projectUniqueName": "test", + "projectName": "test", + "projectPath": "{{JsonEscape(projectPath)}}", + "packagesPath": "{{JsonEscape(packagesPath)}}", + "outputPath": "{{JsonEscape(outputPath)}}", + "projectStyle": "PackageReference", + "frameworks": { + "net8.0": { "targetAlias": "net8.0" }, + "net9.0": { "targetAlias": "net9.0" } + } + }, + "frameworks": { + "net8.0": { "targetAlias": "net8.0" }, + "net9.0": { "targetAlias": "net9.0" } + } + } + } + """; + } + + private const string AnalyzerPackageName = "Analyzer.Package"; + private const string AnalyzerPackageVersion = "1.0.0"; + private const string AnalyzerAssetPath = "analyzers/dotnet/cs/Analyzer.Package.dll"; + + private static void ExecuteAnalyzerAssetsTest( + bool restoreEnableAnalyzerAssets, + bool includeAnalyzerAssetsGroup, + Action assert, + bool? restoreEnableAnalyzerAssetsTaskProperty = null) + { + string testRoot = Path.Combine(Path.GetTempPath(), "rpa-analyzers-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testRoot); + + try + { + string objDir = Path.Combine(testRoot, "obj"); + string packagesDir = Path.Combine(testRoot, "packages"); + Directory.CreateDirectory(objDir); + CreateAnalyzerPackage(packagesDir); + + string projectPath = Path.Combine(testRoot, "test.csproj"); + string projectAssetsJsonPath = Path.Combine(objDir, "project.assets.json"); + File.WriteAllText( + projectAssetsJsonPath, + CreateAnalyzerAssetsJson(projectPath, packagesDir, objDir, includeAnalyzerAssetsGroup, restoreEnableAnalyzerAssets)); + + var task = InitializeAnalyzerAssetsTask( + testRoot, + objDir, + projectAssetsJsonPath, + projectPath, + restoreEnableAnalyzerAssetsTaskProperty ?? restoreEnableAnalyzerAssets); + + task.Execute().Should().BeTrue(); + + string expectedAnalyzerPath = Path.Combine( + packagesDir, + AnalyzerPackageName.ToLowerInvariant(), + AnalyzerPackageVersion, + AnalyzerAssetPath.Replace('/', Path.DirectorySeparatorChar)); + + assert(task.Analyzers.Select(a => a.ItemSpec).ToArray(), expectedAnalyzerPath); + } + finally + { + try { Directory.Delete(testRoot, true); } catch { } + } + } + + private static ResolvePackageAssets InitializeAnalyzerAssetsTask( + string testRoot, + string objDir, + string projectAssetsJsonPath, + string projectPath, + bool restoreEnableAnalyzerAssets) + { + return new ResolvePackageAssets + { + BuildEngine = new MockBuildEngine(), + ProjectAssetsCacheFile = Path.Combine(objDir, "project.assets.cache"), + ProjectAssetsFile = projectAssetsJsonPath, + ProjectPath = projectPath, + TargetFramework = "net8.0", + ProjectLanguage = "C#", + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DefaultImplicitPackages = "Microsoft.NETCore.App", + DisablePackageAssetsCache = true, + RestoreEnableAnalyzerAssets = restoreEnableAnalyzerAssets, + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(testRoot) + }; + } + + private static void CreateAnalyzerPackage(string packagesDir) + { + string packageDirectory = Path.Combine( + packagesDir, + AnalyzerPackageName.ToLowerInvariant(), + AnalyzerPackageVersion); + + string analyzerPath = Path.Combine( + packageDirectory, + AnalyzerAssetPath.Replace('/', Path.DirectorySeparatorChar)); + + Directory.CreateDirectory(Path.GetDirectoryName(analyzerPath)); + File.WriteAllText(analyzerPath, string.Empty); + File.WriteAllText( + Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.{AnalyzerPackageVersion}.nupkg.sha512"), + "abc123"); + File.WriteAllText( + Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.nuspec"), + $"{AnalyzerPackageName}{AnalyzerPackageVersion}"); + } + + private static string CreateAnalyzerAssetsJson( + string projectPath, + string packagesPath, + string outputPath, + bool includeAnalyzerAssetsGroup, + bool restoreEnableAnalyzerAssets) + { + string analyzerAssetsGroup = includeAnalyzerAssetsGroup + ? $@", + ""analyzers"": {{ + ""{AnalyzerAssetPath}"": {{}} + }}" + : ""; + + string restoreEnableAnalyzerAssetsMetadata = restoreEnableAnalyzerAssets + ? @" + ""restoreEnableAnalyzerAssets"": true," + : ""; + + return $$""" + { + "version": 5, + "targets": { + "net8.0": { + "{{AnalyzerPackageName}}/{{AnalyzerPackageVersion}}": { + "type": "package"{{analyzerAssetsGroup}} + } + } + }, + "libraries": { + "{{AnalyzerPackageName}}/{{AnalyzerPackageVersion}}": { + "sha512": "abc123", + "type": "package", + "path": "{{AnalyzerPackageName.ToLowerInvariant()}}/{{AnalyzerPackageVersion}}", + "files": [ + "{{AnalyzerAssetPath}}", + "{{AnalyzerPackageName.ToLowerInvariant()}}.{{AnalyzerPackageVersion}}.nupkg.sha512", + "{{AnalyzerPackageName.ToLowerInvariant()}}.nuspec" + ] + } + }, + "projectFileDependencyGroups": { "net8.0": ["{{AnalyzerPackageName}} >= {{AnalyzerPackageVersion}}"] }, + "packageFolders": { "{{JsonEscape(packagesPath)}}": {} }, + "project": { + "version": "1.0.0", + "restore": {{{restoreEnableAnalyzerAssetsMetadata}} + "projectUniqueName": "test", + "projectName": "test", + "projectPath": "{{JsonEscape(projectPath)}}", + "packagesPath": "{{JsonEscape(packagesPath)}}", + "outputPath": "{{JsonEscape(outputPath)}}", + "projectStyle": "PackageReference", + "frameworks": { + "net8.0": { "targetAlias": "net8.0" } + } + }, + "frameworks": { "net8.0": { "targetAlias": "net8.0" } } + } + } + """; + } + + private static string JsonEscape(string value) => value.Replace(@"\", @"\\"); + + // Derives the analyzer selection metadata from the asset path the same way NuGet restore does, + // so the hand-written assets file matches what restore would produce. + private static string AnalyzerMetadataJson(string path) + { + // '_._' placeholders are written without metadata, matching NuGet restore output. + if (path.EndsWith("_._", StringComparison.Ordinal)) + { + return string.Empty; + } + + string codeLanguage = "any"; + string compilerApiVersion = null; + + string[] segments = path.Split('/'); + for (int i = 0; i < segments.Length - 1; i++) + { + string segment = segments[i]; + if (segment is "cs" or "vb" or "fs") + { + codeLanguage = segment; + } + else if (compilerApiVersion == null + && segment.StartsWith("roslyn", StringComparison.OrdinalIgnoreCase) + && segment.Length > "roslyn".Length + && char.IsDigit(segment["roslyn".Length])) + { + compilerApiVersion = segment; + } + } + + string metadata = $@"""codeLanguage"": ""{codeLanguage}"""; + if (compilerApiVersion != null) + { + metadata += $@", ""compilerApiVersion"": ""{compilerApiVersion}"""; + } + + return metadata; + } + + private static string[] ResolveGroupAnalyzers( + string projectLanguage, + string compilerApiVersion, + string[] analyzerPaths, + bool restoreEnableAnalyzerAssets = true) + { + string testRoot = Path.Combine(Path.GetTempPath(), "rpa-analyzers-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(testRoot); + + try + { + string objDir = Path.Combine(testRoot, "obj"); + string packagesDir = Path.Combine(testRoot, "packages"); + Directory.CreateDirectory(objDir); + + string packageDirectory = Path.Combine( + packagesDir, + AnalyzerPackageName.ToLowerInvariant(), + AnalyzerPackageVersion); + + foreach (string relativePath in analyzerPaths) + { + string fullPath = Path.Combine(packageDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); + File.WriteAllText(fullPath, string.Empty); + } + + File.WriteAllText( + Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.{AnalyzerPackageVersion}.nupkg.sha512"), + "abc123"); + File.WriteAllText( + Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.nuspec"), + $"{AnalyzerPackageName}{AnalyzerPackageVersion}"); + + string projectPath = Path.Combine(testRoot, "test.csproj"); + string projectAssetsJsonPath = Path.Combine(objDir, "project.assets.json"); + File.WriteAllText( + projectAssetsJsonPath, + CreateMultiAnalyzerAssetsJson(projectPath, packagesDir, objDir, analyzerPaths, restoreEnableAnalyzerAssets)); + + var task = new ResolvePackageAssets + { + BuildEngine = new MockBuildEngine(), + ProjectAssetsCacheFile = Path.Combine(objDir, "project.assets.cache"), + ProjectAssetsFile = projectAssetsJsonPath, + ProjectPath = projectPath, + TargetFramework = "net8.0", + ProjectLanguage = projectLanguage, + CompilerApiVersion = compilerApiVersion, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DefaultImplicitPackages = "Microsoft.NETCore.App", + DisablePackageAssetsCache = true, + RestoreEnableAnalyzerAssets = restoreEnableAnalyzerAssets, + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(testRoot) + }; + + task.Execute().Should().BeTrue(); + + string packagePrefix = packageDirectory + Path.DirectorySeparatorChar; + return task.Analyzers + .Select(analyzer => analyzer.ItemSpec) + .Select(itemSpec => itemSpec.StartsWith(packagePrefix, StringComparison.OrdinalIgnoreCase) + ? itemSpec.Substring(packagePrefix.Length).Replace(Path.DirectorySeparatorChar, '/') + : itemSpec) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + finally + { + try { Directory.Delete(testRoot, true); } catch { } + } + } + + private static string CreateMultiAnalyzerAssetsJson( + string projectPath, + string packagesPath, + string outputPath, + string[] analyzerPaths, + bool restoreEnableAnalyzerAssets) + { + string groupEntries = string.Join( + ",\r\n ", + analyzerPaths.Select(path => $@"""{path}"": {{ {AnalyzerMetadataJson(path)} }}")); + + string restoreEnableAnalyzerAssetsMetadata = restoreEnableAnalyzerAssets + ? @" + ""restoreEnableAnalyzerAssets"": true," + : ""; + + string filesArray = string.Join( + ",\r\n ", + analyzerPaths + .Concat(new[] + { + $"{AnalyzerPackageName.ToLowerInvariant()}.{AnalyzerPackageVersion}.nupkg.sha512", + $"{AnalyzerPackageName.ToLowerInvariant()}.nuspec" + }) + .Select(file => $@"""{file}""")); + + return $$""" + { + "version": 5, + "targets": { + "net8.0": { + "{{AnalyzerPackageName}}/{{AnalyzerPackageVersion}}": { + "type": "package", + "analyzers": { + {{groupEntries}} + } + } + } + }, + "libraries": { + "{{AnalyzerPackageName}}/{{AnalyzerPackageVersion}}": { + "sha512": "abc123", + "type": "package", + "path": "{{AnalyzerPackageName.ToLowerInvariant()}}/{{AnalyzerPackageVersion}}", + "files": [ + {{filesArray}} + ] + } + }, + "projectFileDependencyGroups": { "net8.0": ["{{AnalyzerPackageName}} >= {{AnalyzerPackageVersion}}"] }, + "packageFolders": { "{{JsonEscape(packagesPath)}}": {} }, + "project": { + "version": "1.0.0", + "restore": {{{restoreEnableAnalyzerAssetsMetadata}} + "projectUniqueName": "test", + "projectName": "test", + "projectPath": "{{JsonEscape(projectPath)}}", + "packagesPath": "{{JsonEscape(packagesPath)}}", + "outputPath": "{{JsonEscape(outputPath)}}", + "projectStyle": "PackageReference", + "frameworks": { + "net8.0": { "targetAlias": "net8.0" } + } + }, + "frameworks": { "net8.0": { "targetAlias": "net8.0" } } + } + } + """; + } + private static string AssetsFileWithInvalidLocale(string tfm, string locale) => @" { `version`: 3, From 5b57f66a8ac4d0af40b6bdb81c45eb5213099d3c Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Mon, 20 Jul 2026 14:13:54 -0700 Subject: [PATCH 2/4] feat(assets): consume analyzer groups from lock files Treat restore metadata and target analyzer groups as authoritative when the feature is enabled while preserving legacy package scanning when disabled. Reuse the existing analyzer resolver and add focused unit and restore-to-build coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c2487ed6-8e7a-4e23-9be7-4941cdfe9b8e --- .../ResolvePackageAssets.cs | 230 ++++++----------- ...rosoft.PackageDependencyResolution.targets | 12 - .../GivenAResolvePackageAssetsTask.cs | 231 +++++++----------- .../Build/GivenDotnetBuildBuildsCsproj.cs | 69 ++++-- 4 files changed, 215 insertions(+), 327 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs index b922c04e65a3..0afe5d14344c 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs @@ -134,11 +134,6 @@ public sealed class ResolvePackageAssets : TaskBase, IMultiThreadableTask /// public string CompilerApiVersion { get; set; } - /// - /// Consume analyzer assets from project.assets.json instead of scanning package files. - /// - public bool RestoreEnableAnalyzerAssets { get; set; } - /// /// Check that there is at least one package dependency in the RID graph that is not in the RID-agnostic graph. /// Used as a heuristic to detect invalid RIDs. @@ -492,7 +487,6 @@ internal byte[] HashSettings() } writer.Write(ProjectLanguage ?? ""); writer.Write(CompilerApiVersion ?? ""); - writer.Write(RestoreEnableAnalyzerAssets); writer.Write(ProjectPath); // we want to ensure uniqueness of results, so even though `any` is No RID for purposes of Task logic, // we continue to treat it distinctly for hashing @@ -934,142 +928,35 @@ public int GetHashCode((string, NuGetVersion) library) private void WriteAnalyzers() { - // Honor the feature-flag decision that restore actually made (and persisted into the assets file), - // not the raw MSBuild property. The restore-time value already reflects the TFM gate from - // NuGet.targets, which is not imported during build; trusting the raw property here would run the - // metadata path for a project whose assets file has no analyzer group, silently dropping analyzers. + // Use the decision persisted by restore so build consumes the corresponding lock-file shape. if (_lockFile.PackageSpec?.RestoreMetadata?.RestoreEnableAnalyzerAssets == true) { WriteAnalyzerAssets(); - return; } - - WriteAnalyzerPackageFiles(); + else + { + WriteAnalyzerPackageFiles(); + } } private void WriteAnalyzerAssets() { - // The "analyzers" group lists every analyzer assembly in the package (all languages and compiler - // versions), with Include/Exclude/PrivateAssets already applied by restore. Each asset carries - // "codeLanguage" and (when applicable) "compilerApiVersion" metadata, mirroring content files, so - // the SDK selects the applicable analyzers directly from that metadata rather than parsing paths. - string projectCodeLanguage = NuGetUtils.GetLockFileLanguageName(_task.ProjectLanguage); - bool hasProjectCompilerVersion = TryGetCompilerVersion(_task.CompilerApiVersion, out Version projectCompilerVersion); + AnalyzerResolver resolver = new(this); foreach (LockFileTargetLibrary library in _compileTimeTarget.Libraries) { - if (!library.IsPackage() || library.AnalyzerAssets.Count == 0) + if (!library.IsPackage()) { continue; } - Version maxApplicableVersion = null; - List<(string Path, Version Version)> compilerVersionSpecificAssets = null; - foreach (LockFileItem asset in library.AnalyzerAssets) { - if (asset.IsPlaceholderFile()) - { - continue; - } - - if (!IsApplicableAnalyzerLanguage(asset, projectCodeLanguage)) - { - _task.Log.LogMessage(MessageImportance.Low, $"Excluding analyzer '{asset.Path}' from package '{library.Name}' because its code language does not apply to the project language '{projectCodeLanguage}'."); - continue; - } - - if (hasProjectCompilerVersion - && asset.Properties.TryGetValue(LockFileItem.CompilerApiVersionProperty, out string assetCompilerApiVersion) - && TryGetCompilerVersion(assetCompilerApiVersion, out Version assetCompilerVersion)) - { - if (assetCompilerVersion > projectCompilerVersion) - { - // The analyzer targets a newer compiler than the current one; skip it. - _task.Log.LogMessage(MessageImportance.Low, $"Excluding analyzer '{asset.Path}' from package '{library.Name}' because its compiler version '{assetCompilerApiVersion}' is newer than the project compiler version '{_task.CompilerApiVersion}'."); - continue; - } - - compilerVersionSpecificAssets ??= new List<(string, Version)>(); - compilerVersionSpecificAssets.Add((asset.Path, assetCompilerVersion)); - - if (maxApplicableVersion == null || assetCompilerVersion > maxApplicableVersion) - { - maxApplicableVersion = assetCompilerVersion; - } - } - else - { - // Either the analyzer is compiler-version-agnostic, or the project compiler version is - // unknown (in which case every analyzer variant is treated as version-agnostic). Always apply. - _task.Log.LogMessage(MessageImportance.Low, $"Including analyzer '{asset.Path}' from package '{library.Name}'."); - WriteItem(_packageResolver.ResolvePackageAssetPath(library, asset.Path), library); - } - } - - if (compilerVersionSpecificAssets != null) - { - // Among the compiler-version-specific analyzers, only the highest applicable version is used. - foreach ((string path, Version version) in compilerVersionSpecificAssets) - { - if (version.Equals(maxApplicableVersion)) - { - _task.Log.LogMessage(MessageImportance.Low, $"Including analyzer '{path}' from package '{library.Name}' for compiler version '{maxApplicableVersion}'."); - WriteItem(_packageResolver.ResolvePackageAssetPath(library, path), library); - } - else - { - _task.Log.LogMessage(MessageImportance.Low, $"Excluding analyzer '{path}' from package '{library.Name}' because a higher applicable compiler version '{maxApplicableVersion}' is available."); - } - } - } - } - } - - private static bool IsApplicableAnalyzerLanguage(LockFileItem asset, string projectCodeLanguage) - { - // Language-agnostic analyzers (no language segment) apply to every project. Otherwise the analyzer - // applies only when its code language matches the project's language. - if (!asset.Properties.TryGetValue(LockFileContentFile.CodeLanguageProperty, out string codeLanguage) - || string.IsNullOrEmpty(codeLanguage) - || string.Equals(codeLanguage, "any", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return projectCodeLanguage != null - && string.Equals(codeLanguage, projectCodeLanguage, StringComparison.OrdinalIgnoreCase); - } - - private static bool TryGetCompilerVersion(string compilerApiVersion, out Version version) - { - version = null; - - if (string.IsNullOrEmpty(compilerApiVersion)) - { - return false; - } - - int versionStart = -1; - for (int i = 0; i < compilerApiVersion.Length; i++) - { - if (char.IsDigit(compilerApiVersion[i])) - { - versionStart = i; - break; + resolver.AddAsset(asset, library); } - } - if (versionStart < 0) - { - return false; + resolver.CompleteLibraryAnalyzers(); } - -#if NET - return Version.TryParse(compilerApiVersion.AsSpan(versionStart), out version); -#else - return Version.TryParse(compilerApiVersion.Substring(versionStart), out version); -#endif } private void WriteAnalyzerPackageFiles() @@ -1114,10 +1001,12 @@ private void WriteAnalyzerPackageFiles() private class AnalyzerResolver { private readonly CacheWriter _cacheWriter; + private readonly string _compilerName; private readonly string _compilerNameSearchString; private readonly Version _compilerVersion; + private readonly string _projectCodeLanguage; private Dictionary<(string, NuGetVersion), LockFileTargetLibrary> _targetLibraries; - private List<(string, LockFileLibrary, Version)> _potentialAnalyzers; + private List<(string, LockFileTargetLibrary, Version)> _potentialAnalyzers; private Version _maxApplicableVersion; private Dictionary<(string, NuGetVersion), LockFileTargetLibrary> TargetLibraries => @@ -1127,9 +1016,11 @@ private class AnalyzerResolver public AnalyzerResolver(CacheWriter cacheWriter) { _cacheWriter = cacheWriter; + _projectCodeLanguage = NuGetUtils.GetLockFileLanguageName(_cacheWriter._task.ProjectLanguage); if (ParseCompilerApiVersion(_cacheWriter._task.CompilerApiVersion, out ReadOnlyMemory compilerName, out Version compilerVersion)) { + _compilerName = compilerName.ToString(); #if NET _compilerNameSearchString = string.Concat("/".AsSpan(), compilerName.Span); #else @@ -1141,30 +1032,74 @@ public AnalyzerResolver(CacheWriter cacheWriter) public void AddFile(string file, LockFileLibrary library) { - if (NuGetUtils.IsApplicableAnalyzer(file, _cacheWriter._task.ProjectLanguage)) + if (!NuGetUtils.IsApplicableAnalyzer(file, _cacheWriter._task.ProjectLanguage) + || !TargetLibraries.TryGetValue((library.Name, library.Version), out LockFileTargetLibrary targetLibrary)) { - if (IsFileCompilerVersionSpecific(file, out Version fileCompilerVersion)) - { - if (fileCompilerVersion > _compilerVersion) - { - // version is too high - skip this file - return; - } + return; + } - _potentialAnalyzers ??= new List<(string, LockFileLibrary, Version)>(); - _potentialAnalyzers.Add((file, library, fileCompilerVersion)); + AddAnalyzer( + file, + targetLibrary, + IsFileCompilerVersionSpecific(file, out Version fileCompilerVersion) ? fileCompilerVersion : null); + } - if (_maxApplicableVersion == null || fileCompilerVersion > _maxApplicableVersion) - { - _maxApplicableVersion = fileCompilerVersion; - } - } - else - { - // if this file isn't specific to a compiler version, just write it directly - WriteAnalyzer(file, library); - } + public void AddAsset(LockFileItem asset, LockFileTargetLibrary library) + { + if (asset.IsPlaceholderFile() || !IsApplicableAnalyzerLanguage(asset)) + { + return; } + + AddAnalyzer(asset.Path, library, GetAssetCompilerVersion(asset)); + } + + private Version GetAssetCompilerVersion(LockFileItem asset) + { + if (_compilerName == null + || !asset.Properties.TryGetValue(LockFileItem.CompilerApiVersionProperty, out string compilerApiVersion) + || !ParseCompilerApiVersion(compilerApiVersion, out ReadOnlyMemory compilerName, out Version compilerVersion) + || !string.Equals(_compilerName, compilerName.ToString(), StringComparison.Ordinal)) + { + return null; + } + + return compilerVersion; + } + + private void AddAnalyzer(string file, LockFileTargetLibrary library, Version compilerVersion) + { + if (compilerVersion == null) + { + WriteAnalyzer(file, library); + return; + } + + if (compilerVersion > _compilerVersion) + { + return; + } + + _potentialAnalyzers ??= new List<(string, LockFileTargetLibrary, Version)>(); + _potentialAnalyzers.Add((file, library, compilerVersion)); + + if (_maxApplicableVersion == null || compilerVersion > _maxApplicableVersion) + { + _maxApplicableVersion = compilerVersion; + } + } + + private bool IsApplicableAnalyzerLanguage(LockFileItem asset) + { + if (!asset.Properties.TryGetValue(LockFileContentFile.CodeLanguageProperty, out string codeLanguage) + || string.IsNullOrEmpty(codeLanguage) + || string.Equals(codeLanguage, "any", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return _projectCodeLanguage != null + && string.Equals(codeLanguage, _projectCodeLanguage, StringComparison.OrdinalIgnoreCase); } private bool IsFileCompilerVersionSpecific(string file, out Version fileCompilerVersion) @@ -1197,7 +1132,7 @@ public void CompleteLibraryAnalyzers() { if (_maxApplicableVersion != null && _potentialAnalyzers?.Count > 0) { - foreach (var (file, library, version) in _potentialAnalyzers) + foreach ((string file, LockFileTargetLibrary library, Version version) in _potentialAnalyzers) { if (version == _maxApplicableVersion) { @@ -1211,12 +1146,9 @@ public void CompleteLibraryAnalyzers() _potentialAnalyzers?.Clear(); } - private void WriteAnalyzer(string file, LockFileLibrary library) + private void WriteAnalyzer(string file, LockFileTargetLibrary library) { - if (TargetLibraries.TryGetValue((library.Name, library.Version), out var targetLibrary)) - { - _cacheWriter.WriteItem(_cacheWriter._packageResolver.ResolvePackageAssetPath(targetLibrary, file), targetLibrary); - } + _cacheWriter.WriteItem(_cacheWriter._packageResolver.ResolvePackageAssetPath(library, file), library); } /// diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets index 074788b78e7a..a1b3e49023d4 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets +++ b/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.PackageDependencyResolution.targets @@ -56,17 +56,6 @@ Copyright (c) .NET Foundation. All rights reserved. true - - - false @@ -280,7 +269,6 @@ Copyright (c) .NET Foundation. All rights reserved. ProjectPath="$(MSBuildProjectFullPath)" ProjectLanguage="$(Language)" CompilerApiVersion="$(CompilerApiVersion)" - RestoreEnableAnalyzerAssets="$(RestoreEnableAnalyzerAssets)" EmitAssetsLogMessages="$(EmitAssetsLogMessages)" TargetFramework="$(TargetFramework)" RuntimeIdentifier="$(RuntimeIdentifier)" diff --git a/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs index f280ac354462..71dbfcef633d 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs @@ -81,11 +81,8 @@ public void It_reads_analyzer_assets_from_lock_file_when_enabled() { ExecuteAnalyzerAssetsTest( restoreEnableAnalyzerAssets: true, - includeAnalyzerAssetsGroup: true, - assert: (analyzers, expectedAnalyzerPath) => - { - analyzers.Should().Equal(expectedAnalyzerPath); - }); + includeAnalyzerAssetsGroup: true) + .Should().Equal(AnalyzerAssetPath); } [TestMethod] @@ -93,11 +90,8 @@ public void It_does_not_fall_back_to_package_file_scanning_when_analyzer_assets_ { ExecuteAnalyzerAssetsTest( restoreEnableAnalyzerAssets: true, - includeAnalyzerAssetsGroup: false, - assert: (analyzers, expectedAnalyzerPath) => - { - analyzers.Should().BeEmpty(); - }); + includeAnalyzerAssetsGroup: false) + .Should().BeEmpty(); } [TestMethod] @@ -105,11 +99,8 @@ public void It_preserves_package_file_analyzer_scanning_when_analyzer_assets_are { ExecuteAnalyzerAssetsTest( restoreEnableAnalyzerAssets: false, - includeAnalyzerAssetsGroup: false, - assert: (analyzers, expectedAnalyzerPath) => - { - analyzers.Should().Equal(expectedAnalyzerPath); - }); + includeAnalyzerAssetsGroup: false) + .Should().Equal(AnalyzerAssetPath); } [TestMethod] @@ -129,6 +120,18 @@ public void It_applies_language_selection_to_analyzer_assets_from_the_group() selected.Should().Equal("analyzers/dotnet/cs/CSharpAnalyzer.dll"); } + [TestMethod] + public void It_uses_analyzer_metadata_instead_of_the_asset_path() + { + string[] selected = ResolveGroupAnalyzers( + projectLanguage: "C#", + compilerApiVersion: null, + analyzerPaths: new[] { "analyzers/dotnet/cs/CSharpAnalyzer.dll" }, + analyzerMetadata: _ => @"""codeLanguage"": ""vb"""); + + selected.Should().BeEmpty(); + } + [TestMethod] public void It_applies_compiler_version_selection_to_analyzer_assets_from_the_group() { @@ -173,23 +176,6 @@ public void It_ignores_analyzer_metadata_and_uses_legacy_scanning_when_feature_f selectedWithoutFlag.Should().Equal(expected); } - [TestMethod] - public void It_honors_the_restore_decision_from_the_assets_file_over_the_msbuild_property() - { - // The TFM gate (.NET 11+) lives in NuGet.targets, which is not imported during build, so the raw - // MSBuild property can be true for a project that restore actually gated off. The SDK must honor the - // value restore persisted into the assets file (here: disabled, no analyzer group) and fall back to - // legacy package-file scanning instead of silently dropping all analyzers. - ExecuteAnalyzerAssetsTest( - restoreEnableAnalyzerAssets: false, // assets file: restore gated the feature off - includeAnalyzerAssetsGroup: false, - restoreEnableAnalyzerAssetsTaskProperty: true, // raw MSBuild property (ungated) is true - assert: (analyzers, expectedAnalyzerPath) => - { - analyzers.Should().Equal(expectedAnalyzerPath); - }); - } - [TestMethod] public void It_does_not_error_on_duplicate_package_names() { @@ -336,20 +322,10 @@ public void It_reads_analyzer_assets_for_the_current_target_framework_in_a_multi string packagesDir = Path.Combine(testRoot, "packages"); Directory.CreateDirectory(objDir); - string packageDirectory = Path.Combine(packagesDir, AnalyzerPackageName.ToLowerInvariant(), AnalyzerPackageVersion); - foreach (string relativePath in new[] { "analyzers/dotnet/cs/Net8Analyzer.dll", "analyzers/dotnet/cs/Net9Analyzer.dll" }) - { - string fullPath = Path.Combine(packageDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - File.WriteAllText(fullPath, string.Empty); - } - - File.WriteAllText( - Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.{AnalyzerPackageVersion}.nupkg.sha512"), - "abc123"); - File.WriteAllText( - Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.nuspec"), - $"{AnalyzerPackageName}{AnalyzerPackageVersion}"); + string packageDirectory = CreateAnalyzerPackage( + packagesDir, + "analyzers/dotnet/cs/Net8Analyzer.dll", + "analyzers/dotnet/cs/Net9Analyzer.dll"); string projectPath = Path.Combine(testRoot, "test.csproj"); string projectAssetsJsonPath = Path.Combine(objDir, "project.assets.json"); @@ -357,28 +333,15 @@ public void It_reads_analyzer_assets_for_the_current_target_framework_in_a_multi projectAssetsJsonPath, CreateMultiTargetedAnalyzerAssetsJson(projectPath, packagesDir, objDir)); - var task = new ResolvePackageAssets - { - BuildEngine = new MockBuildEngine(), - ProjectAssetsCacheFile = Path.Combine(objDir, "project.assets.cache"), - ProjectAssetsFile = projectAssetsJsonPath, - ProjectPath = projectPath, - TargetFramework = "net9.0", - ProjectLanguage = "C#", - DotNetAppHostExecutableNameWithoutExtension = "apphost", - DefaultImplicitPackages = "Microsoft.NETCore.App", - DisablePackageAssetsCache = true, - RestoreEnableAnalyzerAssets = true, - TaskEnvironment = TaskEnvironmentHelper.CreateForTest(testRoot) - }; + ResolvePackageAssets task = CreateAnalyzerAssetsTask( + testRoot, + projectAssetsJsonPath, + projectPath, + targetFramework: "net9.0"); task.Execute().Should().BeTrue(); - string packagePrefix = packageDirectory + Path.DirectorySeparatorChar; - task.Analyzers - .Select(a => a.ItemSpec.StartsWith(packagePrefix, StringComparison.OrdinalIgnoreCase) - ? a.ItemSpec.Substring(packagePrefix.Length).Replace(Path.DirectorySeparatorChar, '/') - : a.ItemSpec) + GetPackageRelativeAnalyzerPaths(task, packageDirectory) .Should().Equal("analyzers/dotnet/cs/Net9Analyzer.dll"); } finally @@ -456,11 +419,9 @@ private static string CreateMultiTargetedAnalyzerAssetsJson(string projectPath, private const string AnalyzerPackageVersion = "1.0.0"; private const string AnalyzerAssetPath = "analyzers/dotnet/cs/Analyzer.Package.dll"; - private static void ExecuteAnalyzerAssetsTest( + private static string[] ExecuteAnalyzerAssetsTest( bool restoreEnableAnalyzerAssets, - bool includeAnalyzerAssetsGroup, - Action assert, - bool? restoreEnableAnalyzerAssetsTaskProperty = null) + bool includeAnalyzerAssetsGroup) { string testRoot = Path.Combine(Path.GetTempPath(), "rpa-analyzers-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(testRoot); @@ -470,7 +431,7 @@ private static void ExecuteAnalyzerAssetsTest( string objDir = Path.Combine(testRoot, "obj"); string packagesDir = Path.Combine(testRoot, "packages"); Directory.CreateDirectory(objDir); - CreateAnalyzerPackage(packagesDir); + string packageDirectory = CreateAnalyzerPackage(packagesDir, AnalyzerAssetPath); string projectPath = Path.Combine(testRoot, "test.csproj"); string projectAssetsJsonPath = Path.Combine(objDir, "project.assets.json"); @@ -478,22 +439,13 @@ private static void ExecuteAnalyzerAssetsTest( projectAssetsJsonPath, CreateAnalyzerAssetsJson(projectPath, packagesDir, objDir, includeAnalyzerAssetsGroup, restoreEnableAnalyzerAssets)); - var task = InitializeAnalyzerAssetsTask( + ResolvePackageAssets task = CreateAnalyzerAssetsTask( testRoot, - objDir, projectAssetsJsonPath, - projectPath, - restoreEnableAnalyzerAssetsTaskProperty ?? restoreEnableAnalyzerAssets); + projectPath); task.Execute().Should().BeTrue(); - - string expectedAnalyzerPath = Path.Combine( - packagesDir, - AnalyzerPackageName.ToLowerInvariant(), - AnalyzerPackageVersion, - AnalyzerAssetPath.Replace('/', Path.DirectorySeparatorChar)); - - assert(task.Analyzers.Select(a => a.ItemSpec).ToArray(), expectedAnalyzerPath); + return GetPackageRelativeAnalyzerPaths(task, packageDirectory); } finally { @@ -501,48 +453,69 @@ private static void ExecuteAnalyzerAssetsTest( } } - private static ResolvePackageAssets InitializeAnalyzerAssetsTask( + private static ResolvePackageAssets CreateAnalyzerAssetsTask( string testRoot, - string objDir, string projectAssetsJsonPath, string projectPath, - bool restoreEnableAnalyzerAssets) + string targetFramework = "net8.0", + string projectLanguage = "C#", + string compilerApiVersion = null) { return new ResolvePackageAssets { BuildEngine = new MockBuildEngine(), - ProjectAssetsCacheFile = Path.Combine(objDir, "project.assets.cache"), + ProjectAssetsCacheFile = Path.Combine(Path.GetDirectoryName(projectAssetsJsonPath), "project.assets.cache"), ProjectAssetsFile = projectAssetsJsonPath, ProjectPath = projectPath, - TargetFramework = "net8.0", - ProjectLanguage = "C#", + TargetFramework = targetFramework, + ProjectLanguage = projectLanguage, + CompilerApiVersion = compilerApiVersion, DotNetAppHostExecutableNameWithoutExtension = "apphost", DefaultImplicitPackages = "Microsoft.NETCore.App", DisablePackageAssetsCache = true, - RestoreEnableAnalyzerAssets = restoreEnableAnalyzerAssets, TaskEnvironment = TaskEnvironmentHelper.CreateForTest(testRoot) }; } - private static void CreateAnalyzerPackage(string packagesDir) + private static string CreateAnalyzerPackage(string packagesDir, params string[] analyzerPaths) { string packageDirectory = Path.Combine( packagesDir, AnalyzerPackageName.ToLowerInvariant(), AnalyzerPackageVersion); - string analyzerPath = Path.Combine( - packageDirectory, - AnalyzerAssetPath.Replace('/', Path.DirectorySeparatorChar)); + foreach (string relativePath in analyzerPaths) + { + string analyzerPath = Path.Combine( + packageDirectory, + relativePath.Replace('/', Path.DirectorySeparatorChar)); + + Directory.CreateDirectory(Path.GetDirectoryName(analyzerPath)); + File.WriteAllText(analyzerPath, string.Empty); + } - Directory.CreateDirectory(Path.GetDirectoryName(analyzerPath)); - File.WriteAllText(analyzerPath, string.Empty); File.WriteAllText( Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.{AnalyzerPackageVersion}.nupkg.sha512"), "abc123"); File.WriteAllText( Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.nuspec"), $"{AnalyzerPackageName}{AnalyzerPackageVersion}"); + + return packageDirectory; + } + + private static string[] GetPackageRelativeAnalyzerPaths( + ResolvePackageAssets task, + string packageDirectory) + { + string packagePrefix = packageDirectory + Path.DirectorySeparatorChar; + return task.Analyzers + .Select(analyzer => analyzer.ItemSpec) + .Select(itemSpec => itemSpec.StartsWith(packagePrefix, StringComparison.OrdinalIgnoreCase) + ? itemSpec.Substring(packagePrefix.Length).Replace(Path.DirectorySeparatorChar, '/') + : itemSpec) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); } private static string CreateAnalyzerAssetsJson( @@ -652,7 +625,8 @@ private static string[] ResolveGroupAnalyzers( string projectLanguage, string compilerApiVersion, string[] analyzerPaths, - bool restoreEnableAnalyzerAssets = true) + bool restoreEnableAnalyzerAssets = true, + Func analyzerMetadata = null) { string testRoot = Path.Combine(Path.GetTempPath(), "rpa-analyzers-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(testRoot); @@ -663,57 +637,29 @@ private static string[] ResolveGroupAnalyzers( string packagesDir = Path.Combine(testRoot, "packages"); Directory.CreateDirectory(objDir); - string packageDirectory = Path.Combine( - packagesDir, - AnalyzerPackageName.ToLowerInvariant(), - AnalyzerPackageVersion); - - foreach (string relativePath in analyzerPaths) - { - string fullPath = Path.Combine(packageDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - File.WriteAllText(fullPath, string.Empty); - } - - File.WriteAllText( - Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.{AnalyzerPackageVersion}.nupkg.sha512"), - "abc123"); - File.WriteAllText( - Path.Combine(packageDirectory, $"{AnalyzerPackageName.ToLowerInvariant()}.nuspec"), - $"{AnalyzerPackageName}{AnalyzerPackageVersion}"); + string packageDirectory = CreateAnalyzerPackage(packagesDir, analyzerPaths); string projectPath = Path.Combine(testRoot, "test.csproj"); string projectAssetsJsonPath = Path.Combine(objDir, "project.assets.json"); File.WriteAllText( projectAssetsJsonPath, - CreateMultiAnalyzerAssetsJson(projectPath, packagesDir, objDir, analyzerPaths, restoreEnableAnalyzerAssets)); - - var task = new ResolvePackageAssets - { - BuildEngine = new MockBuildEngine(), - ProjectAssetsCacheFile = Path.Combine(objDir, "project.assets.cache"), - ProjectAssetsFile = projectAssetsJsonPath, - ProjectPath = projectPath, - TargetFramework = "net8.0", - ProjectLanguage = projectLanguage, - CompilerApiVersion = compilerApiVersion, - DotNetAppHostExecutableNameWithoutExtension = "apphost", - DefaultImplicitPackages = "Microsoft.NETCore.App", - DisablePackageAssetsCache = true, - RestoreEnableAnalyzerAssets = restoreEnableAnalyzerAssets, - TaskEnvironment = TaskEnvironmentHelper.CreateForTest(testRoot) - }; + CreateMultiAnalyzerAssetsJson( + projectPath, + packagesDir, + objDir, + analyzerPaths, + restoreEnableAnalyzerAssets, + analyzerMetadata ?? AnalyzerMetadataJson)); + + ResolvePackageAssets task = CreateAnalyzerAssetsTask( + testRoot, + projectAssetsJsonPath, + projectPath, + projectLanguage: projectLanguage, + compilerApiVersion: compilerApiVersion); task.Execute().Should().BeTrue(); - - string packagePrefix = packageDirectory + Path.DirectorySeparatorChar; - return task.Analyzers - .Select(analyzer => analyzer.ItemSpec) - .Select(itemSpec => itemSpec.StartsWith(packagePrefix, StringComparison.OrdinalIgnoreCase) - ? itemSpec.Substring(packagePrefix.Length).Replace(Path.DirectorySeparatorChar, '/') - : itemSpec) - .OrderBy(path => path, StringComparer.Ordinal) - .ToArray(); + return GetPackageRelativeAnalyzerPaths(task, packageDirectory); } finally { @@ -726,11 +672,12 @@ private static string CreateMultiAnalyzerAssetsJson( string packagesPath, string outputPath, string[] analyzerPaths, - bool restoreEnableAnalyzerAssets) + bool restoreEnableAnalyzerAssets, + Func analyzerMetadata) { string groupEntries = string.Join( ",\r\n ", - analyzerPaths.Select(path => $@"""{path}"": {{ {AnalyzerMetadataJson(path)} }}")); + analyzerPaths.Select(path => $@"""{path}"": {{ {analyzerMetadata(path)} }}")); string restoreEnableAnalyzerAssetsMetadata = restoreEnableAnalyzerAssets ? @" diff --git a/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs b/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs index 7299b5c935f0..9055039c021f 100644 --- a/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs +++ b/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs @@ -319,17 +319,22 @@ public void It_builds_referenced_exe_with_self_contained_specified_via_command_l } [TestMethod] - [DataRow("roslyn3.9")] - [DataRow("roslyn4.0")] - public void It_resolves_analyzers_targeting_mulitple_roslyn_versions(string compilerApiVersion) + [DataRow("roslyn3.9", false)] + [DataRow("roslyn3.9", true)] + [DataRow("roslyn4.0", false)] + [DataRow("roslyn4.0", true)] + public void It_resolves_analyzers_targeting_multiple_roslyn_versions( + string compilerApiVersion, + bool restoreEnableAnalyzerAssets) { var testProject = new TestProject() { TargetFrameworks = "netstandard2.0" }; - // Disable analyzers built in to the SDK so we can more easily test the ones coming from NuGet packages + // Disable analyzers built in to the SDK so we can more easily test the ones coming from NuGet packages. testProject.AdditionalProperties["EnableNETAnalyzers"] = "false"; + testProject.AdditionalProperties["RestoreEnableAnalyzerAssets"] = restoreEnableAnalyzerAssets.ToString(); testProject.ProjectChanges.Add(project => { @@ -342,11 +347,14 @@ public void It_resolves_analyzers_targeting_mulitple_roslyn_versions(string comp project.Root?.Add(itemGroup); }); - var testAsset = TestAssetsManager.CreateTestProject(testProject, identifier: compilerApiVersion); + var testAsset = TestAssetsManager.CreateTestProject( + testProject, + identifier: $"{compilerApiVersion}-{restoreEnableAnalyzerAssets}"); NuGetConfigWriter.Write(testAsset.Path, SdkTestContext.Current.TestPackages); - var command = new GetValuesCommand(testAsset, + var command = new GetValuesCommand( + testAsset, "Analyzer", GetValuesCommand.ValueType.Item); @@ -358,25 +366,38 @@ public void It_resolves_analyzers_targeting_mulitple_roslyn_versions(string comp var analyzers = command.GetValues(); - switch (compilerApiVersion) + if (restoreEnableAnalyzerAssets) { - case "roslyn3.9": - analyzers.Select(RelativeNuGetPath).Should().BeEquivalentTo( - "library.containsanalyzer/1.0.0/analyzers/dotnet/roslyn3.9/cs/Library.ContainsAnalyzer.dll", - "library.containsanalyzer2/1.0.0/analyzers/dotnet/roslyn3.8/cs/Library.ContainsAnalyzer2.dll" - ); - break; - - case "roslyn4.0": - analyzers.Select(RelativeNuGetPath).Should().BeEquivalentTo( - "library.containsanalyzer/1.0.0/analyzers/dotnet/roslyn4.0/cs/Library.ContainsAnalyzer.dll", - "library.containsanalyzer2/1.0.0/analyzers/dotnet/roslyn3.10/cs/Library.ContainsAnalyzer2.dll" - ); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(compilerApiVersion)); + AssertAnalyzerAssetsWereRestored(testAsset.Path); } + + string[] expectedAnalyzers = compilerApiVersion switch + { + "roslyn3.9" => new[] + { + "library.containsanalyzer/1.0.0/analyzers/dotnet/roslyn3.9/cs/Library.ContainsAnalyzer.dll", + "library.containsanalyzer2/1.0.0/analyzers/dotnet/roslyn3.8/cs/Library.ContainsAnalyzer2.dll", + }, + "roslyn4.0" => new[] + { + "library.containsanalyzer/1.0.0/analyzers/dotnet/roslyn4.0/cs/Library.ContainsAnalyzer.dll", + "library.containsanalyzer2/1.0.0/analyzers/dotnet/roslyn3.10/cs/Library.ContainsAnalyzer2.dll", + }, + _ => throw new ArgumentOutOfRangeException(nameof(compilerApiVersion)) + }; + + analyzers.Select(RelativeNuGetPath).Should().BeEquivalentTo(expectedAnalyzers); + } + + private static void AssertAnalyzerAssetsWereRestored(string testAssetPath) + { + string projectFile = Directory.GetFiles(testAssetPath, "*.*proj", SearchOption.AllDirectories).Single(); + string projectDirectory = Path.GetDirectoryName(projectFile) + ?? throw new InvalidOperationException($"Could not determine the project directory for '{projectFile}'."); + string assetsFile = File.ReadAllText(Path.Combine(projectDirectory, "obj", "project.assets.json")); + + assetsFile.Should().Contain(@"""restoreEnableAnalyzerAssets"": true"); + assetsFile.Should().Contain(@"""analyzers"": {"); } static readonly List nugetRoots = new() @@ -389,7 +410,7 @@ static string RelativeNuGetPath(string absoluteNuGetPath) { foreach (var nugetRoot in nugetRoots) { - if (nugetRoot is not null && absoluteNuGetPath.StartsWith(nugetRoot + Path.DirectorySeparatorChar)) + if (nugetRoot is not null && absoluteNuGetPath.StartsWith(nugetRoot + Path.DirectorySeparatorChar)) { return absoluteNuGetPath.Substring(nugetRoot.Length + 1) .Replace(Path.DirectorySeparatorChar, '/'); From 11f8451d0b787d005e4c25834e1b4ccd397d0da1 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Thu, 6 Aug 2026 16:39:29 -0700 Subject: [PATCH 3/4] Address analyzer assets review feedback Avoid allocating compiler names on modern .NET and parse the assets file for semantic test assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42436a94-4466-4134-a8f6-8c44fa48f514 --- .../ResolvePackageAssets.cs | 4 ++++ .../Build/GivenDotnetBuildBuildsCsproj.cs | 21 ++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs index 0afe5d14344c..b18720b282ae 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs @@ -1059,7 +1059,11 @@ private Version GetAssetCompilerVersion(LockFileItem asset) if (_compilerName == null || !asset.Properties.TryGetValue(LockFileItem.CompilerApiVersionProperty, out string compilerApiVersion) || !ParseCompilerApiVersion(compilerApiVersion, out ReadOnlyMemory compilerName, out Version compilerVersion) +#if NET + || !compilerName.Span.Equals(_compilerName.AsSpan(), StringComparison.Ordinal)) +#else || !string.Equals(_compilerName, compilerName.ToString(), StringComparison.Ordinal)) +#endif { return null; } diff --git a/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs b/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs index 9055039c021f..ff360ea89ea2 100644 --- a/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs +++ b/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Text.Json; using Microsoft.DotNet.Cli.Commands; using Microsoft.DotNet.Configurer; @@ -394,10 +395,24 @@ private static void AssertAnalyzerAssetsWereRestored(string testAssetPath) string projectFile = Directory.GetFiles(testAssetPath, "*.*proj", SearchOption.AllDirectories).Single(); string projectDirectory = Path.GetDirectoryName(projectFile) ?? throw new InvalidOperationException($"Could not determine the project directory for '{projectFile}'."); - string assetsFile = File.ReadAllText(Path.Combine(projectDirectory, "obj", "project.assets.json")); + using JsonDocument assetsFile = JsonDocument.Parse( + File.ReadAllText(Path.Combine(projectDirectory, "obj", "project.assets.json"))); + + assetsFile.RootElement + .GetProperty("project") + .GetProperty("restore") + .GetProperty("restoreEnableAnalyzerAssets") + .GetBoolean() + .Should() + .BeTrue(); + + bool hasAnalyzerGroup = assetsFile.RootElement + .GetProperty("targets") + .EnumerateObject() + .SelectMany(target => target.Value.EnumerateObject()) + .Any(library => library.Value.TryGetProperty("analyzers", out _)); - assetsFile.Should().Contain(@"""restoreEnableAnalyzerAssets"": true"); - assetsFile.Should().Contain(@"""analyzers"": {"); + hasAnalyzerGroup.Should().BeTrue(); } static readonly List nugetRoots = new() From 49700191f042b1fd44568e38611b5e5702bdcd72 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Mon, 10 Aug 2026 13:52:57 -0700 Subject: [PATCH 4/4] Use SDK restore for analyzer asset tests Restore analyzer-asset-enabled cases with the built SDK before invoking Full MSBuild, so the test does not depend on Visual Studio's in-box NuGet writing the new assets metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42436a94-4466-4134-a8f6-8c44fa48f514 --- .../Build/GivenDotnetBuildBuildsCsproj.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs b/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs index ff360ea89ea2..c845b08eb6bb 100644 --- a/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs +++ b/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs @@ -363,6 +363,19 @@ public void It_resolves_analyzers_targeting_multiple_roslyn_versions( // the CodeAnalysis targets. command.Properties.Add("CompilerApiVersion", compilerApiVersion); + if (restoreEnableAnalyzerAssets && + SdkTestContext.Current.ToolsetUnderTest.ShouldUseFullFrameworkMSBuild) + { + // Full MSBuild may use an in-box NuGet that does not produce analyzer assets yet. + // Restore with the SDK's NuGet, then verify Full MSBuild consumes that assets file. + new DotnetRestoreCommand(Log, command.FullPathProjectFile) + .Execute() + .Should() + .Pass(); + + command.ShouldRestore = false; + } + command.Execute().Should().Pass(); var analyzers = command.GetValues();