diff --git a/src/NuGet.Core/NuGet.Build.Tasks.Console/MSBuildStaticGraphRestore.cs b/src/NuGet.Core/NuGet.Build.Tasks.Console/MSBuildStaticGraphRestore.cs index 80af5866a4e..4c715585220 100644 --- a/src/NuGet.Core/NuGet.Build.Tasks.Console/MSBuildStaticGraphRestore.cs +++ b/src/NuGet.Core/NuGet.Build.Tasks.Console/MSBuildStaticGraphRestore.cs @@ -790,7 +790,8 @@ internal static List GetTargetFrameworkInfos(IReadOn PackagesToPrune = prunedReferences, RuntimeIdentifierGraphPath = msBuildProjectInstance.GetProperty(nameof(TargetFrameworkInformation.RuntimeIdentifierGraphPath)), TargetAlias = targetAlias, - Warn = warn + Warn = warn, + RestoreEnableAnalyzerAssets = msBuildProjectInstance.IsPropertyTrue("RestoreEnableAnalyzerAssets") }; targetFrameworkInfos.Add(targetFrameworkInformation); diff --git a/src/NuGet.Core/NuGet.Build.Tasks/NuGet.targets b/src/NuGet.Core/NuGet.Build.Tasks/NuGet.targets index b125016e3a0..38607159b20 100644 --- a/src/NuGet.Core/NuGet.Build.Tasks/NuGet.targets +++ b/src/NuGet.Core/NuGet.Build.Tasks/NuGet.targets @@ -80,6 +80,12 @@ Copyright (c) .NET Foundation. All rights reserved. AND '$(TargetFrameworkIdentifier)' == '.NETCoreApp' AND $([MSBuild]::VersionGreaterThanOrEquals('$(TargetFrameworkVersion)', '10.0'))">all direct + + false + false @@ -1185,6 +1191,7 @@ Copyright (c) .NET Foundation. All rights reserved. $(WindowsTargetPlatformMinVersion) $(RestoreEnablePackagePruning) $(RestorePackagePruningDefault) + $(RestoreEnableAnalyzerAssets) $(NuGetAuditMode) diff --git a/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilder.cs b/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilder.cs index 80572729569..3e6dde8d02c 100644 --- a/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilder.cs +++ b/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilder.cs @@ -155,7 +155,8 @@ public LockFile CreateLockFile(LockFile previousLockFile, var librariesWithWarnings = new HashSet(); - var rootProjectStyle = project.RestoreMetadata?.ProjectStyle ?? ProjectStyle.Unknown; + var restoreMetadata = project.RestoreMetadata; + var rootProjectStyle = restoreMetadata?.ProjectStyle ?? ProjectStyle.Unknown; // Add the targets foreach (var targetGraph in targetGraphs @@ -184,6 +185,9 @@ public LockFile CreateLockFile(LockFile previousLockFile, // Check if warnings should be displayed for the current framework. var tfi = project.GetTargetFramework(targetGraph.Framework); + // Analyzer assets are honored per target framework (gated to .NET 11+ via the opt-in value). + bool restoreEnableAnalyzerAssets = tfi.RestoreEnableAnalyzerAssets; + bool warnForImportsOnGraph = tfi.Warn && (target.TargetFramework is FallbackFramework || target.TargetFramework is AssetTargetFallbackFramework); @@ -238,6 +242,7 @@ public LockFile CreateLockFile(LockFile previousLockFile, dependencyType: includeFlags, targetFrameworkOverride: null, dependencies: graphItem.Data.Dependencies, + restoreEnableAnalyzerAssets: restoreEnableAnalyzerAssets, cache: lockFileBuilderCache); target.Libraries.Add(targetLibrary); @@ -258,6 +263,7 @@ public LockFile CreateLockFile(LockFile previousLockFile, targetFrameworkOverride: nonFallbackFramework, dependencyType: includeFlags, dependencies: graphItem.Data.Dependencies, + restoreEnableAnalyzerAssets: restoreEnableAnalyzerAssets, cache: lockFileBuilderCache); usedFallbackFramework = !targetLibrary.Equals(targetLibraryWithoutFallback); } diff --git a/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilderCache.cs b/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilderCache.cs index 48a00a380af..48d1c364637 100644 --- a/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilderCache.cs +++ b/src/NuGet.Core/NuGet.Commands/RestoreCommand/LockFileBuilderCache.cs @@ -31,7 +31,7 @@ public class LockFileBuilderCache private readonly ConcurrentDictionary, bool)>> _criteriaSets = new(); - private readonly ConcurrentDictionary<(CriteriaKey, string path, string aliases, LibraryIncludeFlags, int dependencyCount), Lazy<(LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework)>> _lockFileTargetLibraryCache = + private readonly ConcurrentDictionary<(CriteriaKey, string path, string aliases, LibraryIncludeFlags, int dependencyCount, bool restoreEnableAnalyzerAssets), Lazy<(LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework)>> _lockFileTargetLibraryCache = new(); /// @@ -106,7 +106,7 @@ public ContentItemCollection GetContentItems(LockFileLibrary library, LocalPacka /// /// Try to get a LockFileTargetLibrary from the cache. /// - internal (LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework) GetLockFileTargetLibrary(RestoreTargetGraph graph, NuGetFramework framework, LocalPackageInfo localPackageInfo, string aliases, LibraryIncludeFlags libraryIncludeFlags, List dependencies, Func<(LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework)> valueFactory) + internal (LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework) GetLockFileTargetLibrary(RestoreTargetGraph graph, NuGetFramework framework, LocalPackageInfo localPackageInfo, string aliases, LibraryIncludeFlags libraryIncludeFlags, List dependencies, bool restoreEnableAnalyzerAssets, Func<(LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework)> valueFactory) { // Comparing RuntimeGraph for equality is very expensive, // so in case of a request where the RuntimeGraph is not empty we avoid using the cache. @@ -116,7 +116,7 @@ public ContentItemCollection GetContentItems(LockFileLibrary library, LocalPacka localPackageInfo = localPackageInfo ?? throw new ArgumentNullException(nameof(localPackageInfo)); var criteriaKey = new CriteriaKey(graph.TargetGraphName, framework); var packagePath = localPackageInfo.ExpandedPath; - return _lockFileTargetLibraryCache.GetOrAdd((criteriaKey, packagePath, aliases, libraryIncludeFlags, dependencies.Count), + return _lockFileTargetLibraryCache.GetOrAdd((criteriaKey, packagePath, aliases, libraryIncludeFlags, dependencies.Count, restoreEnableAnalyzerAssets), key => new Lazy<(LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework)>(valueFactory)).Value; } diff --git a/src/NuGet.Core/NuGet.Commands/RestoreCommand/RestoreCommand.cs b/src/NuGet.Core/NuGet.Commands/RestoreCommand/RestoreCommand.cs index f1aed0e8bcf..24f1b15648a 100644 --- a/src/NuGet.Core/NuGet.Commands/RestoreCommand/RestoreCommand.cs +++ b/src/NuGet.Core/NuGet.Commands/RestoreCommand/RestoreCommand.cs @@ -151,6 +151,15 @@ private readonly Dictionary ExecuteAsync(CancellationToken token) telemetry.TelemetryEvent[UpdatedAssetsFile] = restoreResult._isAssetsFileDirty.Value; telemetry.TelemetryEvent[UpdatedMSBuildFiles] = restoreResult._dirtyMSBuildFiles.Value.Count > 0; + PopulateAnalyzerAssetsTelemetry(telemetry.TelemetryEvent, assetsFile, graphs, _request.Project); return restoreResult; } @@ -408,6 +418,7 @@ private void InitializeTelemetry(TelemetryActivity telemetry, int httpSourcesCou } telemetry.TelemetryEvent[AuditEnabled] = auditEnabled ? "enabled" : "disabled"; + telemetry.TelemetryEvent[AnalyzerAssetsEnabled] = _request.Project.TargetFrameworks.Any(e => e.RestoreEnableAnalyzerAssets); PopulatePruningEnabledTelemetry(_request.Project, telemetry.TelemetryEvent); } @@ -453,6 +464,135 @@ internal static void PopulatePruningEnabledTelemetry(PackageSpec project, Teleme telemetryEvent[PackagePruningFrameworksUnsupportedCount] = pruningNotApplicableCount; } + /// + /// Reports analyzer-asset usage so the impact of enabling RestoreEnableAnalyzerAssets by + /// default can be measured ahead of the rollout. The counts are derived from the resolved dependency + /// graphs and the package file lists, so they are reported on every restore regardless of whether the + /// feature is currently enabled. This lets us see, before flipping the default, how many packages and + /// analyzer assemblies would stop being applied because PrivateAssets/ExcludeAssets would + /// finally be honored. + /// + /// + /// Analyzers are not runtime-identifier specific, so only the target-framework graphs (those with a + /// null runtime identifier) are inspected to avoid counting the same package once per RID. This runs on + /// the full-restore path only (after the no-op short-circuit), where the dependency graphs are available. + /// + private void PopulateAnalyzerAssetsTelemetry(TelemetryEvent telemetryEvent, LockFile assetsFile, List graphs, PackageSpec project) + { + // Count the analyzer assemblies each package contributes, from the always-present libraries section. + var analyzerAssemblyCountByPackage = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (LockFileLibrary library in assetsFile.Libraries) + { + int analyzerAssemblyCount = 0; + foreach (string file in library.Files) + { + if (IsAnalyzerAssemblyPath(file)) + { + analyzerAssemblyCount++; + } + } + + if (analyzerAssemblyCount > 0) + { + analyzerAssemblyCountByPackage[GetAnalyzerPackageKey(library.Name, library.Version)] = analyzerAssemblyCount; + } + } + + int appliedAnalyzerAssemblies = 0; + int excludedAnalyzerAssemblies = 0; + int packagesWithAnalyzers = 0; + int packagesWithExcludedAnalyzers = 0; + int excludedByPrivateAssets = 0; + int excludedByExcludeAssets = 0; + + foreach (RestoreTargetGraph graph in graphs) + { + // Analyzers are not runtime specific; only inspect the target framework graphs. + if (graph.RuntimeIdentifier != null) + { + continue; + } + + Dictionary flattenedFlags = IncludeFlagUtils.FlattenDependencyTypes(_includeFlagGraphs, project, graph); + TargetFrameworkInformation targetFrameworkInformation = project.GetTargetFramework(graph.Framework); + + foreach (GraphItem graphItem in graph.Flattened) + { + LibraryIdentity library = graphItem.Key; + if (library.Type != LibraryType.Package) + { + continue; + } + + if (!analyzerAssemblyCountByPackage.TryGetValue(GetAnalyzerPackageKey(library.Name, library.Version), out int analyzerAssemblyCount)) + { + continue; + } + + packagesWithAnalyzers++; + + if (!flattenedFlags.TryGetValue(library.Name, out LibraryIncludeFlags includeFlags)) + { + includeFlags = ~LibraryIncludeFlags.ContentFiles; + } + + if ((includeFlags & LibraryIncludeFlags.Analyzers) != LibraryIncludeFlags.None) + { + appliedAnalyzerAssemblies += analyzerAssemblyCount; + continue; + } + + // The package contributes analyzers, but they would be filtered out for this project. + excludedAnalyzerAssemblies += analyzerAssemblyCount; + packagesWithExcludedAnalyzers++; + + // Attribute the exclusion: a direct reference whose own IncludeAssets/ExcludeAssets drops + // analyzers is counted separately from analyzers suppressed transitively via PrivateAssets + // (the default for analyzers), since the transitive case is the surprising one for customers. + LibraryDependency directDependency = targetFrameworkInformation?.Dependencies.FirstOrDefault( + dependency => dependency.Name.Equals(library.Name, StringComparison.OrdinalIgnoreCase)); + + bool excludedByOwnAssetsFilter = directDependency != null + && (directDependency.IncludeType & LibraryIncludeFlags.Analyzers) == LibraryIncludeFlags.None; + + if (excludedByOwnAssetsFilter) + { + excludedByExcludeAssets++; + } + else + { + excludedByPrivateAssets++; + } + } + } + + telemetryEvent[AnalyzerAssetsCount] = appliedAnalyzerAssemblies; + telemetryEvent[AnalyzerAssetsExcludedCount] = excludedAnalyzerAssemblies; + telemetryEvent[AnalyzerAssetsPackagesWithAnalyzersCount] = packagesWithAnalyzers; + telemetryEvent[AnalyzerAssetsPackagesWithExcludedAnalyzersCount] = packagesWithExcludedAnalyzers; + telemetryEvent[AnalyzerAssetsExcludedByPrivateAssetsCount] = excludedByPrivateAssets; + telemetryEvent[AnalyzerAssetsExcludedByExcludeAssetsCount] = excludedByExcludeAssets; + } + + private static string GetAnalyzerPackageKey(string id, NuGetVersion version) + { + return id + "/" + version?.ToNormalizedString(); + } + + /// + /// Determines whether a package file path is an analyzer assembly. This intentionally mirrors the + /// detection used by ManagedCodeConventions.ManagedCodePatterns.AnalyzerAssemblies (any '.dll' under + /// 'analyzers/' at any depth, excluding satellite '.resources.dll' assemblies), but as a cheap string + /// check so analyzer packages can be counted from the package file list for telemetry even when analyzer + /// assets are not selected (the feature is off), without allocating a content-item collection per package. + /// + private static bool IsAnalyzerAssemblyPath(string path) + { + return path.StartsWith("analyzers/", StringComparison.Ordinal) + && path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase); + } + private async Task<(RestoreResult, bool, CacheFile)> EvaluateNoOpAsync(TelemetryActivity telemetry, CacheFile cacheFile, Stopwatch restoreTime) { telemetry.StartIntervalMeasure(); diff --git a/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/LockFileUtils.cs b/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/LockFileUtils.cs index 7f604bb7d9c..8314974ac5b 100644 --- a/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/LockFileUtils.cs +++ b/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/LockFileUtils.cs @@ -42,6 +42,7 @@ public static LockFileTargetLibrary CreateLockFileTargetLibrary( dependencyType: dependencyType, targetFrameworkOverride: null, dependencies: null, + restoreEnableAnalyzerAssets: false, cache: new LockFileBuilderCache()); return lockFileTargetLibrary; } @@ -56,62 +57,84 @@ public static LockFileTargetLibrary CreateLockFileTargetLibrary( /// The resolved dependency type. /// The original framework if the asset selection is happening for a fallback framework. /// The dependencies of this package. + /// Whether analyzer assets should be selected for the lock file. /// The lock file build cache. /// The LockFileTargetLibrary, whether a fallback framework criteria was used to select it, the framework selected for compile assets, and the framework selected for runtime assets. internal static (LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework) CreateLockFileTargetLibrary( - string aliases, - LockFileLibrary library, - LocalPackageInfo package, - RestoreTargetGraph targetGraph, - LibraryIncludeFlags dependencyType, - NuGetFramework targetFrameworkOverride, - List dependencies, - LockFileBuilderCache cache) + string aliases, + LockFileLibrary library, + LocalPackageInfo package, + RestoreTargetGraph targetGraph, + LibraryIncludeFlags dependencyType, + NuGetFramework targetFrameworkOverride, + List dependencies, + bool restoreEnableAnalyzerAssets, + LockFileBuilderCache cache) { var runtimeIdentifier = targetGraph.RuntimeIdentifier; var framework = targetFrameworkOverride ?? targetGraph.Framework; - return cache.GetLockFileTargetLibrary(targetGraph, framework, package, aliases, dependencyType, dependencies, - () => - { - LockFileTargetLibrary lockFileLib = null; - NuGetFramework compileAssetFramework = null; - NuGetFramework runtimeAssetFramework = null; - // This will throw an appropriate error if the nuspec is missing - var nuspec = package.Nuspec; + return cache.GetLockFileTargetLibrary( + targetGraph, + framework, + package, + aliases, + dependencyType, + dependencies, + restoreEnableAnalyzerAssets, + CreateLockFileTargetLibraryCore); - List<(List orderedCriteria, bool fallbackUsed)> orderedCriteriaSets = cache.GetLabeledSelectionCriteria(targetGraph, framework); - var contentItems = cache.GetContentItems(library, package); + (LockFileTargetLibrary, bool, NuGetFramework, NuGetFramework) CreateLockFileTargetLibraryCore() + { + LockFileTargetLibrary lockFileLib = null; + NuGetFramework compileAssetFramework = null; + NuGetFramework runtimeAssetFramework = null; + // This will throw an appropriate error if the nuspec is missing + var nuspec = package.Nuspec; + + List<(List orderedCriteria, bool fallbackUsed)> orderedCriteriaSets = cache.GetLabeledSelectionCriteria(targetGraph, framework); + var contentItems = cache.GetContentItems(library, package); - var packageTypes = nuspec.GetPackageTypes().AsList(); - bool fallbackUsed = false; + var packageTypes = nuspec.GetPackageTypes().AsList(); + bool fallbackUsed = false; - for (var i = 0; i < orderedCriteriaSets.Count; i++) + for (var i = 0; i < orderedCriteriaSets.Count; i++) + { + (lockFileLib, compileAssetFramework, runtimeAssetFramework) = CreateLockFileTargetLibrary( + aliases, + library, + package, + targetGraph.Conventions, + dependencyType, + framework, + runtimeIdentifier, + contentItems, + nuspec, + packageTypes, + orderedCriteriaSets[i].orderedCriteria, + restoreEnableAnalyzerAssets); + // Check if compatible assets were found. + // If no compatible assets were found and this is the last check + // continue on with what was given, this will fail in the normal + // compat verification. + if (CompatibilityChecker.HasCompatibleAssets(lockFileLib)) { - (lockFileLib, compileAssetFramework, runtimeAssetFramework) = CreateLockFileTargetLibrary(aliases, library, package, targetGraph.Conventions, dependencyType, - framework, runtimeIdentifier, contentItems, nuspec, packageTypes, orderedCriteriaSets[i].orderedCriteria); - // Check if compatible assets were found. - // If no compatible assets were found and this is the last check - // continue on with what was given, this will fail in the normal - // compat verification. - if (CompatibilityChecker.HasCompatibleAssets(lockFileLib)) - { - fallbackUsed = orderedCriteriaSets[i].fallbackUsed; - // Stop when compatible assets are found. - break; - } + fallbackUsed = orderedCriteriaSets[i].fallbackUsed; + // Stop when compatible assets are found. + break; } + } - // Add dependencies - AddDependencies(dependencies, lockFileLib, framework, nuspec); + // Add dependencies + AddDependencies(dependencies, lockFileLib, framework, nuspec); - // Exclude items - ExcludeItems(lockFileLib, dependencyType); + // Exclude items + ExcludeItems(lockFileLib, dependencyType); - lockFileLib.Freeze(); + lockFileLib.Freeze(); - return (lockFileLib, fallbackUsed, compileAssetFramework, runtimeAssetFramework); - }); + return (lockFileLib, fallbackUsed, compileAssetFramework, runtimeAssetFramework); + } } /// @@ -178,7 +201,8 @@ internal static (LockFileTargetLibrary lockFileLib, NuGetFramework compileAssetF ContentItemCollection contentItems, NuspecReader nuspec, IList packageTypes, - List orderedCriteria) + List orderedCriteria, + bool restoreEnableAnalyzerAssets) { LockFileTargetLibrary lockFileLib = new LockFileTargetLibrary() { @@ -224,6 +248,12 @@ internal static (LockFileTargetLibrary lockFileLib, NuGetFramework compileAssetF contentItems, managedCodeConventions.Patterns.ResourceAssemblies); + // Analyzers + if (restoreEnableAnalyzerAssets) + { + lockFileLib.AnalyzerAssets = GetAnalyzerLockFileItems(contentItems, managedCodeConventions); + } + // Native lockFileLib.NativeLibraries = GetLockFileItems( orderedCriteria, @@ -727,6 +757,108 @@ private static IList GetLockFileItems( return GetLockFileItems(criteria, items, additionalAction: null, out _, patterns); } + /// + /// Create analyzer lock file items for every analyzer assembly in the package. + /// Detection uses the shared analyzer pattern (any '.dll' under + /// 'analyzers/' at any depth, excluding satellite '.resources.dll' assemblies). Each item carries + /// 'codeLanguage' and (when present in the path) 'compilerApiVersion' metadata, mirroring how content + /// files carry 'codeLanguage', so the SDK can select the applicable analyzers from the metadata. + /// + private static IList GetAnalyzerLockFileItems(ContentItemCollection contentItems, ManagedCodeConventions managedCodeConventions) + { + var lockFileItems = new List(); + foreach (ContentItem item in contentItems.FindItems(managedCodeConventions.Patterns.AnalyzerAssemblies)) + { + var lockFileItem = new LockFileItem(item.Path); + (var codeLanguage, var compilerApiVersion) = GetAnalyzerAssetMetadata(item.Path); + + lockFileItem.Properties[LockFileContentFile.CodeLanguageProperty] = codeLanguage; + if (compilerApiVersion != null) + { + lockFileItem.Properties[LockFileItem.CompilerApiVersionProperty] = compilerApiVersion; + } + + lockFileItems.Add(lockFileItem); + } + + lockFileItems.Sort(static (x, y) => string.CompareOrdinal(x.Path, y.Path)); + + return lockFileItems; + } + + /// + /// Derives the analyzer selection metadata by scanning the directory segments of the asset path. + /// The 'codeLanguage' ('cs', 'vb', 'fs') and 'compilerApiVersion' ('roslynX.Y') segments are optional + /// and may appear at any depth and in either order — for example 'analyzers/dotnet/cs/A.dll' or + /// 'analyzers/dotnet/roslyn4.7/cs/A.dll' (the most common real-world layout, where the language follows + /// the compiler version). Each segment is inspected rather than assuming a fixed folder layout. + /// + /// + /// The code language ('cs', 'vb', 'fs', or 'any' when the path has no language segment) and the + /// compiler API version ('roslynX.Y', or null when the path has no compiler version segment). + /// + private static (string CodeLanguage, string CompilerApiVersion) GetAnalyzerAssetMetadata(string path) + { + string codeLanguage = ManagedCodeConventions.PropertyNames.AnyValue; + string compilerApiVersion = null; + + int lastSeparator = path.LastIndexOf(Path.AltDirectorySeparatorChar); + int segmentStart = 0; + while (segmentStart < lastSeparator) + { + int separator = path.IndexOf(Path.AltDirectorySeparatorChar, segmentStart); + int segmentLength = separator - segmentStart; + ReadOnlySpan segment = path.AsSpan(segmentStart, segmentLength); + + // The 'cs'/'vb'/'fs' literals are interned, so comparing against them does not allocate. + if (segment.Equals("cs".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + codeLanguage = "cs"; + } + else if (segment.Equals("vb".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + codeLanguage = "vb"; + } + else if (segment.Equals("fs".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + codeLanguage = "fs"; + } + else if (compilerApiVersion == null && IsCompilerApiVersionSegment(segment)) + { + // The stored string is the single necessary allocation; compiler version segments are + // conventionally already lowercase, so avoid a second allocation from ToLowerInvariant. + string segmentValue = path.Substring(segmentStart, segmentLength); + compilerApiVersion = IsLowerInvariant(segment) ? segmentValue : segmentValue.ToLowerInvariant(); + } + + segmentStart = separator + 1; + } + + return (codeLanguage, compilerApiVersion); + } + + private static bool IsLowerInvariant(ReadOnlySpan segment) + { + foreach (char c in segment) + { + if (char.IsUpper(c)) + { + return false; + } + } + + return true; + } + + private static bool IsCompilerApiVersionSegment(ReadOnlySpan segment) + { + const string roslynPrefix = "roslyn"; + + return segment.Length > roslynPrefix.Length + && segment.StartsWith(roslynPrefix.AsSpan(), StringComparison.OrdinalIgnoreCase) + && char.IsDigit(segment[roslynPrefix.Length]); + } + /// /// Get packageId.targets and packageId.props /// @@ -835,7 +967,7 @@ private static List CreateCriteria( /// Clears a lock file group and replaces the first item with _._ if /// the group has items. Empty groups are left alone. /// - private static void ClearIfExists(IList group, Func factory) where T : LockFileItem + private static void ClearIfExists(IList group, Func factory, bool copyProperties = true) where T : LockFileItem { if (GroupHasNonEmptyItems(group)) { @@ -862,9 +994,12 @@ private static void ClearIfExists(IList group, Func factory) wh var emptyItem = factory(emptyDir); // Copy over the properties from the first - foreach (var pair in firstItem.Properties) + if (copyProperties) { - emptyItem.Properties.Add(pair.Key, pair.Value); + foreach (var pair in firstItem.Properties) + { + emptyItem.Properties.Add(pair.Key, pair.Value); + } } group.Add(emptyItem); @@ -1052,6 +1187,11 @@ public static void ExcludeItems(LockFileTargetLibrary lockFileLib, LibraryInclud ClearIfExists(lockFileLib.NativeLibraries, static path => new LockFileItem(path)); } + if ((dependencyType & LibraryIncludeFlags.Analyzers) == LibraryIncludeFlags.None) + { + ClearIfExists(lockFileLib.AnalyzerAssets, static path => new LockFileItem(path), copyProperties: false); + } + if ((dependencyType & LibraryIncludeFlags.ContentFiles) == LibraryIncludeFlags.None && GroupHasNonEmptyItems(lockFileLib.ContentFiles)) { diff --git a/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/MSBuildRestoreUtility.cs b/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/MSBuildRestoreUtility.cs index af00c7c4e88..1c68c96c047 100644 --- a/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/MSBuildRestoreUtility.cs +++ b/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/MSBuildRestoreUtility.cs @@ -572,7 +572,8 @@ private static IEnumerable GetTargetFrameworkInforma Imports = imports, RuntimeIdentifierGraphPath = runtimeIdentifierGraphPath, TargetAlias = targetAlias, - Warn = warn + Warn = warn, + RestoreEnableAnalyzerAssets = IsPropertyTrue(item, "RestoreEnableAnalyzerAssets") }; yield return targetFrameworkInfo; diff --git a/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/PackageSpecFactory.cs b/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/PackageSpecFactory.cs index 87b488e212f..f10e0593b86 100644 --- a/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/PackageSpecFactory.cs +++ b/src/NuGet.Core/NuGet.Commands/RestoreCommand/Utility/PackageSpecFactory.cs @@ -267,7 +267,8 @@ internal static List GetTargetFrameworkInfos(IProjec PackagesToPrune = prunedReferences, RuntimeIdentifierGraphPath = msBuildProjectInstance.GetProperty(nameof(TargetFrameworkInformation.RuntimeIdentifierGraphPath)), TargetAlias = targetAlias, - Warn = warn + Warn = warn, + RestoreEnableAnalyzerAssets = msBuildProjectInstance.IsPropertyTrue("RestoreEnableAnalyzerAssets") }; targetFrameworkInfos.Add(targetFrameworkInformation); diff --git a/src/NuGet.Core/NuGet.Packaging/ContentModel/ManagedCodeConventions.cs b/src/NuGet.Core/NuGet.Packaging/ContentModel/ManagedCodeConventions.cs index f6425c38a09..2f641b79024 100644 --- a/src/NuGet.Core/NuGet.Packaging/ContentModel/ManagedCodeConventions.cs +++ b/src/NuGet.Core/NuGet.Packaging/ContentModel/ManagedCodeConventions.cs @@ -36,6 +36,10 @@ public class ManagedCodeConventions PropertyNames.CodeLanguage, parser: CodeLanguage_Parser); + private static readonly ContentPropertyDefinition AnalyzerAssemblyProperty = new ContentPropertyDefinition( + PropertyNames.AnalyzerAssembly, + parser: AnalyzerAssembly_Parser); + private static readonly Dictionary NetTFMTable = new Dictionary { { "tfm", new NuGetFramework(FrameworkConstants.FrameworkIdentifiers.Net, FrameworkConstants.EmptyVersion) }, @@ -85,6 +89,7 @@ public ManagedCodeConventions(RuntimeGraph? runtimeGraph) props[MSBuildProperty.Name] = MSBuildProperty; props[SatelliteAssemblyProperty.Name] = SatelliteAssemblyProperty; props[CodeLanguageProperty.Name] = CodeLanguageProperty; + props[AnalyzerAssemblyProperty.Name] = AnalyzerAssemblyProperty; props[PropertyNames.RuntimeIdentifier] = new ContentPropertyDefinition( PropertyNames.RuntimeIdentifier, @@ -296,6 +301,23 @@ private static object IdentityParser(ReadOnlyMemory s, PatternTable? _, bo return null; } + /// + /// Matches an analyzer assembly: a '.dll' (at any depth, so the token is terminal and may receive a + /// multi-segment remainder) excluding satellite '.resources.dll' assemblies. Mirrors the SDK's analyzer + /// detection apart from the 'analyzers/' folder casing, which the content model matches case-insensitively. + /// + private static object? AnalyzerAssembly_Parser(ReadOnlyMemory s, PatternTable? _, bool matchOnly) + { + ReadOnlySpan span = s.Span; + if (span.EndsWith(".dll".AsSpan(), StringComparison.OrdinalIgnoreCase) + && !span.EndsWith(".resources.dll".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return matchOnly ? string.Empty : s.ToString(); + } + + return null; + } + private static bool TargetFrameworkName_CompatibilityTest(object? criteria, object? available) { var criteriaFrameworkName = criteria as NuGetFramework; @@ -472,6 +494,11 @@ public class ManagedCodePatterns /// public PatternSet MSBuildTransitiveFiles { get; } + /// + /// Pattern used to identify analyzer assemblies, at any depth under 'analyzers/'. + /// + public PatternSet AnalyzerAssemblies { get; } + internal ManagedCodePatterns(ManagedCodeConventions conventions) { AnyTargettedFile = new PatternSet( @@ -627,6 +654,17 @@ internal ManagedCodePatterns(ManagedCodeConventions conventions) new PatternDefinition("buildTransitive/{tfm}/{msbuild}", table: DotnetAnyTable), new PatternDefinition("buildTransitive/{msbuild}", table: null, defaults: DefaultTfmAny) }); + + AnalyzerAssemblies = new PatternSet( + conventions.Properties, + groupPatterns: new PatternDefinition[] + { + new PatternDefinition("analyzers/{analyzerAssembly}"), + }, + pathPatterns: new PatternDefinition[] + { + new PatternDefinition("analyzers/{analyzerAssembly}"), + }); } } @@ -640,6 +678,7 @@ public static class PropertyNames public static readonly string MSBuild = "msbuild"; public static readonly string SatelliteAssembly = "satelliteAssembly"; public static readonly string CodeLanguage = "codeLanguage"; + public static readonly string AnalyzerAssembly = "analyzerAssembly"; } } } diff --git a/src/NuGet.Core/NuGet.Packaging/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/NuGet.Core/NuGet.Packaging/PublicAPI/net472/PublicAPI.Unshipped.txt index a57f2f04705..bc86f11b503 100644 --- a/src/NuGet.Core/NuGet.Packaging/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/src/NuGet.Core/NuGet.Packaging/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable +NuGet.Client.ManagedCodeConventions.ManagedCodePatterns.AnalyzerAssemblies.get -> NuGet.ContentModel.PatternSet! NuGet.Packaging.PackageBuilder.DeterministicTimestamp.init -> void static NuGet.Packaging.PackageIdValidator.IsValidPackageId(string! packageId, bool useRestrictedCharacterSet) -> bool +static readonly NuGet.Client.ManagedCodeConventions.PropertyNames.AnalyzerAssembly -> string! diff --git a/src/NuGet.Core/NuGet.Packaging/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/NuGet.Core/NuGet.Packaging/PublicAPI/net8.0/PublicAPI.Unshipped.txt index a57f2f04705..bc86f11b503 100644 --- a/src/NuGet.Core/NuGet.Packaging/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/NuGet.Core/NuGet.Packaging/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable +NuGet.Client.ManagedCodeConventions.ManagedCodePatterns.AnalyzerAssemblies.get -> NuGet.ContentModel.PatternSet! NuGet.Packaging.PackageBuilder.DeterministicTimestamp.init -> void static NuGet.Packaging.PackageIdValidator.IsValidPackageId(string! packageId, bool useRestrictedCharacterSet) -> bool +static readonly NuGet.Client.ManagedCodeConventions.PropertyNames.AnalyzerAssembly -> string! diff --git a/src/NuGet.Core/NuGet.ProjectModel/JsonPackageSpecReader.Utf8JsonStreamReader.cs b/src/NuGet.Core/NuGet.ProjectModel/JsonPackageSpecReader.Utf8JsonStreamReader.cs index 41c2c2cc9a2..d344e5525ee 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/JsonPackageSpecReader.Utf8JsonStreamReader.cs +++ b/src/NuGet.Core/NuGet.ProjectModel/JsonPackageSpecReader.Utf8JsonStreamReader.cs @@ -96,6 +96,7 @@ public partial class JsonPackageSpecReader private static readonly byte[] UsingMicrosoftNETSdk = Encoding.UTF8.GetBytes("UsingMicrosoftNETSdk"); private static readonly byte[] UseLegacyDependencyResolverPropertyName = Encoding.UTF8.GetBytes("restoreUseLegacyDependencyResolver"); private static readonly byte[] RestoreDoNotWriteDependencyGraphSpecPropertyName = Encoding.UTF8.GetBytes("restoreDoNotWriteDependencyGraphSpec"); + private static readonly byte[] RestoreEnableAnalyzerAssetsPropertyName = Encoding.UTF8.GetBytes("restoreEnableAnalyzerAssets"); private static readonly byte[] PackagesToPrunePropertyName = Encoding.UTF8.GetBytes("packagesToPrune"); internal static PackageSpec GetPackageSpecUtf8JsonStreamReader(Stream stream, string name, string packageSpecPath, IEnvironmentVariableReader environmentVariableReader, string snapshotValue = null) @@ -1422,6 +1423,7 @@ private static void ReadTargetFrameworks(PackageSpec packageSpec, ref Utf8JsonSt string runtimeIdentifierGraphPath = null; string targetAlias = string.Empty; bool warn = false; + bool restoreEnableAnalyzerAssets = false; Dictionary packagesToPrune = null; NuGetFramework secondaryFramework = default; @@ -1506,6 +1508,10 @@ private static void ReadTargetFrameworks(PackageSpec packageSpec, ref Utf8JsonSt { warn = jsonReader.ReadNextTokenAsBoolOrFalse(); } + else if (jsonReader.ValueTextEquals(RestoreEnableAnalyzerAssetsPropertyName)) + { + restoreEnableAnalyzerAssets = jsonReader.ReadNextTokenAsBoolOrFalse(); + } else if (jsonReader.ValueTextEquals(PackagesToPrunePropertyName)) { packagesToPrune ??= new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -1532,7 +1538,8 @@ private static void ReadTargetFrameworks(PackageSpec packageSpec, ref Utf8JsonSt RuntimeIdentifierGraphPath = runtimeIdentifierGraphPath, PackagesToPrune = packagesToPrune, TargetAlias = targetAlias, - Warn = warn + Warn = warn, + RestoreEnableAnalyzerAssets = restoreEnableAnalyzerAssets }; if (frameworkName == null) // V3 writers don't set the framework property, so use the key instead. diff --git a/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileFormat.cs b/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileFormat.cs index af8c439606a..ecf83ded40a 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileFormat.cs +++ b/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileFormat.cs @@ -46,6 +46,7 @@ public class LockFileFormat private const string FrameworkAssembliesProperty = "frameworkAssemblies"; private const string RuntimeProperty = "runtime"; private const string CompileProperty = "compile"; + private const string AnalyzersProperty = "analyzers"; private const string NativeProperty = "native"; private const string BuildProperty = "build"; private const string BuildMultiTargetingProperty = "buildMultiTargeting"; @@ -413,6 +414,14 @@ private static void WriteTargetLibrary(JsonWriter writer, LockFileTargetLibrary JsonUtility.WriteObject(writer, ordered, WriteFileItem); } + if (library.AnalyzerAssets.Count > 0) + { + var ordered = library.AnalyzerAssets.OrderBy(assembly => assembly.Path, StringComparer.Ordinal); + + writer.WritePropertyName(AnalyzersProperty); + JsonUtility.WriteObject(writer, ordered, WriteFileItem); + } + if (library.RuntimeAssemblies.Count > 0) { var ordered = library.RuntimeAssemblies.OrderBy(assembly => assembly.Path, StringComparer.Ordinal); diff --git a/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileItem.cs b/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileItem.cs index c28f4569676..4aca429a5a0 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileItem.cs +++ b/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileItem.cs @@ -12,6 +12,7 @@ namespace NuGet.ProjectModel public class LockFileItem : IEquatable { public static readonly string AliasesProperty = "aliases"; + public static readonly string CompilerApiVersionProperty = "compilerApiVersion"; private static readonly object PropertiesLock = new object(); public LockFileItem(string path) diff --git a/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileTargetLibrary.cs b/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileTargetLibrary.cs index 8b0ff70a789..e837ed04a60 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileTargetLibrary.cs +++ b/src/NuGet.Core/NuGet.ProjectModel/LockFile/LockFileTargetLibrary.cs @@ -21,6 +21,7 @@ public class LockFileTargetLibrary : IEquatable private static readonly PropertyKey RuntimeAssembliesKey = new(nameof(RuntimeAssemblies)); private static readonly PropertyKey ResourceAssembliesKey = new(nameof(ResourceAssemblies)); private static readonly PropertyKey CompileTimeAssembliesKey = new(nameof(CompileTimeAssemblies)); + private static readonly PropertyKey AnalyzerAssetsKey = new(nameof(AnalyzerAssets)); private static readonly PropertyKey NativeLibrariesKey = new(nameof(NativeLibraries)); private static readonly PropertyKey BuildKey = new(nameof(Build)); private static readonly PropertyKey BuildMultiTargetingKey = new(nameof(BuildMultiTargeting)); @@ -108,6 +109,12 @@ public IList CompileTimeAssemblies set => SetListProperty(CompileTimeAssembliesKey, value); } + public IList AnalyzerAssets + { + get => GetListProperty(AnalyzerAssetsKey); + set => SetListProperty(AnalyzerAssetsKey, value); + } + public IList NativeLibraries { get => GetListProperty(NativeLibrariesKey); @@ -180,6 +187,7 @@ public bool Equals(LockFileTargetLibrary? other) && IsListOrderedEqual(RuntimeAssembliesKey, static o => o.Path) && IsListOrderedEqual(ResourceAssembliesKey, static o => o.Path) && IsListOrderedEqual(CompileTimeAssembliesKey, static o => o.Path) + && IsListOrderedEqual(AnalyzerAssetsKey, static o => o.Path) && IsListOrderedEqual(NativeLibrariesKey, static o => o.Path) && IsListOrderedEqual(ContentFilesKey, static o => o.Path) && IsListOrderedEqual(RuntimeTargetsKey, static o => o.Path) @@ -221,6 +229,7 @@ public override int GetHashCode() combiner.AddUnorderedSequence(RuntimeAssemblies); combiner.AddUnorderedSequence(ResourceAssemblies); combiner.AddUnorderedSequence(CompileTimeAssemblies); + combiner.AddUnorderedSequence(AnalyzerAssets); combiner.AddUnorderedSequence(NativeLibraries); combiner.AddUnorderedSequence(ContentFiles); combiner.AddUnorderedSequence(RuntimeTargets); diff --git a/src/NuGet.Core/NuGet.ProjectModel/LockFile/Utf8JsonStreamLockFileTargetLibraryConverter.cs b/src/NuGet.Core/NuGet.ProjectModel/LockFile/Utf8JsonStreamLockFileTargetLibraryConverter.cs index 37ec3f6d615..f4cf830d6e2 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/LockFile/Utf8JsonStreamLockFileTargetLibraryConverter.cs +++ b/src/NuGet.Core/NuGet.ProjectModel/LockFile/Utf8JsonStreamLockFileTargetLibraryConverter.cs @@ -28,6 +28,9 @@ namespace NuGet.ProjectModel /// "compile": { /// , /// }, + /// "analyzers": { + /// , + /// }, /// "runtime": { /// , /// }, @@ -59,6 +62,7 @@ internal class Utf8JsonStreamLockFileTargetLibraryConverter : IUtf8JsonStreamRea private static readonly byte[] FrameworkAssembliesPropertyName = Encoding.UTF8.GetBytes("frameworkAssemblies"); private static readonly byte[] RuntimePropertyName = Encoding.UTF8.GetBytes("runtime"); private static readonly byte[] CompilePropertyName = Encoding.UTF8.GetBytes("compile"); + private static readonly byte[] AnalyzersPropertyName = Encoding.UTF8.GetBytes("analyzers"); private static readonly byte[] ResourcePropertyName = Encoding.UTF8.GetBytes("resource"); private static readonly byte[] NativePropertyName = Encoding.UTF8.GetBytes("native"); private static readonly byte[] BuildPropertyName = Encoding.UTF8.GetBytes("build"); @@ -120,6 +124,11 @@ public LockFileTargetLibrary Read(ref Utf8JsonStreamReader reader) reader.Read(); lockFileTargetLibrary.CompileTimeAssemblies = reader.ReadObjectAsList(Utf8JsonStreamLockFileConverters.LockFileItemConverter); } + else if (reader.ValueTextEquals(AnalyzersPropertyName)) + { + reader.Read(); + lockFileTargetLibrary.AnalyzerAssets = reader.ReadObjectAsList(Utf8JsonStreamLockFileConverters.LockFileItemConverter); + } else if (reader.ValueTextEquals(ResourcePropertyName)) { reader.Read(); diff --git a/src/NuGet.Core/NuGet.ProjectModel/PackageSpecWriter.cs b/src/NuGet.Core/NuGet.ProjectModel/PackageSpecWriter.cs index 4d61190932f..c3c45a7e478 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/PackageSpecWriter.cs +++ b/src/NuGet.Core/NuGet.ProjectModel/PackageSpecWriter.cs @@ -539,6 +539,7 @@ private static void SetFrameworks(IObjectWriter writer, IList GetSources(IList sources) diff --git a/src/NuGet.Core/NuGet.ProjectModel/PublicAPI.Unshipped.txt b/src/NuGet.Core/NuGet.ProjectModel/PublicAPI.Unshipped.txt index c80b941a34e..9681c9ba0c3 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/PublicAPI.Unshipped.txt +++ b/src/NuGet.Core/NuGet.ProjectModel/PublicAPI.Unshipped.txt @@ -1,3 +1,8 @@ #nullable enable +~static readonly NuGet.ProjectModel.LockFileItem.CompilerApiVersionProperty -> string +NuGet.ProjectModel.LockFileTargetLibrary.AnalyzerAssets.get -> System.Collections.Generic.IList! +NuGet.ProjectModel.LockFileTargetLibrary.AnalyzerAssets.set -> void NuGet.ProjectModel.ProjectRestoreMetadata.RestoreDoNotWriteDependencyGraphSpec.get -> bool NuGet.ProjectModel.ProjectRestoreMetadata.RestoreDoNotWriteDependencyGraphSpec.set -> void +NuGet.ProjectModel.TargetFrameworkInformation.RestoreEnableAnalyzerAssets.get -> bool +NuGet.ProjectModel.TargetFrameworkInformation.RestoreEnableAnalyzerAssets.init -> void diff --git a/src/NuGet.Core/NuGet.ProjectModel/TargetFrameworkInformation.cs b/src/NuGet.Core/NuGet.ProjectModel/TargetFrameworkInformation.cs index 4dcd7387b3c..61916d8ddd1 100644 --- a/src/NuGet.Core/NuGet.ProjectModel/TargetFrameworkInformation.cs +++ b/src/NuGet.Core/NuGet.ProjectModel/TargetFrameworkInformation.cs @@ -62,6 +62,12 @@ public ImmutableArray Imports /// public bool Warn { get; init; } + /// + /// Whether analyzer assets should be tracked in the assets file for this target framework. + /// Reflects the framework-specific, gated value of RestoreEnableAnalyzerAssets. + /// + public bool RestoreEnableAnalyzerAssets { get; init; } + /// /// List of dependencies that are not part of the graph resolution. /// @@ -138,6 +144,7 @@ public TargetFrameworkInformation(TargetFrameworkInformation cloneFrom) Imports = cloneFrom.Imports; AssetTargetFallback = cloneFrom.AssetTargetFallback; Warn = cloneFrom.Warn; + RestoreEnableAnalyzerAssets = cloneFrom.RestoreEnableAnalyzerAssets; DownloadDependencies = cloneFrom.DownloadDependencies; CentralPackageVersions = cloneFrom.CentralPackageVersions; FrameworkReferences = cloneFrom.FrameworkReferences; @@ -159,6 +166,7 @@ public override int GetHashCode() hashCode.AddUnorderedSequence(Dependencies); hashCode.AddSequence((IReadOnlyList)Imports); hashCode.AddObject(Warn); + hashCode.AddObject(RestoreEnableAnalyzerAssets); hashCode.AddUnorderedSequence(DownloadDependencies); hashCode.AddUnorderedSequence(FrameworkReferences); if (RuntimeIdentifierGraphPath != null) @@ -192,6 +200,7 @@ public bool Equals(TargetFrameworkInformation other) EqualityUtility.OrderedEquals(Dependencies, other.Dependencies, dependency => dependency.Name, StringComparer.OrdinalIgnoreCase) && Imports.SequenceEqualWithNullCheck(other.Imports) && Warn == other.Warn && + RestoreEnableAnalyzerAssets == other.RestoreEnableAnalyzerAssets && AssetTargetFallback == other.AssetTargetFallback && EqualityUtility.OrderedEquals(DownloadDependencies, other.DownloadDependencies, e => e.Name, StringComparer.OrdinalIgnoreCase) && EqualityUtility.OrderedEquals(FrameworkReferences, other.FrameworkReferences, e => e.Name, ComparisonUtility.FrameworkReferenceNameComparer) && diff --git a/test/NuGet.Core.FuncTests/Msbuild.Integration.Test/NuGetPropertyDefaultsTests.cs b/test/NuGet.Core.FuncTests/Msbuild.Integration.Test/NuGetPropertyDefaultsTests.cs index 8bc68e7b02e..0573ab8021e 100644 --- a/test/NuGet.Core.FuncTests/Msbuild.Integration.Test/NuGetPropertyDefaultsTests.cs +++ b/test/NuGet.Core.FuncTests/Msbuild.Integration.Test/NuGetPropertyDefaultsTests.cs @@ -160,5 +160,48 @@ public void PackagePruningDefaults_RestorePackagePruningDefault(string SdkAnalys // Assert resultText.Should().Be(expected); } + + [Theory] + // .NET 11 and greater: opt-in is honored. + [InlineData("net11.0", "true")] + [InlineData("net12.0", "true")] + // Older .NET (Core): opt-in is forced off. + [InlineData("net10.0", "false")] + [InlineData("net9.0", "false")] + [InlineData("net8.0", "false")] + [InlineData("netcoreapp3.1", "false")] + // .NET Standard: not available. + [InlineData("netstandard2.1", "false")] + [InlineData("netstandard2.0", "false")] + // .NET Framework: not available. + [InlineData("net48", "false")] + public void AnalyzerAssetsAvailability_RestoreEnableAnalyzerAssets_OnlyAvailableForNet11OrGreater(string targetFramework, string expected) + { + // Arrange + using var testDirectory = TestDirectory.Create(); + + // The opt-in is set in the project so that the gating in NuGet.targets (imported afterwards) can override it. + string projectText = @" + + true + + +"; + var projectFilePath = Path.Combine(testDirectory, "my.proj"); + File.WriteAllText(projectFilePath, projectText); + + string args = $"{projectFilePath} -getProperty:RestoreEnableAnalyzerAssets"; + + var framework = NuGetFramework.Parse(targetFramework); + args += $" -p:TargetFrameworkIdentifier={framework.Framework}"; + args += $" -p:TargetFrameworkVersion={framework.Version}"; + + // Act + var result = _fixture.RunMsBuild(testDirectory, args); + var resultText = result.Output.Trim(); + + // Assert + resultText.Should().Be(expected); + } } } diff --git a/test/NuGet.Core.Tests/NuGet.Build.Tasks.Console.Test/MSBuildStaticGraphRestoreTests.cs b/test/NuGet.Core.Tests/NuGet.Build.Tasks.Console.Test/MSBuildStaticGraphRestoreTests.cs index 2a981d5f570..f7ce0a7a599 100644 --- a/test/NuGet.Core.Tests/NuGet.Build.Tasks.Console.Test/MSBuildStaticGraphRestoreTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Build.Tasks.Console.Test/MSBuildStaticGraphRestoreTests.cs @@ -1097,6 +1097,42 @@ public void GetTargetFrameworkInfos_WithCustomAliases_InfersCorrectTargetFramewo netTFI.AssetTargetFallback.Should().BeFalse(); } + [Fact] + public void GetTargetFrameworkInfos_WithRestoreEnableAnalyzerAssets_SetsValuePerFramework() + { + // Arrange + var innerNodes = new Dictionary + { + ["net8.0"] = new MockMSBuildProject("Project", + new Dictionary + { + { "TargetFramework", "net8.0" }, + { "TargetFrameworkIdentifier", ".NETCoreApp" }, + { "TargetFrameworkVersion", "v8.0" }, + { "TargetFrameworkMoniker", ".NETCoreApp,Version=v8.0" }, + { "RestoreEnableAnalyzerAssets", "false" }, + }, + new Dictionary>()), + ["net9.0"] = new MockMSBuildProject("Project", + new Dictionary + { + { "TargetFramework", "net9.0" }, + { "TargetFrameworkIdentifier", ".NETCoreApp" }, + { "TargetFrameworkVersion", "v9.0" }, + { "TargetFrameworkMoniker", ".NETCoreApp,Version=v9.0" }, + { "RestoreEnableAnalyzerAssets", "true" }, + }, + new Dictionary>()), + }; + + // Act + var targetFrameworkInfos = MSBuildStaticGraphRestore.GetTargetFrameworkInfos(innerNodes, isCpvmEnabled: false, isPruningEnabledGlobally: false); + + // Assert + targetFrameworkInfos.Single(e => e.TargetAlias == "net8.0").RestoreEnableAnalyzerAssets.Should().BeFalse(); + targetFrameworkInfos.Single(e => e.TargetAlias == "net9.0").RestoreEnableAnalyzerAssets.Should().BeTrue(); + } + [Fact] public void GetTargetFrameworkInfos_WithPrunePackageReferences() { diff --git a/test/NuGet.Core.Tests/NuGet.Commands.Test/MSBuildRestoreUtilityTests.cs b/test/NuGet.Core.Tests/NuGet.Commands.Test/MSBuildRestoreUtilityTests.cs index 3348c662ced..ff5d8714e05 100644 --- a/test/NuGet.Core.Tests/NuGet.Commands.Test/MSBuildRestoreUtilityTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Commands.Test/MSBuildRestoreUtilityTests.cs @@ -988,6 +988,40 @@ public void MSBuildRestoreUtility_GetPackageSpec_NetCoreVerifyIncludeFlags() { "PrivateAssets", "all" }, }); + // A net46 -> AnalyzerInclude + items.Add(new Dictionary() + { + { "Type", "Dependency" }, + { "ProjectUniqueName", "482C20DE-DFF9-4BD0-B90A-BD3201AA351A" }, + { "Id", "analyzerInclude" }, + { "VersionRange", "1.0.0" }, + { "TargetFrameworks", "net46" }, + { "IncludeAssets", "compile;analyzers" }, + { "ExcludeAssets", "compile" }, + }); + + // A net46 -> AnalyzerExclude + items.Add(new Dictionary() + { + { "Type", "Dependency" }, + { "ProjectUniqueName", "482C20DE-DFF9-4BD0-B90A-BD3201AA351A" }, + { "Id", "analyzerExclude" }, + { "VersionRange", "1.0.0" }, + { "TargetFrameworks", "net46" }, + { "ExcludeAssets", "analyzers" }, + }); + + // A net46 -> AnalyzerPrivate + items.Add(new Dictionary() + { + { "Type", "Dependency" }, + { "ProjectUniqueName", "482C20DE-DFF9-4BD0-B90A-BD3201AA351A" }, + { "Id", "analyzerPrivate" }, + { "VersionRange", "1.0.0" }, + { "TargetFrameworks", "net46" }, + { "PrivateAssets", "analyzers" }, + }); + var wrappedItems = items.Select(CreateItems).ToList(); // Act @@ -996,6 +1030,9 @@ public void MSBuildRestoreUtility_GetPackageSpec_NetCoreVerifyIncludeFlags() var x = project1Spec.GetTargetFramework(NuGetFramework.Parse("net46")).Dependencies.Single(e => e.Name == "x"); var y = project1Spec.GetTargetFramework(NuGetFramework.Parse("net46")).Dependencies.Single(e => e.Name == "y"); var z = project1Spec.GetTargetFramework(NuGetFramework.Parse("net46")).Dependencies.Single(e => e.Name == "z"); + var analyzerInclude = project1Spec.GetTargetFramework(NuGetFramework.Parse("net46")).Dependencies.Single(e => e.Name == "analyzerInclude"); + var analyzerExclude = project1Spec.GetTargetFramework(NuGetFramework.Parse("net46")).Dependencies.Single(e => e.Name == "analyzerExclude"); + var analyzerPrivate = project1Spec.GetTargetFramework(NuGetFramework.Parse("net46")).Dependencies.Single(e => e.Name == "analyzerPrivate"); // Assert // X @@ -1009,6 +1046,18 @@ public void MSBuildRestoreUtility_GetPackageSpec_NetCoreVerifyIncludeFlags() // Z Assert.Equal(LibraryIncludeFlags.All, z.IncludeType); Assert.Equal(LibraryIncludeFlags.All, z.SuppressParent); + + // AnalyzerInclude + Assert.Equal(LibraryIncludeFlags.Analyzers, analyzerInclude.IncludeType); + Assert.Equal(LibraryIncludeFlagUtils.DefaultSuppressParent, analyzerInclude.SuppressParent); + + // AnalyzerExclude + Assert.Equal(LibraryIncludeFlags.All & ~LibraryIncludeFlags.Analyzers, analyzerExclude.IncludeType); + Assert.Equal(LibraryIncludeFlagUtils.DefaultSuppressParent, analyzerExclude.SuppressParent); + + // AnalyzerPrivate + Assert.Equal(LibraryIncludeFlags.All, analyzerPrivate.IncludeType); + Assert.Equal(LibraryIncludeFlags.Analyzers, analyzerPrivate.SuppressParent); } } @@ -4919,6 +4968,132 @@ public void MSBuildRestoreUtility_GetPackageSpec_RestoreDoNotWriteDependencyGrap } } + [Fact] + public void GetPackageSpec_WithAnalyzerAssetMetadata_PopulatesTargetFramework() + { + using (var workingDir = TestDirectory.Create()) + { + // Arrange + var projectRoot = Path.Combine(workingDir, "a"); + var projectPath = Path.Combine(projectRoot, "a.csproj"); + + var items = new List> + { + CreateAnalyzerProjectSpecItem(projectPath, targetFrameworks: "net8.0"), + CreateAnalyzerTargetFrameworkInformationItem("net8.0", "8.0", restoreEnableAnalyzerAssets: true), + }; + + // Act + var projectSpec = GetSingleProjectSpec(items); + + // Assert + projectSpec.TargetFrameworks.Single().RestoreEnableAnalyzerAssets.Should().BeTrue(); + } + } + + [Fact] + public void GetPackageSpec_WithProjectSpecAnalyzerAssetsEnabled_EnablesAnalyzerAssets() + { + using (var workingDir = TestDirectory.Create()) + { + // Arrange + var projectRoot = Path.Combine(workingDir, "a"); + var projectPath = Path.Combine(projectRoot, "a.csproj"); + + var items = new List> + { + CreateAnalyzerProjectSpecItem( + projectPath, + targetFrameworks: "net8.0;net9.0", + crossTargeting: true), + CreateAnalyzerTargetFrameworkInformationItem("net8.0", "8.0", restoreEnableAnalyzerAssets: true), + CreateAnalyzerTargetFrameworkInformationItem("net9.0", "9.0", restoreEnableAnalyzerAssets: true), + }; + + // Act + var projectSpec = GetSingleProjectSpec(items); + + // Assert + projectSpec.TargetFrameworks.Should().OnlyContain(f => f.RestoreEnableAnalyzerAssets); + } + } + + [Fact] + public void GetPackageSpec_WithProjectSpecAnalyzerAssetsDisabled_DoesNotEnableAnalyzerAssets() + { + using (var workingDir = TestDirectory.Create()) + { + // Arrange + var projectRoot = Path.Combine(workingDir, "a"); + var projectPath = Path.Combine(projectRoot, "a.csproj"); + + var items = new List> + { + CreateAnalyzerProjectSpecItem( + projectPath, + targetFrameworks: "net8.0;net9.0", + crossTargeting: true), + CreateAnalyzerTargetFrameworkInformationItem("net8.0", "8.0", restoreEnableAnalyzerAssets: false), + CreateAnalyzerTargetFrameworkInformationItem("net9.0", "9.0", restoreEnableAnalyzerAssets: false), + }; + + // Act + var projectSpec = GetSingleProjectSpec(items); + + // Assert + projectSpec.TargetFrameworks.Should().OnlyContain(f => !f.RestoreEnableAnalyzerAssets); + } + } + + private PackageSpec GetSingleProjectSpec(IEnumerable> items) + { + var wrappedItems = items.Select(CreateItems).ToList(); + var dgSpec = MSBuildRestoreUtility.GetDependencySpec(wrappedItems); + + return dgSpec.Projects.Single(); + } + + private static Dictionary CreateAnalyzerProjectSpecItem( + string projectPath, + string targetFrameworks = "net46", + bool crossTargeting = false) + { + var item = new Dictionary() + { + { "Type", "ProjectSpec" }, + { "Version", "2.0.0" }, + { "ProjectName", "a" }, + { "ProjectStyle", "PackageReference" }, + { "ProjectUniqueName", "482C20DE-DFF9-4BD0-B90A-BD3201AA351A" }, + { "ProjectPath", projectPath }, + { "TargetFrameworks", targetFrameworks }, + }; + + if (crossTargeting) + { + item["CrossTargeting"] = "true"; + } + + return item; + } + + private static Dictionary CreateAnalyzerTargetFrameworkInformationItem( + string targetFramework, + string targetFrameworkVersion, + bool restoreEnableAnalyzerAssets = false) + { + return new Dictionary() + { + { "Type", "TargetFrameworkInformation" }, + { "ProjectUniqueName", "482C20DE-DFF9-4BD0-B90A-BD3201AA351A" }, + { "TargetFramework", targetFramework }, + { "TargetFrameworkIdentifier", ".NETCoreApp" }, + { "TargetFrameworkVersion", $"v{targetFrameworkVersion}" }, + { "TargetFrameworkMoniker", $".NETCoreApp,Version=v{targetFrameworkVersion}" }, + { "RestoreEnableAnalyzerAssets", restoreEnableAnalyzerAssets ? "true" : "false" }, + }; + } + private Dictionary WithUniqueName(Dictionary item, string uniqueName) { var newItem = new Dictionary(item, StringComparer.OrdinalIgnoreCase); diff --git a/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/RestoreCommandTests.cs b/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/RestoreCommandTests.cs index d441bf4d737..9cd11d6e27c 100644 --- a/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/RestoreCommandTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/RestoreCommandTests.cs @@ -43,6 +43,1066 @@ public class RestoreCommandTests { private static SignedPackageVerifierSettings DefaultSettings = SignedPackageVerifierSettings.GetDefault(TestEnvironmentVariableReader.EmptyInstance); + [Fact] + public async Task RestoreCommand_WithAnalyzerAssetsEnabled_WritesIdentifiedAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + await result.CommitAsync(logger, CancellationToken.None); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + var analyzerAssets = GetAnalyzerAssetPaths(targetLibrary); + + Assert.Equal( + new[] + { + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/foo/UnknownFolderAnalyzer.dll", + "analyzers/dotnet/fs/FSharpAnalyzer.dll", + "analyzers/dotnet/vb/VisualBasicAnalyzer.dll", + }, + analyzerAssets); + + var assetsFile = File.ReadAllText(result.LockFilePath); + assetsFile.Should().Contain(@"""analyzers"": {"); + + // Each analyzer carries the codeLanguage derived from its path ("any" when language-agnostic), + // mirroring how content files carry codeLanguage metadata. + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/NeutralAnalyzer.dll", codeLanguage: "any"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/cs/CSharpAnalyzer.dll", codeLanguage: "cs"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/foo/UnknownFolderAnalyzer.dll", codeLanguage: "any"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/fs/FSharpAnalyzer.dll", codeLanguage: "fs"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/vb/VisualBasicAnalyzer.dll", codeLanguage: "vb"); + + assetsFile.Should().Contain(@"""codeLanguage"": ""cs"""); + assetsFile.Should().Contain(@"""codeLanguage"": ""any"""); + } + } + + [Fact] + public async Task RestoreCommand_WithAnalyzerAssetsEnabled_EmitsAnalyzerAssetsTelemetryAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + + // Set up telemetry capture *after* package creation, which also emits telemetry. + var telemetryEvents = new ConcurrentQueue(); + var telemetryService = new Mock(MockBehavior.Loose); + telemetryService + .Setup(x => x.EmitTelemetryEvent(It.IsAny())) + .Callback(x => telemetryEvents.Enqueue(x)); + TelemetryActivity.NuGetTelemetryService = telemetryService.Object; + + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var projectInformationEvent = telemetryEvents.Single(e => e.Name.Equals("ProjectRestoreInformation")); + projectInformationEvent["AnalyzerAssets.Enabled"].Should().Be(true); + // The analyzer package ships five analyzer assemblies; satellite and non-analyzer files are excluded. + projectInformationEvent["AnalyzerAssets.Count"].Should().Be(5); + projectInformationEvent["AnalyzerAssets.Excluded.Count"].Should().Be(0); + projectInformationEvent["AnalyzerAssets.PackagesWithAnalyzers.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.PackagesWithExcludedAnalyzers.Count"].Should().Be(0); + projectInformationEvent["AnalyzerAssets.ExcludedByPrivateAssets.Count"].Should().Be(0); + projectInformationEvent["AnalyzerAssets.ExcludedByExcludeAssets.Count"].Should().Be(0); + } + } + + [Fact] + public async Task RestoreCommand_WithAnalyzerAssetsDisabled_StillEmitsAnalyzerAssetsTelemetryAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: false); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + + // Set up telemetry capture *after* package creation, which also emits telemetry. + var telemetryEvents = new ConcurrentQueue(); + var telemetryService = new Mock(MockBehavior.Loose); + telemetryService + .Setup(x => x.EmitTelemetryEvent(It.IsAny())) + .Callback(x => telemetryEvents.Enqueue(x)); + TelemetryActivity.NuGetTelemetryService = telemetryService.Object; + + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var projectInformationEvent = telemetryEvents.Single(e => e.Name.Equals("ProjectRestoreInformation")); + // The feature is off, but the blast-radius counts are still reported so the impact of enabling + // it by default can be measured. Nothing is filtered here, so all five analyzers are "applied". + projectInformationEvent["AnalyzerAssets.Enabled"].Should().Be(false); + projectInformationEvent["AnalyzerAssets.Count"].Should().Be(5); + projectInformationEvent["AnalyzerAssets.PackagesWithAnalyzers.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.Excluded.Count"].Should().Be(0); + projectInformationEvent["AnalyzerAssets.PackagesWithExcludedAnalyzers.Count"].Should().Be(0); + } + } + + [Fact] + public async Task RestoreCommand_WithExcludeAssetsAnalyzers_EmitsExcludedByExcludeAssetsTelemetryAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + includeType: LibraryIncludeFlags.All & ~LibraryIncludeFlags.Analyzers); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + + var telemetryEvents = new ConcurrentQueue(); + var telemetryService = new Mock(MockBehavior.Loose); + telemetryService + .Setup(x => x.EmitTelemetryEvent(It.IsAny())) + .Callback(x => telemetryEvents.Enqueue(x)); + TelemetryActivity.NuGetTelemetryService = telemetryService.Object; + + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var projectInformationEvent = telemetryEvents.Single(e => e.Name.Equals("ProjectRestoreInformation")); + // ExcludeAssets="analyzers" on the project's own reference filters all five analyzers. + projectInformationEvent["AnalyzerAssets.Count"].Should().Be(0); + projectInformationEvent["AnalyzerAssets.Excluded.Count"].Should().Be(5); + projectInformationEvent["AnalyzerAssets.PackagesWithAnalyzers.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.PackagesWithExcludedAnalyzers.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.ExcludedByExcludeAssets.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.ExcludedByPrivateAssets.Count"].Should().Be(0); + } + } + + [Fact] + public async Task RestoreCommand_WithPrivateAssetsAnalyzersOnTransitiveProjectReference_EmitsExcludedByPrivateAssetsTelemetryAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var libraryDirectory = Path.Combine(pathContext.SolutionRoot, "Library"); + Directory.CreateDirectory(libraryDirectory); + + var appDirectory = Path.Combine(pathContext.SolutionRoot, "App"); + Directory.CreateDirectory(appDirectory); + + var librarySpec = CreateAnalyzerPackageSpec( + libraryDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + suppressParent: LibraryIncludeFlags.Analyzers, + projectName: "Library"); + + var appSpec = CreateAnalyzerProjectSpec( + "App", + appDirectory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(librarySpec); + + var libraryLogger = new TestLogger(); + var libraryRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, libraryLogger, librarySpec); + var libraryRestoreCommand = new RestoreCommand(libraryRequest); + + // Restore the library first; only the app restore telemetry is captured below. + var libraryResult = await libraryRestoreCommand.ExecuteAsync(); + libraryResult.Success.Should().BeTrue(because: libraryLogger.ShowMessages()); + + var telemetryEvents = new ConcurrentQueue(); + var telemetryService = new Mock(MockBehavior.Loose); + telemetryService + .Setup(x => x.EmitTelemetryEvent(It.IsAny())) + .Callback(x => telemetryEvents.Enqueue(x)); + TelemetryActivity.NuGetTelemetryService = telemetryService.Object; + + var appLogger = new TestLogger(); + var appRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, appLogger, appSpec, librarySpec); + var appRestoreCommand = new RestoreCommand(appRequest); + + // Act + var appResult = await appRestoreCommand.ExecuteAsync(); + + // Assert + appResult.Success.Should().BeTrue(because: appLogger.ShowMessages()); + + var projectInformationEvent = telemetryEvents.Single(e => e.Name.Equals("ProjectRestoreInformation")); + // The analyzers flow transitively through the Library project reference, where PrivateAssets="analyzers" + // (the default) suppresses them for the consuming App. + projectInformationEvent["AnalyzerAssets.Count"].Should().Be(0); + projectInformationEvent["AnalyzerAssets.Excluded.Count"].Should().Be(5); + projectInformationEvent["AnalyzerAssets.PackagesWithAnalyzers.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.PackagesWithExcludedAnalyzers.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.ExcludedByPrivateAssets.Count"].Should().Be(1); + projectInformationEvent["AnalyzerAssets.ExcludedByExcludeAssets.Count"].Should().Be(0); + } + } + + [Fact] + public async Task RestoreCommand_WithCSharpSourceGeneratorAnalyzerDll_WritesAnalyzerAssetAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + package.AddFile("analyzers/dotnet/cs/SourceGenerator.dll"); + package.AddFile("analyzers/dotnet/vb/VisualBasicSourceGenerator.dll"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + + Assert.Equal( + new[] + { + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/cs/SourceGenerator.dll", + "analyzers/dotnet/foo/UnknownFolderAnalyzer.dll", + "analyzers/dotnet/fs/FSharpAnalyzer.dll", + "analyzers/dotnet/vb/VisualBasicAnalyzer.dll", + "analyzers/dotnet/vb/VisualBasicSourceGenerator.dll", + }, + GetAnalyzerAssetPaths(targetLibrary)); + } + } + + [Fact] + public async Task RestoreCommand_WithAnalyzerAssetsDisabled_DoesNotWriteAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: false); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + await result.CommitAsync(logger, CancellationToken.None); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + Assert.Empty(targetLibrary.AnalyzerAssets); + + var assetsFile = File.ReadAllText(result.LockFilePath); + assetsFile.Should().NotContain(@"""analyzers"": {"); + } + } + + [Fact] + public async Task RestoreCommand_WithExcludeAssetsAnalyzers_WritesAnalyzerAssetsPlaceholderAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + includeType: LibraryIncludeFlags.All & ~LibraryIncludeFlags.Analyzers); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + await result.CommitAsync(logger, CancellationToken.None); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + AssertAnalyzerAssetsExcluded(targetLibrary); + + var assetsFile = File.ReadAllText(result.LockFilePath); + assetsFile.Should().Contain(@"""analyzers"": {"); + assetsFile.Should().Contain(@"""analyzers/dotnet/_._"": {}"); + assetsFile.Should().NotContain(@"""analyzers/dotnet/NeutralAnalyzer.dll"": {}"); + assetsFile.Should().NotContain(@"""analyzers/dotnet/cs/CSharpAnalyzer.dll"": {}"); + } + } + + [Fact] + public async Task RestoreCommand_WithIncludeAssetsCompileAndRuntime_WritesAnalyzerAssetsPlaceholderAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + includeType: LibraryIncludeFlags.Compile | LibraryIncludeFlags.Runtime); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + await result.CommitAsync(logger, CancellationToken.None); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + AssertAnalyzerAssetsExcluded(targetLibrary); + + var assetsFile = File.ReadAllText(result.LockFilePath); + assetsFile.Should().Contain(@"""analyzers"": {"); + assetsFile.Should().Contain(@"""analyzers/dotnet/_._"": {}"); + assetsFile.Should().NotContain(@"""analyzers/dotnet/NeutralAnalyzer.dll"": {}"); + assetsFile.Should().NotContain(@"""analyzers/dotnet/cs/CSharpAnalyzer.dll"": {}"); + } + } + + [Fact] + public async Task RestoreCommand_WithPrivateAssetsAnalyzersOnTransitiveProjectReference_DoesNotFlowAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var libraryDirectory = Path.Combine(pathContext.SolutionRoot, "Library"); + Directory.CreateDirectory(libraryDirectory); + + var appDirectory = Path.Combine(pathContext.SolutionRoot, "App"); + Directory.CreateDirectory(appDirectory); + + var librarySpec = CreateAnalyzerPackageSpec( + libraryDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + suppressParent: LibraryIncludeFlags.Analyzers, + projectName: "Library"); + + var appSpec = CreateAnalyzerProjectSpec( + "App", + appDirectory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(librarySpec); + + var libraryLogger = new TestLogger(); + var libraryRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, libraryLogger, librarySpec); + var libraryRestoreCommand = new RestoreCommand(libraryRequest); + + var appLogger = new TestLogger(); + var appRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, appLogger, appSpec, librarySpec); + var appRestoreCommand = new RestoreCommand(appRequest); + + // Act + var libraryResult = await libraryRestoreCommand.ExecuteAsync(); + var appResult = await appRestoreCommand.ExecuteAsync(); + + // Assert + libraryResult.Success.Should().BeTrue(because: libraryLogger.ShowMessages()); + appResult.Success.Should().BeTrue(because: appLogger.ShowMessages()); + + var libraryTargetLibrary = GetAnalyzerTargetLibrary(libraryResult.LockFile, package.Id); + AssertAnalyzerAssetsSelected(libraryTargetLibrary); + + var appTargetLibrary = GetAnalyzerTargetLibrary(appResult.LockFile, package.Id); + AssertAnalyzerAssetsExcluded(appTargetLibrary); + } + } + + [Fact] + public async Task RestoreCommand_WithTransitiveAnalyzerPackage_WritesTransitiveAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var parentPackage = CreateAnalyzerPackageContext("ParentPackage"); + var transitivePackage = CreateAnalyzerPackageContext("TransitiveAnalyzerPackage"); + parentPackage.Dependencies.Add(transitivePackage); + + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, parentPackage); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, transitivePackage); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + parentPackage.Id, + restoreEnableAnalyzerAssets: true); + + var logger = new TestLogger(); + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var parentTargetLibrary = GetAnalyzerTargetLibrary(result.LockFile, parentPackage.Id); + AssertAnalyzerAssetsSelected(parentTargetLibrary); + + var transitiveTargetLibrary = GetAnalyzerTargetLibrary(result.LockFile, transitivePackage.Id); + AssertAnalyzerAssetsSelected(transitiveTargetLibrary); + } + } + + [Fact] + public async Task RestoreCommand_WithExcludeAssetsAnalyzersOnTransitivePackage_WritesPlaceholdersAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var parentPackage = CreateAnalyzerPackageContext("ParentPackage"); + var transitivePackage = CreateAnalyzerPackageContext("TransitiveAnalyzerPackage"); + parentPackage.Dependencies.Add(transitivePackage); + + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, parentPackage); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, transitivePackage); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + parentPackage.Id, + restoreEnableAnalyzerAssets: true, + includeType: LibraryIncludeFlags.All & ~LibraryIncludeFlags.Analyzers); + + var logger = new TestLogger(); + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var parentTargetLibrary = GetAnalyzerTargetLibrary(result.LockFile, parentPackage.Id); + AssertAnalyzerAssetsExcluded(parentTargetLibrary); + + var transitiveTargetLibrary = GetAnalyzerTargetLibrary(result.LockFile, transitivePackage.Id); + AssertAnalyzerAssetsExcluded(transitiveTargetLibrary); + } + } + + [Fact] + public async Task RestoreCommand_WithAnalyzerPackageThroughProjectReference_DoesNotFlowAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var libraryDirectory = Path.Combine(pathContext.SolutionRoot, "Library"); + Directory.CreateDirectory(libraryDirectory); + + var appDirectory = Path.Combine(pathContext.SolutionRoot, "App"); + Directory.CreateDirectory(appDirectory); + + var librarySpec = CreateAnalyzerPackageSpec( + libraryDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + projectName: "Library"); + + var appSpec = CreateAnalyzerProjectSpec( + "App", + appDirectory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(librarySpec); + + var libraryLogger = new TestLogger(); + var libraryRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, libraryLogger, librarySpec); + var libraryRestoreCommand = new RestoreCommand(libraryRequest); + + var appLogger = new TestLogger(); + var appRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, appLogger, appSpec, librarySpec); + var appRestoreCommand = new RestoreCommand(appRequest); + + // Act + var libraryResult = await libraryRestoreCommand.ExecuteAsync(); + var appResult = await appRestoreCommand.ExecuteAsync(); + + // Assert + libraryResult.Success.Should().BeTrue(because: libraryLogger.ShowMessages()); + appResult.Success.Should().BeTrue(because: appLogger.ShowMessages()); + + var libraryTargetLibrary = GetAnalyzerTargetLibrary(libraryResult.LockFile, package.Id); + AssertAnalyzerAssetsSelected(libraryTargetLibrary); + + var appTargetLibrary = GetAnalyzerTargetLibrary(appResult.LockFile, package.Id); + AssertAnalyzerAssetsExcluded(appTargetLibrary); + } + } + + [Fact] + public async Task RestoreCommand_WithProjectReferencePrivateAssetsWithoutAnalyzers_FlowsAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var libraryDirectory = Path.Combine(pathContext.SolutionRoot, "Library"); + Directory.CreateDirectory(libraryDirectory); + + var appDirectory = Path.Combine(pathContext.SolutionRoot, "App"); + Directory.CreateDirectory(appDirectory); + + var nonAnalyzerPrivateAssets = LibraryIncludeFlags.Build | LibraryIncludeFlags.ContentFiles; + var librarySpec = CreateAnalyzerPackageSpec( + libraryDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + suppressParent: nonAnalyzerPrivateAssets, + projectName: "Library"); + + var appSpec = CreateAnalyzerProjectSpec( + "App", + appDirectory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(librarySpec, nonAnalyzerPrivateAssets); + + var libraryLogger = new TestLogger(); + var libraryRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, libraryLogger, librarySpec); + var libraryRestoreCommand = new RestoreCommand(libraryRequest); + + var appLogger = new TestLogger(); + var appRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, appLogger, appSpec, librarySpec); + var appRestoreCommand = new RestoreCommand(appRequest); + + // Act + var libraryResult = await libraryRestoreCommand.ExecuteAsync(); + var appResult = await appRestoreCommand.ExecuteAsync(); + + // Assert + libraryResult.Success.Should().BeTrue(because: libraryLogger.ShowMessages()); + appResult.Success.Should().BeTrue(because: appLogger.ShowMessages()); + + var libraryTargetLibrary = GetAnalyzerTargetLibrary(libraryResult.LockFile, package.Id); + AssertAnalyzerAssetsSelected(libraryTargetLibrary); + + var appTargetLibrary = GetAnalyzerTargetLibrary(appResult.LockFile, package.Id); + AssertAnalyzerAssetsSelected(appTargetLibrary); + } + } + + [Fact] + public async Task RestoreCommand_WithProjectReferencePackageExcludeAssetsAnalyzers_WritesPlaceholdersAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var libraryDirectory = Path.Combine(pathContext.SolutionRoot, "Library"); + Directory.CreateDirectory(libraryDirectory); + + var appDirectory = Path.Combine(pathContext.SolutionRoot, "App"); + Directory.CreateDirectory(appDirectory); + + var librarySpec = CreateAnalyzerPackageSpec( + libraryDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + includeType: LibraryIncludeFlags.All & ~LibraryIncludeFlags.Analyzers, + projectName: "Library"); + + var appSpec = CreateAnalyzerProjectSpec( + "App", + appDirectory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(librarySpec); + + var libraryLogger = new TestLogger(); + var libraryRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, libraryLogger, librarySpec); + var libraryRestoreCommand = new RestoreCommand(libraryRequest); + + var appLogger = new TestLogger(); + var appRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, appLogger, appSpec, librarySpec); + var appRestoreCommand = new RestoreCommand(appRequest); + + // Act + var libraryResult = await libraryRestoreCommand.ExecuteAsync(); + var appResult = await appRestoreCommand.ExecuteAsync(); + + // Assert + libraryResult.Success.Should().BeTrue(because: libraryLogger.ShowMessages()); + appResult.Success.Should().BeTrue(because: appLogger.ShowMessages()); + + var libraryTargetLibrary = GetAnalyzerTargetLibrary(libraryResult.LockFile, package.Id); + AssertAnalyzerAssetsExcluded(libraryTargetLibrary); + + var appTargetLibrary = GetAnalyzerTargetLibrary(appResult.LockFile, package.Id); + AssertAnalyzerAssetsExcluded(appTargetLibrary); + } + } + + [Fact] + public async Task RestoreCommand_WithCompilerVersionAnalyzers_FlowsCompilerApiVersionMetadataAcrossProjectReferenceAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var package = new SimpleTestPackageContext("CompilerAnalyzerPackage") + { + UseDefaultRuntimeAssemblies = false, + }; + package.AddFile("analyzers/dotnet/cs/CSharpAnalyzer.dll"); + package.AddFile("analyzers/dotnet/roslyn4.0/cs/RoslynAnalyzer.dll"); + package.AddFile("lib/netstandard2.0/Package.dll"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var libraryDirectory = Path.Combine(pathContext.SolutionRoot, "Library"); + Directory.CreateDirectory(libraryDirectory); + + var appDirectory = Path.Combine(pathContext.SolutionRoot, "App"); + Directory.CreateDirectory(appDirectory); + + // PrivateAssets without analyzers so the analyzers flow across the project reference. + var nonAnalyzerPrivateAssets = LibraryIncludeFlags.Build | LibraryIncludeFlags.ContentFiles; + var librarySpec = CreateAnalyzerPackageSpec( + libraryDirectory, + package.Id, + restoreEnableAnalyzerAssets: true, + suppressParent: nonAnalyzerPrivateAssets, + projectName: "Library"); + + var appSpec = CreateAnalyzerProjectSpec( + "App", + appDirectory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(librarySpec, nonAnalyzerPrivateAssets); + + var libraryLogger = new TestLogger(); + var libraryRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, libraryLogger, librarySpec); + var libraryRestoreCommand = new RestoreCommand(libraryRequest); + + var appLogger = new TestLogger(); + var appRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, appLogger, appSpec, librarySpec); + var appRestoreCommand = new RestoreCommand(appRequest); + + // Act + var libraryResult = await libraryRestoreCommand.ExecuteAsync(); + var appResult = await appRestoreCommand.ExecuteAsync(); + + // Assert + libraryResult.Success.Should().BeTrue(because: libraryLogger.ShowMessages()); + appResult.Success.Should().BeTrue(because: appLogger.ShowMessages()); + + // The compiler-version metadata is preserved on the analyzers that flow into the consuming project. + var appTargetLibrary = GetAnalyzerTargetLibrary(appResult.LockFile, package.Id); + Assert.Equal( + new[] + { + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/RoslynAnalyzer.dll", + }, + GetAnalyzerAssetPaths(appTargetLibrary)); + AssertAnalyzerMetadata(appTargetLibrary, "analyzers/dotnet/cs/CSharpAnalyzer.dll", codeLanguage: "cs"); + AssertAnalyzerMetadata(appTargetLibrary, "analyzers/dotnet/roslyn4.0/cs/RoslynAnalyzer.dll", codeLanguage: "cs", compilerApiVersion: "roslyn4.0"); + } + } + + [Fact] + public async Task RestoreCommand_WithThreeHopProjectReferenceChain_FlowsAnalyzerAssetsToRootAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + // App -> Library2 -> Library1 -> analyzer package + var package = CreateAnalyzerPackageContext("AnalyzerPackage"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var library1Directory = Path.Combine(pathContext.SolutionRoot, "Library1"); + var library2Directory = Path.Combine(pathContext.SolutionRoot, "Library2"); + var appDirectory = Path.Combine(pathContext.SolutionRoot, "App"); + Directory.CreateDirectory(library1Directory); + Directory.CreateDirectory(library2Directory); + Directory.CreateDirectory(appDirectory); + + // PrivateAssets without analyzers at every hop so the analyzers flow all the way to the root. + var nonAnalyzerPrivateAssets = LibraryIncludeFlags.Build | LibraryIncludeFlags.ContentFiles; + + var library1Spec = CreateAnalyzerPackageSpec( + library1Directory, + package.Id, + restoreEnableAnalyzerAssets: true, + suppressParent: nonAnalyzerPrivateAssets, + projectName: "Library1"); + + var library2Spec = CreateAnalyzerProjectSpec( + "Library2", + library2Directory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(library1Spec, nonAnalyzerPrivateAssets); + + var appSpec = CreateAnalyzerProjectSpec( + "App", + appDirectory, + ImmutableArray.Empty, + restoreEnableAnalyzerAssets: true) + .WithTestProjectReference(library2Spec, nonAnalyzerPrivateAssets); + + var appLogger = new TestLogger(); + var appRequest = ProjectTestHelpers.CreateRestoreRequest(pathContext, appLogger, appSpec, library2Spec, library1Spec); + var appRestoreCommand = new RestoreCommand(appRequest); + + // Act + var appResult = await appRestoreCommand.ExecuteAsync(); + + // Assert + appResult.Success.Should().BeTrue(because: appLogger.ShowMessages()); + + // The analyzer package is a 3-hop transitive dependency; its analyzers (with metadata) reach the root. + var appTargetLibrary = GetAnalyzerTargetLibrary(appResult.LockFile, package.Id); + AssertAnalyzerAssetsSelected(appTargetLibrary); + } + } + + [Fact] + public async Task RestoreCommand_WithAnalyzerTargetFrameworkAndArchitectureSegments_WritesIdentifiedAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = new SimpleTestPackageContext("AnalyzerPackage") + { + UseDefaultRuntimeAssemblies = false, + }; + package.AddFile("analyzers/dotnet8.0/TargetFrameworkAnalyzer.dll"); + package.AddFile("analyzers/dotnet8.0/cs/TargetFrameworkCSharpAnalyzer.dll"); + package.AddFile("analyzers/dotnet8.0/x64/TargetFrameworkArchitectureAnalyzer.dll"); + package.AddFile("analyzers/dotnet8.0/x64/cs/TargetFrameworkArchitectureCSharpAnalyzer.dll"); + package.AddFile("analyzers/dotnet/roslyn4.0/cs/CompilerApiVersionCSharpAnalyzer.dll"); + package.AddFile("analyzers/dotnet/x64/ArchitectureAnalyzer.dll"); + package.AddFile("analyzers/dotnet/x64/cs/ArchitectureCSharpAnalyzer.dll"); + package.AddFile("analyzers/dotnet/x64/vb/ArchitectureVisualBasicAnalyzer.dll"); + package.AddFile("analyzers/dotnet8.x/InvalidTargetFrameworkAnalyzer.dll"); + package.AddFile("analyzers/dotnet/foo/InvalidArchitectureAnalyzer.dll"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + + Assert.Equal( + new[] + { + "analyzers/dotnet/foo/InvalidArchitectureAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/CompilerApiVersionCSharpAnalyzer.dll", + "analyzers/dotnet/x64/ArchitectureAnalyzer.dll", + "analyzers/dotnet/x64/cs/ArchitectureCSharpAnalyzer.dll", + "analyzers/dotnet/x64/vb/ArchitectureVisualBasicAnalyzer.dll", + "analyzers/dotnet8.0/TargetFrameworkAnalyzer.dll", + "analyzers/dotnet8.0/cs/TargetFrameworkCSharpAnalyzer.dll", + "analyzers/dotnet8.0/x64/TargetFrameworkArchitectureAnalyzer.dll", + "analyzers/dotnet8.0/x64/cs/TargetFrameworkArchitectureCSharpAnalyzer.dll", + "analyzers/dotnet8.x/InvalidTargetFrameworkAnalyzer.dll", + }, + GetAnalyzerAssetPaths(targetLibrary)); + + // The compiler version ('roslynX.Y') and language segments are captured as metadata. + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/roslyn4.0/cs/CompilerApiVersionCSharpAnalyzer.dll", codeLanguage: "cs", compilerApiVersion: "roslyn4.0"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/x64/cs/ArchitectureCSharpAnalyzer.dll", codeLanguage: "cs"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/x64/vb/ArchitectureVisualBasicAnalyzer.dll", codeLanguage: "vb"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/foo/InvalidArchitectureAnalyzer.dll", codeLanguage: "any"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet8.0/TargetFrameworkAnalyzer.dll", codeLanguage: "any"); + } + } + + [Fact] + public async Task RestoreCommand_WithAnalyzerTargetOmitted_WritesIdentifiedAnalyzerAssetsAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = new SimpleTestPackageContext("AnalyzerPackage") + { + UseDefaultRuntimeAssemblies = false, + }; + package.AddFile("analyzers/NeutralAnalyzer.dll"); + package.AddFile("analyzers/x64/ArchitectureAnalyzer.dll"); + package.AddFile("analyzers/x64/cs/ArchitectureCSharpAnalyzer.dll"); + package.AddFile("analyzers/cs/CSharpAnalyzer.dll"); + package.AddFile("analyzers/vb/VisualBasicAnalyzer.dll"); + package.AddFile("analyzers/cs/CSharpAnalyzer.resources.dll"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + + Assert.Equal( + new[] + { + "analyzers/NeutralAnalyzer.dll", + "analyzers/cs/CSharpAnalyzer.dll", + "analyzers/vb/VisualBasicAnalyzer.dll", + "analyzers/x64/ArchitectureAnalyzer.dll", + "analyzers/x64/cs/ArchitectureCSharpAnalyzer.dll", + }, + GetAnalyzerAssetPaths(targetLibrary)); + } + } + + [Fact] + public async Task RestoreCommand_WithDeeplyNestedAnalyzers_WritesAllAnalyzerAssetsRegardlessOfDepthAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = new SimpleTestPackageContext("AnalyzerPackage") + { + UseDefaultRuntimeAssemblies = false, + }; + package.AddFile("analyzers/dotnet/cs/Analyzer.dll"); + package.AddFile("analyzers/dotnet/roslyn4.0/x64/cs/nested/TooDeepAnalyzer.dll"); + package.AddFile("analyzers/a/b/c/d/e/f/g/VeryDeepAnalyzer.dll"); + package.AddFile("analyzers/dotnet/cs/nested/Localized.resources.dll"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + + // All analyzer assemblies are included at any depth (matching the SDK), while satellite + // '.resources.dll' assemblies are excluded. + Assert.Equal( + new[] + { + "analyzers/a/b/c/d/e/f/g/VeryDeepAnalyzer.dll", + "analyzers/dotnet/cs/Analyzer.dll", + "analyzers/dotnet/roslyn4.0/x64/cs/nested/TooDeepAnalyzer.dll", + }, + GetAnalyzerAssetPaths(targetLibrary)); + } + } + + [Fact] + public async Task RestoreCommand_WithAnalyzerLikePathsOutsideAnalyzersFolder_AreNotSelectedAsync() + { + // Arrange + using (var pathContext = new SimpleTestPathContext()) + { + var logger = new TestLogger(); + var package = new SimpleTestPackageContext("AnalyzerPackage") + { + UseDefaultRuntimeAssemblies = false, + }; + // Only assemblies under the 'analyzers/' directory are analyzers. A root-level 'analyzers.dll' + // and an unrelated 'analyzersfoo/' directory must not be mistaken for analyzer assets. + package.AddFile("analyzers.dll"); + package.AddFile("analyzersfoo/NotAnAnalyzer.dll"); + package.AddFile("analyzers/dotnet/cs/RealAnalyzer.dll"); + await SimpleTestPackageUtility.CreateFullPackageAsync(pathContext.PackageSource, package); + + var projectDirectory = Path.Combine(pathContext.SolutionRoot, "AnalyzerProject"); + Directory.CreateDirectory(projectDirectory); + + var packageSpec = CreateAnalyzerPackageSpec( + projectDirectory, + package.Id, + restoreEnableAnalyzerAssets: true); + + var request = ProjectTestHelpers.CreateRestoreRequest(pathContext, logger, packageSpec); + var restoreCommand = new RestoreCommand(request); + + // Act + var result = await restoreCommand.ExecuteAsync(); + + // Assert + result.Success.Should().BeTrue(because: logger.ShowMessages()); + + var targetLibrary = GetAnalyzerTargetLibrary(result.LockFile, package.Id); + + Assert.Equal( + new[] + { + "analyzers/dotnet/cs/RealAnalyzer.dll", + }, + GetAnalyzerAssetPaths(targetLibrary)); + } + } + [Fact] public async Task RestoreCommand_VerifyRuntimeSpecificAssetsAreNotIncludedForCompile_RuntimeOnlyAsync() { @@ -3054,6 +4114,13 @@ await SimpleTestPackageUtility.CreateFolderFeedV3Async( ["LocalSourcesCount"] = value => value.Should().Be(1), ["FallbackFoldersCount"] = value => value.Should().Be(0), ["Audit.Enabled"] = value => value.Should().Be("enabled"), + ["AnalyzerAssets.Enabled"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.Excluded.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.PackagesWithAnalyzers.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.PackagesWithExcludedAnalyzers.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.ExcludedByPrivateAssets.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.ExcludedByExcludeAssets.Count"] = value => value.Should().BeOfType(), ["Audit.Level"] = value => value.Should().Be(0), ["Audit.Mode"] = value => value.Should().Be("Unknown"), ["Audit.SuppressedAdvisories.Defined.Count"] = value => value.Should().Be(1), @@ -3282,6 +4349,7 @@ await SimpleTestPackageUtility.CreateFolderFeedV3Async( ["FallbackFoldersCount"] = value => value.Should().Be(0), ["IsLockFileEnabled"] = value => value.Should().Be(false), ["NoOpCacheFileAgeDays"] = value => value.Should().NotBeNull(), + ["AnalyzerAssets.Enabled"] = value => value.Should().BeOfType(), ["UseLegacyDependencyResolver"] = value => value.Should().BeOfType(), ["UsedLegacyDependencyResolver"] = value => value.Should().BeOfType(), ["Audit.Enabled"] = value => value.Should().BeOfType(), @@ -3386,6 +4454,13 @@ await SimpleTestPackageUtility.CreateFolderFeedV3Async( ["LockFileEvaluationResult"] = value => value.Should().Be(true), ["NoOpDuration"] = value => value.Should().NotBeNull(), ["TotalUniquePackagesCount"] = value => value.Should().Be(2), + ["AnalyzerAssets.Enabled"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.Excluded.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.PackagesWithAnalyzers.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.PackagesWithExcludedAnalyzers.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.ExcludedByPrivateAssets.Count"] = value => value.Should().BeOfType(), + ["AnalyzerAssets.ExcludedByExcludeAssets.Count"] = value => value.Should().BeOfType(), ["NewPackagesInstalledCount"] = value => value.Should().Be(1), ["AnyPackageIdContainsNonAlphanumericDotDashOrUnderscoreCharacters"] = value => value.Should().Be(false), ["EvaluateLockFileDuration"] = value => value.Should().NotBeNull(), @@ -3768,6 +4843,136 @@ public void CreateFrameworkRuntimeDefinitions_ReturnsPairsInExpectedOrder() pairs[5].RuntimeIdentifier.Should().Be("win-x64"); } + private static SimpleTestPackageContext CreateAnalyzerPackageContext(string packageId) + { + var package = new SimpleTestPackageContext(packageId) + { + UseDefaultRuntimeAssemblies = false, + }; + + package.AddFile("analyzers/dotnet/NeutralAnalyzer.dll"); + package.AddFile("analyzers/dotnet/cs/CSharpAnalyzer.dll"); + package.AddFile("analyzers/dotnet/vb/VisualBasicAnalyzer.dll"); + package.AddFile("analyzers/dotnet/fs/FSharpAnalyzer.dll"); + package.AddFile("analyzers/dotnet/cs/CSharpAnalyzer.resources.dll"); + package.AddFile("analyzers/dotnet/cs/NotAnAnalyzer.exe"); + package.AddFile("analyzers/dotnet/cs/NotAnAnalyzer.winmd"); + package.AddFile("analyzers/dotnet/foo/UnknownFolderAnalyzer.dll"); + package.AddFile("lib/netstandard2.0/Package.dll"); + + return package; + } + + private static PackageSpec CreateAnalyzerPackageSpec( + string projectDirectory, + string packageId, + bool restoreEnableAnalyzerAssets, + LibraryIncludeFlags includeType = LibraryIncludeFlags.All, + LibraryIncludeFlags? suppressParent = null, + string projectName = "AnalyzerProject") + { + return CreateAnalyzerProjectSpec( + projectName, + projectDirectory, + [ + CreateAnalyzerPackageDependency(packageId, includeType, suppressParent), + ], + restoreEnableAnalyzerAssets); + } + + private static PackageSpec CreateAnalyzerProjectSpec( + string projectName, + string projectDirectory, + ImmutableArray dependencies, + bool restoreEnableAnalyzerAssets) + { + var packageSpec = PackageReferenceSpecBuilder.Create(projectName, projectDirectory) + .WithTargetFrameworks( + [ + new TargetFrameworkInformation + { + FrameworkName = NuGetFramework.Parse("netstandard2.0"), + Dependencies = dependencies, + RestoreEnableAnalyzerAssets = restoreEnableAnalyzerAssets, + }, + ]) + .Build() + .WithTestRestoreMetadata(); + + return packageSpec; + } + + private static LibraryDependency CreateAnalyzerPackageDependency( + string packageId, + LibraryIncludeFlags includeType, + LibraryIncludeFlags? suppressParent) + { + return new LibraryDependency + { + LibraryRange = new LibraryRange( + packageId, + VersionRange.Parse("1.0.0"), + LibraryDependencyTarget.All), + IncludeType = includeType, + SuppressParent = suppressParent ?? LibraryIncludeFlagUtils.DefaultSuppressParent, + }; + } + + private static List GetAnalyzerAssetPaths(LockFileTargetLibrary targetLibrary) + { + return targetLibrary.AnalyzerAssets + .Select(item => item.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + } + + private static void AssertAnalyzerMetadata(LockFileTargetLibrary targetLibrary, string path, string codeLanguage, string compilerApiVersion = null) + { + var item = targetLibrary.AnalyzerAssets.Single(a => a.Path == path); + + item.Properties.TryGetValue("codeLanguage", out var actualCodeLanguage); + actualCodeLanguage.Should().Be(codeLanguage, because: $"{path} should report its code language"); + + item.Properties.TryGetValue("compilerApiVersion", out var actualCompilerApiVersion); + actualCompilerApiVersion.Should().Be(compilerApiVersion, because: $"{path} should report its compiler API version"); + } + + private static void AssertAnalyzerAssetsSelected(LockFileTargetLibrary targetLibrary) + { + Assert.Equal( + new[] + { + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/foo/UnknownFolderAnalyzer.dll", + "analyzers/dotnet/fs/FSharpAnalyzer.dll", + "analyzers/dotnet/vb/VisualBasicAnalyzer.dll", + }, + GetAnalyzerAssetPaths(targetLibrary)); + + // Selected analyzers (including ones that flow across project references) carry their metadata. + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/NeutralAnalyzer.dll", codeLanguage: "any"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/cs/CSharpAnalyzer.dll", codeLanguage: "cs"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/foo/UnknownFolderAnalyzer.dll", codeLanguage: "any"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/fs/FSharpAnalyzer.dll", codeLanguage: "fs"); + AssertAnalyzerMetadata(targetLibrary, "analyzers/dotnet/vb/VisualBasicAnalyzer.dll", codeLanguage: "vb"); + } + + private static void AssertAnalyzerAssetsExcluded(LockFileTargetLibrary targetLibrary) + { + Assert.Equal( + new[] + { + "analyzers/dotnet/_._", + }, + GetAnalyzerAssetPaths(targetLibrary)); + } + + private static LockFileTargetLibrary GetAnalyzerTargetLibrary(LockFile lockFile, string packageId) + { + return lockFile.Targets.Single().Libraries.Single(library => library.Name == packageId); + } + private static TargetFrameworkInformation CreateTargetFrameworkInformation(ImmutableArray dependencies, List centralVersionsDependencies, NuGetFramework framework = null) { NuGetFramework nugetFramework = framework ?? new NuGetFramework("net40"); diff --git a/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/Utility/PackageSpecFactoryTests.cs b/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/Utility/PackageSpecFactoryTests.cs index a5a56889268..87d4fd85656 100644 --- a/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/Utility/PackageSpecFactoryTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Commands.Test/RestoreCommandTests/Utility/PackageSpecFactoryTests.cs @@ -1,7 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; +using System.Linq; using FluentAssertions; +using NuGet.LibraryModel; using NuGet.ProjectManagement; using Test.Utility; using Xunit; @@ -58,4 +61,132 @@ public void GetPackageSpec_CpmEnabledByCorrectPropertyForBuildContext( // Assert packageSpec.RestoreMetadata.CentralPackageVersionsEnabled.Should().Be(expectedCpmEnabled); } + + [Fact] + public void GetPackageSpec_WithAnalyzerAssetsEnabled_PopulatesTargetFramework() + { + // Arrange + var factory = new TestPackageSpecFactory(outerBuild => + { + outerBuild.WithProperty("TargetFramework", "net8.0"); + outerBuild.WithProperty("RestoreEnableAnalyzerAssets", "true"); + outerBuild.WithItem("PackageReference", "SomePackage", null); + }); + + // Act + var packageSpec = factory.Build(); + + // Assert + packageSpec.TargetFrameworks.Single().RestoreEnableAnalyzerAssets.Should().BeTrue(); + } + + [Fact] + public void GetPackageSpec_WithAnalyzerAssetsEnabledInInnerBuild_PopulatesThatTargetFrameworkOnly() + { + // Arrange + var factory = new TestPackageSpecFactory(outerBuild => + { + outerBuild.WithProperty("TargetFrameworks", "net8.0;net9.0"); + outerBuild.WithProperty("RestoreEnableAnalyzerAssets", "false"); + outerBuild.WithItem("PackageReference", "SomePackage", null); + }) + .WithInnerBuild(innerBuild => + { + innerBuild.WithProperty("TargetFramework", "net8.0"); + innerBuild.WithProperty("RestoreEnableAnalyzerAssets", "false"); + }) + .WithInnerBuild(innerBuild => + { + innerBuild.WithProperty("TargetFramework", "net9.0"); + innerBuild.WithProperty("RestoreEnableAnalyzerAssets", "true"); + }); + + // Act + var packageSpec = factory.Build(); + + // Assert + packageSpec.TargetFrameworks.Single(f => f.FrameworkName.GetShortFolderName() == "net8.0").RestoreEnableAnalyzerAssets.Should().BeFalse(); + packageSpec.TargetFrameworks.Single(f => f.FrameworkName.GetShortFolderName() == "net9.0").RestoreEnableAnalyzerAssets.Should().BeTrue(); + } + + [Fact] + public void GetPackageSpec_WithAnalyzerAssetsDisabledInAllInnerBuilds_DoesNotPopulateAnyTargetFramework() + { + // Arrange + var factory = new TestPackageSpecFactory(outerBuild => + { + outerBuild.WithProperty("TargetFrameworks", "net8.0;net9.0"); + outerBuild.WithProperty("RestoreEnableAnalyzerAssets", "true"); + outerBuild.WithItem("PackageReference", "SomePackage", null); + }) + .WithInnerBuild(innerBuild => + { + innerBuild.WithProperty("TargetFramework", "net8.0"); + innerBuild.WithProperty("RestoreEnableAnalyzerAssets", "false"); + }) + .WithInnerBuild(innerBuild => + { + innerBuild.WithProperty("TargetFramework", "net9.0"); + innerBuild.WithProperty("RestoreEnableAnalyzerAssets", "false"); + }); + + // Act + var packageSpec = factory.Build(); + + // Assert + packageSpec.TargetFrameworks.Should().OnlyContain(f => !f.RestoreEnableAnalyzerAssets); + } + + [Fact] + public void GetPackageSpec_WithPackageReferenceAnalyzerAssetMetadata_PopulatesDependencyAssetFlags() + { + // Arrange + var factory = new TestPackageSpecFactory(outerBuild => + { + outerBuild.WithProperty("TargetFramework", "net8.0"); + outerBuild.WithItem( + "PackageReference", + "SomePackage", + [ + new KeyValuePair("IncludeAssets", "runtime;analyzers"), + new KeyValuePair("ExcludeAssets", "runtime"), + new KeyValuePair("PrivateAssets", "analyzers"), + ]); + }); + + // Act + var packageSpec = factory.Build(); + var dependency = packageSpec.TargetFrameworks.Single().Dependencies.Single(); + + // Assert + dependency.IncludeType.Should().Be(LibraryIncludeFlags.Analyzers); + dependency.SuppressParent.Should().Be(LibraryIncludeFlags.Analyzers); + } + + [Fact] + public void GetPackageSpec_WithProjectReferenceAnalyzerAssetMetadata_PopulatesProjectReferenceAssetFlags() + { + // Arrange + var factory = new TestPackageSpecFactory(outerBuild => + { + outerBuild.WithProperty("TargetFramework", "net8.0"); + outerBuild.WithItem( + ProjectItems.ProjectReference, + "..\\Referenced\\Referenced.csproj", + [ + new KeyValuePair("IncludeAssets", "runtime;analyzers"), + new KeyValuePair("ExcludeAssets", "runtime"), + new KeyValuePair("PrivateAssets", "analyzers"), + ]); + }); + + // Act + var packageSpec = factory.Build(); + var projectReference = packageSpec.RestoreMetadata.TargetFrameworks.Single().ProjectReferences.Single(); + + // Assert + projectReference.IncludeAssets.Should().Be(LibraryIncludeFlags.Runtime | LibraryIncludeFlags.Analyzers); + projectReference.ExcludeAssets.Should().Be(LibraryIncludeFlags.Runtime); + projectReference.PrivateAssets.Should().Be(LibraryIncludeFlags.Analyzers); + } } diff --git a/test/NuGet.Core.Tests/NuGet.Packaging.Test/ContentModelTests/ContentModelAnalyzerTests.cs b/test/NuGet.Core.Tests/NuGet.Packaging.Test/ContentModelTests/ContentModelAnalyzerTests.cs new file mode 100644 index 00000000000..e366cf9f693 --- /dev/null +++ b/test/NuGet.Core.Tests/NuGet.Packaging.Test/ContentModelTests/ContentModelAnalyzerTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable disable + +using System.Collections.Generic; +using System.Linq; +using NuGet.Client; +using NuGet.ContentModel; +using NuGet.RuntimeModel; +using Xunit; + +namespace NuGet.Packaging.Test.ContentModelTests +{ + public class ContentModelAnalyzerTests + { + private static ManagedCodeConventions CreateConventions() + { + return new ManagedCodeConventions( + new RuntimeGraph( + new List() { new CompatibilityProfile("net46.app") })); + } + + private static List FindAnalyzers(params string[] files) + { + var conventions = CreateConventions(); + var collection = new ContentItemCollection(); + collection.Load(files); + + return collection + .FindItems(conventions.Patterns.AnalyzerAssemblies) + .Select(item => item.Path) + .OrderBy(path => path, System.StringComparer.Ordinal) + .ToList(); + } + + [Fact] + public void AnalyzerAssemblies_MatchesAssembliesAtAnyDepth() + { + var analyzers = FindAnalyzers( + "analyzers/Root.dll", + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/VersionedAnalyzer.dll", + "analyzers/a/b/c/d/e/f/g/VeryDeepAnalyzer.dll"); + + Assert.Equal( + new[] + { + "analyzers/Root.dll", + "analyzers/a/b/c/d/e/f/g/VeryDeepAnalyzer.dll", + "analyzers/dotnet/NeutralAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/roslyn4.0/cs/VersionedAnalyzer.dll", + }, + analyzers); + } + + [Fact] + public void AnalyzerAssemblies_ExcludesSatelliteResourceAssemblies() + { + var analyzers = FindAnalyzers( + "analyzers/dotnet/cs/CSharpAnalyzer.dll", + "analyzers/dotnet/cs/CSharpAnalyzer.resources.dll"); + + Assert.Equal(new[] { "analyzers/dotnet/cs/CSharpAnalyzer.dll" }, analyzers); + } + + [Fact] + public void AnalyzerAssemblies_ExcludesNonDllFiles() + { + var analyzers = FindAnalyzers( + "analyzers/dotnet/cs/Analyzer.dll", + "analyzers/dotnet/cs/NotAnAnalyzer.exe", + "analyzers/dotnet/cs/NotAnAnalyzer.winmd", + "analyzers/dotnet/cs/readme.txt"); + + Assert.Equal(new[] { "analyzers/dotnet/cs/Analyzer.dll" }, analyzers); + } + + [Fact] + public void AnalyzerAssemblies_ExcludesAssembliesOutsideAnalyzersFolder() + { + var analyzers = FindAnalyzers( + "analyzers/dotnet/cs/Analyzer.dll", + "lib/netstandard2.0/Library.dll", + "ref/netstandard2.0/Library.dll", + "notanalyzers/Foo.dll"); + + Assert.Equal(new[] { "analyzers/dotnet/cs/Analyzer.dll" }, analyzers); + } + + [Fact] + public void AnalyzerAssemblies_MatchesAnalyzersFolderCaseInsensitively() + { + // The content model matches literal path segments case-insensitively, so an 'Analyzers/' folder + // (capital A) is detected. This differs from the previous hand-rolled detection, which matched the + // 'analyzers/' prefix with StringComparison.Ordinal (case-sensitive). + var analyzers = FindAnalyzers( + "analyzers/dotnet/cs/Lower.dll", + "Analyzers/dotnet/cs/Upper.dll"); + + Assert.Equal( + new[] + { + "Analyzers/dotnet/cs/Upper.dll", + "analyzers/dotnet/cs/Lower.dll", + }, + analyzers); + } + } +} diff --git a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/DependencyGraphSpecTests.cs b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/DependencyGraphSpecTests.cs index e8455a1345e..d05fbd31ed0 100644 --- a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/DependencyGraphSpecTests.cs +++ b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/DependencyGraphSpecTests.cs @@ -607,6 +607,25 @@ public void GetHash_WithCentralPackageVersionsInDifferentJsonOrder_IgnoresInputO Assert.Equal(firstHash, secondHash); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetHash_WithDifferentRestoreEnableAnalyzerAssetsValues_ReturnsDifferentHashes(bool useLegacyHashFunction) + { + // Arrange + DependencyGraphSpec first = CreateDependencyGraphSpec(); + DependencyGraphSpec second = CreateDependencyGraphSpec(); + first.GetProjectSpec("a").TargetFrameworks.Add(new TargetFrameworkInformation { FrameworkName = NuGetFramework.Parse("net5.0") }); + second.GetProjectSpec("a").TargetFrameworks.Add(new TargetFrameworkInformation { FrameworkName = NuGetFramework.Parse("net5.0"), RestoreEnableAnalyzerAssets = true }); + + // Act + string firstHash = GetHash(first, useLegacyHashFunction); + string secondHash = GetHash(second, useLegacyHashFunction); + + // Assert + Assert.NotEqual(firstHash, secondHash); + } + [Fact] public void AddProject_WhenRestoreMetadataIsNull_AddsProject() { diff --git a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/JsonPackageSpecReaderTests.cs b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/JsonPackageSpecReaderTests.cs index bc5e7dbe655..e7917546bce 100644 --- a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/JsonPackageSpecReaderTests.cs +++ b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/JsonPackageSpecReaderTests.cs @@ -197,6 +197,35 @@ public void PackageSpecReader_ExplicitIncludesOverrideTypePlatform() Assert.Equal(expected, dep.IncludeType); } + [Fact] + public void PackageSpecReader_ReadsAnalyzerDependencyAssetFlags() + { + // Arrange + var json = @"{ + ""frameworks"": { + ""net46"": { + ""dependencies"": { + ""analyzerPackage"": { + ""version"": ""1.0.0"", + ""include"": ""compile, analyzers"", + ""exclude"": ""compile"", + ""suppressParent"": ""analyzers"" + } + } + } + } + }"; + + // Act + var actual = GetPackageSpec(json, "TestProject", "project.json", null); + + // Assert + var dep = actual.TargetFrameworks[0].Dependencies.FirstOrDefault(d => d.Name.Equals("analyzerPackage")); + Assert.NotNull(dep); + Assert.Equal(LibraryIncludeFlags.Analyzers, dep.IncludeType); + Assert.Equal(LibraryIncludeFlags.Analyzers, dep.SuppressParent); + } + [Fact] public void PackageSpecReader_ReadsDependencyWithMultipleNoWarn() { @@ -2956,6 +2985,35 @@ public void GetPackageSpec_WithRestoreUseLegacyDependencyResolver_ReturnsUseLega packageSpec.RestoreMetadata.UseLegacyDependencyResolver.Should().Be(useLegacyDependencyResolver); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetPackageSpec_WithRestoreEnableAnalyzerAssets_ReturnsRestoreEnableAnalyzerAssets( + bool restoreEnableAnalyzerAssets) + { + // Arrange + var json = $"{{\"frameworks\":{{\"net5.0\":{{\"restoreEnableAnalyzerAssets\":{restoreEnableAnalyzerAssets.ToString().ToLowerInvariant()}}}}}}}"; + + // Act + PackageSpec packageSpec = GetPackageSpec(json); + + // Assert + packageSpec.TargetFrameworks.Single().RestoreEnableAnalyzerAssets.Should().Be(restoreEnableAnalyzerAssets); + } + + [Fact] + public void GetPackageSpec_WithNoRestoreEnableAnalyzerAssetsValuePassed_DefaultsFalse() + { + // Arrange + var json = "{\"frameworks\":{\"net5.0\":{}}}"; + + // Act + PackageSpec packageSpec = GetPackageSpec(json); + + // Assert + packageSpec.TargetFrameworks.Single().RestoreEnableAnalyzerAssets.Should().BeFalse(); + } + [Fact] public void GetPackageSpec_WhenFrameworksPackagesToPrunePropertyIsAbsent_ReturnsEmptyPackagesToPrune() diff --git a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileFormatTests.cs b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileFormatTests.cs index 58f50b0411c..c9cee165d87 100644 --- a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileFormatTests.cs +++ b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileFormatTests.cs @@ -365,6 +365,63 @@ public void LockFileFormat_WritesLockFile() Assert.Equal(expected.ToString(), output.ToString()); } + [Fact] + public void LockFileFormat_WithAnalyzerAssets_WritesAndReadsAnalyzerAssets() + { + // Arrange + const string AnalyzerAssetPath = "analyzers/dotnet/cs/MyAnalyzer.dll"; + + var lockFile = new LockFile() + { + Version = 5, + PackageSpec = new PackageSpec(new[] + { + new TargetFrameworkInformation + { + FrameworkName = FrameworkConstants.CommonFrameworks.DotNet, + TargetAlias = "dotnet" + } + }) + }; + + var target = new LockFileTarget() + { + TargetFramework = FrameworkConstants.CommonFrameworks.DotNet, + TargetAlias = "dotnet", + Name = "dotnet" + }; + + var targetLibrary = new LockFileTargetLibrary() + { + Name = "MyAnalyzerPackage", + Version = NuGetVersion.Parse("1.0.0"), + Type = LibraryType.Package + }; + var analyzerAsset = new LockFileItem(AnalyzerAssetPath); + analyzerAsset.Properties["codeLanguage"] = "cs"; + analyzerAsset.Properties["compilerApiVersion"] = "roslyn4.0"; + targetLibrary.AnalyzerAssets.Add(analyzerAsset); + target.Libraries.Add(targetLibrary); + lockFile.Targets.Add(target); + + var lockFileFormat = new LockFileFormat(); + + // Act + var renderedJson = lockFileFormat.Render(lockFile); + var parsedLockFile = Parse(renderedJson, "In Memory"); + + // Assert + Assert.Contains(@"""version"": 5", renderedJson); + Assert.Contains(@"""analyzers"": {", renderedJson); + Assert.Contains(@"""codeLanguage"": ""cs""", renderedJson); + Assert.Contains(@"""compilerApiVersion"": ""roslyn4.0""", renderedJson); + + var parsedAsset = parsedLockFile.Targets.Single().Libraries.Single().AnalyzerAssets.Single(); + Assert.Equal(AnalyzerAssetPath, parsedAsset.Path); + Assert.Equal("cs", parsedAsset.Properties["codeLanguage"]); + Assert.Equal("roslyn4.0", parsedAsset.Properties["compilerApiVersion"]); + } + [Fact] public void LockFileFormat_WritesPackageSpec() { @@ -404,6 +461,57 @@ public void LockFileFormat_WritesPackageSpec() Assert.Equal(expected.ToString(), output.ToString()); } + [Fact] + public void LockFileFormat_WithRestoreEnableAnalyzerAssets_WritesPackageSpecFramework() + { + // Arrange + var lockFileContent = @"{ + ""version"": 2, + ""targets"": {}, + ""libraries"": {}, + ""projectFileDependencyGroups"": {}, + ""project"": { + ""restore"": { + ""projectUniqueName"": ""projectUniqueName"" + }, + ""frameworks"": { + ""dotnet"": { + ""framework"": ""dotnet"", + ""restoreEnableAnalyzerAssets"": true + } + } + } +}"; + var lockFile = new LockFile() + { + Version = 2, + + PackageSpec = new PackageSpec(new[] + { + new TargetFrameworkInformation + { + FrameworkName = FrameworkConstants.CommonFrameworks.DotNet, + RestoreEnableAnalyzerAssets = true + } + }) + { + RestoreMetadata = new ProjectRestoreMetadata + { + ProjectUniqueName = "projectUniqueName", + UsingMicrosoftNETSdk = true + } + } + }; + + // Act + var lockFileFormat = new LockFileFormat(); + var output = JObject.Parse(lockFileFormat.Render(lockFile)); + var expected = JObject.Parse(lockFileContent); + + // Assert + Assert.Equal(expected.ToString(), output.ToString()); + } + [Fact] public void Render_LockFileWithPackageFolder_WritesPackageFolder() { diff --git a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileTargetLibraryTests.cs b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileTargetLibraryTests.cs index a9b273e26a4..893a49daf61 100644 --- a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileTargetLibraryTests.cs +++ b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/LockFileTargetLibraryTests.cs @@ -288,6 +288,34 @@ public void Equals_WithCompileTimeAssemblies(string left, string right, bool exp } } + [Theory] + [InlineData("a", "a", true)] + [InlineData("A;b", "a;B", true)] + [InlineData("a;b;c", "c;a;B", true)] + [InlineData("a;b;c;d", "c;a;B", false)] + public void Equals_WithAnalyzerAssets_ReturnsExpectedResult(string left, string right, bool expected) + { + var leftSide = new LockFileTargetLibrary() + { + AnalyzerAssets = left.Split(';').Select(e => new LockFileItem(e)).ToList() + }; + + var rightSide = new LockFileTargetLibrary() + { + AnalyzerAssets = right.Split(';').Select(e => new LockFileItem(e)).ToList() + }; + + // Act & Assert + if (expected) + { + leftSide.Should().Be(rightSide); + } + else + { + leftSide.Should().NotBe(rightSide); + } + } + [Theory] [InlineData("a", "a", true)] [InlineData("A;b", "a;B", true)] @@ -776,6 +804,34 @@ public void HashCode_WithCompileTimeAssemblies(string left, string right, bool e } } + [Theory] + [InlineData("a", "a", true)] + [InlineData("A;b", "a;B", true)] + [InlineData("a;b;c", "c;a;B", true)] + [InlineData("a;b;c;d", "c;a;B", false)] + public void HashCode_WithAnalyzerAssets_ReturnsExpectedResult(string left, string right, bool expected) + { + var leftSide = new LockFileTargetLibrary() + { + AnalyzerAssets = left.Split(';').Select(e => new LockFileItem(e)).ToList() + }; + + var rightSide = new LockFileTargetLibrary() + { + AnalyzerAssets = right.Split(';').Select(e => new LockFileItem(e)).ToList() + }; + + // Act & Assert + if (expected) + { + leftSide.GetHashCode().Should().Be(rightSide.GetHashCode()); + } + else + { + leftSide.GetHashCode().Should().NotBe(rightSide.GetHashCode()); + } + } + [Theory] [InlineData("a", "a", true)] [InlineData("A;b", "a;B", true)] diff --git a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/PackageSpecWriterTests.cs b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/PackageSpecWriterTests.cs index 1c5adad51b5..47a426e65e7 100644 --- a/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/PackageSpecWriterTests.cs +++ b/test/NuGet.Core.Tests/NuGet.ProjectModel.Test/PackageSpecWriterTests.cs @@ -226,6 +226,64 @@ public void Write_ReadWrite_RestoreDoNotWriteDependencyGraphSpec_DefaultFalse_No output.Should().NotContain("restoreDoNotWriteDependencyGraphSpec"); } + [Fact] + public void Write_ReadWrite_RestoreEnableAnalyzerAssets_True() + { + // Arrange + var spec = new PackageSpec(new[] + { + new TargetFrameworkInformation + { + FrameworkName = NuGetFramework.Parse("net45"), + RestoreEnableAnalyzerAssets = true + } + }) + { + RestoreMetadata = new ProjectRestoreMetadata + { + ProjectUniqueName = "projectUniqueName", + ProjectName = "projectName", + ProjectStyle = ProjectStyle.PackageReference + } + }; + + // Act + var json = GetJsonString(spec); + var roundTripped = JsonPackageSpecReader.GetPackageSpec(json, "projectName", "project.csproj"); + + // Assert + json.Should().Contain("restoreEnableAnalyzerAssets"); + roundTripped.TargetFrameworks[0].RestoreEnableAnalyzerAssets.Should().BeTrue(); + } + + [Fact] + public void Write_ReadWrite_RestoreEnableAnalyzerAssets_DefaultFalse_NotWritten() + { + // Arrange + var spec = new PackageSpec(new[] + { + new TargetFrameworkInformation + { + FrameworkName = NuGetFramework.Parse("net45") + } + }) + { + RestoreMetadata = new ProjectRestoreMetadata + { + ProjectUniqueName = "projectUniqueName", + ProjectName = "projectName", + ProjectStyle = ProjectStyle.PackageReference + } + }; + + // Act + var output = GetJsonString(spec); + + // Assert + spec.TargetFrameworks[0].RestoreEnableAnalyzerAssets.Should().BeFalse(); + output.Should().NotContain("restoreEnableAnalyzerAssets"); + } + [Fact] public void Write_SerializesMembersAsJson() {