diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs index 84324d55e852..b18720b282ae 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs @@ -927,6 +927,39 @@ public int GetHashCode((string, NuGetVersion) library) } private void WriteAnalyzers() + { + // Use the decision persisted by restore so build consumes the corresponding lock-file shape. + if (_lockFile.PackageSpec?.RestoreMetadata?.RestoreEnableAnalyzerAssets == true) + { + WriteAnalyzerAssets(); + } + else + { + WriteAnalyzerPackageFiles(); + } + } + + private void WriteAnalyzerAssets() + { + AnalyzerResolver resolver = new(this); + + foreach (LockFileTargetLibrary library in _compileTimeTarget.Libraries) + { + if (!library.IsPackage()) + { + continue; + } + + foreach (LockFileItem asset in library.AnalyzerAssets) + { + resolver.AddAsset(asset, library); + } + + resolver.CompleteLibraryAnalyzers(); + } + } + + private void WriteAnalyzerPackageFiles() { AnalyzerResolver resolver = new(this); @@ -968,10 +1001,12 @@ private void WriteAnalyzers() 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 => @@ -981,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 @@ -995,32 +1032,80 @@ 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) +#if NET + || !compilerName.Span.Equals(_compilerName.AsSpan(), StringComparison.Ordinal)) +#else + || !string.Equals(_compilerName, compilerName.ToString(), StringComparison.Ordinal)) +#endif + { + 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) { fileCompilerVersion = null; @@ -1051,7 +1136,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) { @@ -1065,12 +1150,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/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs index 2d12dd1aa893..71dbfcef633d 100644 --- a/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs +++ b/test/Microsoft.NET.Build.Tasks.Tests/GivenAResolvePackageAssetsTask.cs @@ -76,6 +76,106 @@ 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) + .Should().Equal(AnalyzerAssetPath); + } + + [TestMethod] + public void It_does_not_fall_back_to_package_file_scanning_when_analyzer_assets_are_enabled() + { + ExecuteAnalyzerAssetsTest( + restoreEnableAnalyzerAssets: true, + includeAnalyzerAssetsGroup: false) + .Should().BeEmpty(); + } + + [TestMethod] + public void It_preserves_package_file_analyzer_scanning_when_analyzer_assets_are_disabled() + { + ExecuteAnalyzerAssetsTest( + restoreEnableAnalyzerAssets: false, + includeAnalyzerAssetsGroup: false) + .Should().Equal(AnalyzerAssetPath); + } + + [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_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() + { + // 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_does_not_error_on_duplicate_package_names() { @@ -116,6 +216,528 @@ 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 = 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"); + File.WriteAllText( + projectAssetsJsonPath, + CreateMultiTargetedAnalyzerAssetsJson(projectPath, packagesDir, objDir)); + + ResolvePackageAssets task = CreateAnalyzerAssetsTask( + testRoot, + projectAssetsJsonPath, + projectPath, + targetFramework: "net9.0"); + + task.Execute().Should().BeTrue(); + + GetPackageRelativeAnalyzerPaths(task, packageDirectory) + .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 string[] ExecuteAnalyzerAssetsTest( + bool restoreEnableAnalyzerAssets, + bool includeAnalyzerAssetsGroup) + { + 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 = CreateAnalyzerPackage(packagesDir, AnalyzerAssetPath); + + string projectPath = Path.Combine(testRoot, "test.csproj"); + string projectAssetsJsonPath = Path.Combine(objDir, "project.assets.json"); + File.WriteAllText( + projectAssetsJsonPath, + CreateAnalyzerAssetsJson(projectPath, packagesDir, objDir, includeAnalyzerAssetsGroup, restoreEnableAnalyzerAssets)); + + ResolvePackageAssets task = CreateAnalyzerAssetsTask( + testRoot, + projectAssetsJsonPath, + projectPath); + + task.Execute().Should().BeTrue(); + return GetPackageRelativeAnalyzerPaths(task, packageDirectory); + } + finally + { + try { Directory.Delete(testRoot, true); } catch { } + } + } + + private static ResolvePackageAssets CreateAnalyzerAssetsTask( + string testRoot, + string projectAssetsJsonPath, + string projectPath, + string targetFramework = "net8.0", + string projectLanguage = "C#", + string compilerApiVersion = null) + { + return new ResolvePackageAssets + { + BuildEngine = new MockBuildEngine(), + ProjectAssetsCacheFile = Path.Combine(Path.GetDirectoryName(projectAssetsJsonPath), "project.assets.cache"), + ProjectAssetsFile = projectAssetsJsonPath, + ProjectPath = projectPath, + TargetFramework = targetFramework, + ProjectLanguage = projectLanguage, + CompilerApiVersion = compilerApiVersion, + DotNetAppHostExecutableNameWithoutExtension = "apphost", + DefaultImplicitPackages = "Microsoft.NETCore.App", + DisablePackageAssetsCache = true, + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(testRoot) + }; + } + + private static string CreateAnalyzerPackage(string packagesDir, params string[] analyzerPaths) + { + string packageDirectory = Path.Combine( + packagesDir, + AnalyzerPackageName.ToLowerInvariant(), + AnalyzerPackageVersion); + + foreach (string relativePath in analyzerPaths) + { + string analyzerPath = Path.Combine( + packageDirectory, + relativePath.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}"); + + 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( + 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, + Func analyzerMetadata = 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); + + 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, + analyzerMetadata ?? AnalyzerMetadataJson)); + + ResolvePackageAssets task = CreateAnalyzerAssetsTask( + testRoot, + projectAssetsJsonPath, + projectPath, + projectLanguage: projectLanguage, + compilerApiVersion: compilerApiVersion); + + task.Execute().Should().BeTrue(); + return GetPackageRelativeAnalyzerPaths(task, packageDirectory); + } + finally + { + try { Directory.Delete(testRoot, true); } catch { } + } + } + + private static string CreateMultiAnalyzerAssetsJson( + string projectPath, + string packagesPath, + string outputPath, + string[] analyzerPaths, + bool restoreEnableAnalyzerAssets, + Func analyzerMetadata) + { + string groupEntries = string.Join( + ",\r\n ", + analyzerPaths.Select(path => $@"""{path}"": {{ {analyzerMetadata(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, diff --git a/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs b/test/dotnet.Tests/CommandTests/Build/GivenDotnetBuildBuildsCsproj.cs index 7299b5c935f0..c845b08eb6bb 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; @@ -319,17 +320,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 +348,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); @@ -354,29 +363,69 @@ public void It_resolves_analyzers_targeting_mulitple_roslyn_versions(string comp // 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(); - 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}'."); + 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 _)); + + hasAnalyzerGroup.Should().BeTrue(); } static readonly List nugetRoots = new() @@ -389,7 +438,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, '/');