File: RestoreCommand\Diagnostics\UnexpectedDependencyMessages.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.Commands\NuGet.Commands.csproj (NuGet.Commands)
// 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;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using NuGet.Common;
using NuGet.LibraryModel;
using NuGet.ProjectModel;
using NuGet.Shared;
using NuGet.Versioning;

namespace NuGet.Commands
{
    /// <summary>
    /// Log warnings for packages that did not resolve to the minimum version of the dependency range.
    /// </summary>
    internal static class UnexpectedDependencyMessages
    {
        /// <summary>
        /// Log warnings for all project issues related to unexpected dependencies.
        /// </summary>
        public static async Task LogAsync(IEnumerable<IRestoreTargetGraph> graphs, PackageSpec project, ILogger logger)
        {
            var ignoreIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var graphList = graphs.AsList();

            // Index the flattened graph for faster lookups.
            var indexedGraphs = graphList.Select(IndexedRestoreTargetGraph.Create).ToList();

            // 1. Detect project dependency authoring issues in the current project.
            //    The user can fix these themselves.
            var projectMissingVersions = GetProjectDependenciesMissingVersion(project);
            ignoreIds.UnionWith(projectMissingVersions.Select(e => e.LibraryId));
            await logger.LogMessagesAsync(DiagnosticUtility.MergeOnTargetGraph(projectMissingVersions));

            var projectMissingLowerBounds = GetProjectDependenciesMissingLowerBounds(project);
            ignoreIds.UnionWith(projectMissingLowerBounds.Select(e => e.LibraryId));
            await logger.LogMessagesAsync(DiagnosticUtility.MergeOnTargetGraph(projectMissingLowerBounds));

            // Ignore generating NU1603/NU1602 across entire graph if lock file is enabled. Because
            // lock file enforce a fixed resolved version for all the different requests for the same package ID.
            if (!PackagesLockFileUtilities.IsNuGetLockFileEnabled(project))
            {
                // 2. Detect dependency and source issues across the entire graph 
                //    where the minimum version was not matched exactly.
                //    Ignore packages already logged by #1
                var missingMinimums = GetMissingLowerBounds(graphList, ignoreIds);
                ignoreIds.UnionWith(missingMinimums.Select(e => e.LibraryId));
                await logger.LogMessagesAsync(DiagnosticUtility.MergeOnTargetGraph(missingMinimums));
            }

            // 3. Detect top level dependencies that have a version different from the specified version.
            //    Ignore packages already logged in #1 and #2 since those errors are more specific.
            var bumpedUp = GetBumpedUpDependencies(indexedGraphs, project, ignoreIds);
            await logger.LogMessagesAsync(DiagnosticUtility.MergeOnTargetGraph(bumpedUp));

            // 4. Detect dependencies that are higher than the upper bound of a version range.
            var aboveUpperBounds = GetDependenciesAboveUpperBounds(indexedGraphs, logger);
            await logger.LogMessagesAsync(aboveUpperBounds);
        }

        /// <summary>
        /// Get warnings for packages that have dependencies on non-existant versions of packages
        /// and also for packages with ranges that have missing minimum versions.
        /// </summary>
        public static IEnumerable<RestoreLogMessage> GetMissingLowerBounds(IEnumerable<IRestoreTargetGraph> graphs, ISet<string> ignoreIds)
        {
            var messages = new List<RestoreLogMessage>();

            foreach (var graph in graphs)
            {
                messages.AddRange(graph.ResolvedDependencies
                                            .Where(e => !ignoreIds.Contains(e.Child.Name, StringComparer.OrdinalIgnoreCase)
                                                        && DependencyRangeHasMissingExactMatch(e))
                                            .OrderBy(e => e.Child.Name, StringComparer.OrdinalIgnoreCase)
                                            .ThenBy(e => e.Child.Version)
                                            .ThenBy(e => e.Parent.Name, StringComparer.OrdinalIgnoreCase)
                                            .Select(e => GetMissingLowerBoundMessage(e, graph.TargetGraphName)));
            }

            return messages;
        }

        /// <summary>
        /// Get warning message for missing minimum dependencies.
        /// </summary>
        public static RestoreLogMessage GetMissingLowerBoundMessage(ResolvedDependencyKey dependency, params string[] targetGraphs)
        {
            NuGetLogCode code;
            var message = string.Empty;
            var parent = DiagnosticUtility.FormatIdentity(dependency.Parent);
            var dependencyRange = DiagnosticUtility.FormatDependency(dependency.Child.Name, dependency.Range);
            var missingChild = DiagnosticUtility.FormatExpectedIdentity(dependency.Child.Name, dependency.Range);
            var resolvedChild = DiagnosticUtility.FormatIdentity(dependency.Child);

            if (HasMissingLowerBound(dependency.Range))
            {
                // Range does not have a lower bound, the best match can only be approximate.
                message = string.Format(CultureInfo.CurrentCulture, Strings.Warning_MinVersionNonInclusive,
                    parent,
                    dependencyRange,
                    resolvedChild);

                code = NuGetLogCode.NU1602;
            }
            else
            {
                // The minimum version does not exist.
                message = string.Format(CultureInfo.CurrentCulture, Strings.Warning_MinVersionDoesNotExist,
                    parent,
                    dependencyRange,
                    missingChild,
                    resolvedChild);

                code = NuGetLogCode.NU1603;
            }

            return RestoreLogMessage.CreateWarning(code, message, dependency.Child.Name, targetGraphs);
        }

        /// <summary>
        /// Warn for dependencies that have been bumped up.
        /// </summary>
        public static IEnumerable<RestoreLogMessage> GetBumpedUpDependencies(
            List<IndexedRestoreTargetGraph> graphs,
            PackageSpec project,
            ISet<string> ignoreIds)
        {
            var messages = new List<RestoreLogMessage>();

            // Check each graph individually using its target alias to get the correct dependencies.
            foreach (var indexedGraph in graphs)
            {
                // Use the alias to look up the correct per-alias dependencies.
                // For alias projects, TargetAlias is non-empty and uniquely identifies the target framework.
                // For non-alias projects, fall back to framework-based lookup.
                string targetAlias = indexedGraph.Graph.TargetAlias;
                IEnumerable<LibraryDependency> dependencies = !string.IsNullOrEmpty(targetAlias)
                    ? project.GetTargetFramework(targetAlias)!.Dependencies
                    : project.GetPackageDependenciesForFramework(indexedGraph.Graph.Framework);

                foreach (var dependency in dependencies)
                {
                    if (ignoreIds.Contains(dependency.Name, StringComparer.OrdinalIgnoreCase)
                        || !IsNonFloatingPackageDependency(dependency))
                    {
                        continue;
                    }

                    var minVersion = dependency.LibraryRange.VersionRange?.MinVersion;
                    if (minVersion != null && dependency.LibraryRange.VersionRange.IsMinInclusive)
                    {
                        var match = indexedGraph.GetItemById(dependency.Name, LibraryType.Package);

                        if (match != null && match.Key.Version > minVersion)
                        {
                            var message = string.Format(CultureInfo.CurrentCulture, Strings.Log_DependencyBumpedUp,
                                dependency.LibraryRange.Name,
                                dependency.LibraryRange.VersionRange.PrettyPrint(),
                                match.Key.Name,
                                match.Key.Version);

                            var graphName = indexedGraph.Graph.TargetGraphName;

                            messages.Add(RestoreLogMessage.CreateWarning(NuGetLogCode.NU1601, message, match.Key.Name, graphName));
                        }
                    }
                }
            }

            return messages;
        }

        /// <summary>
        /// Warn for project dependencies that do not have a version.
        /// </summary>
        internal static IEnumerable<RestoreLogMessage> GetProjectDependenciesMissingVersion(PackageSpec project)
        {
            return project.GetAllPackageDependencies()
                    .Where(e => e.LibraryRange.VersionRange == null)
                    .OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
                    .Select(e => RestoreLogMessage.CreateWarning(
                       code: NuGetLogCode.NU1604,
                       message: string.Format(CultureInfo.CurrentCulture, Strings.Warning_ProjectDependencyMissingVersion,
                                              DiagnosticUtility.FormatDependency(e.Name, e.LibraryRange.VersionRange)),
                       libraryId: e.Name,
                       targetGraphs: GetDependencyTargetGraphs(project, e)));
        }

        /// <summary>
        /// Warn for project dependencies that do not include a lower bound on the version range.
        /// </summary>
        public static IEnumerable<RestoreLogMessage> GetProjectDependenciesMissingLowerBounds(PackageSpec project)
        {
            return project.GetAllPackageDependencies()
                   .Where(e => e.LibraryRange.VersionRange != null && HasMissingLowerBound(e.LibraryRange.VersionRange))
                   .OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
                   .Select(e => RestoreLogMessage.CreateWarning(
                       code: NuGetLogCode.NU1604,
                       message: string.Format(CultureInfo.CurrentCulture, Strings.Warning_ProjectDependencyMissingLowerBound,
                                              DiagnosticUtility.FormatDependency(e.Name, e.LibraryRange.VersionRange)),
                       libraryId: e.Name,
                       targetGraphs: GetDependencyTargetGraphs(project, e)));
        }

        /// <summary>
        /// True if the dependency version range has a min version that matches the resolved version.
        /// </summary>
        public static bool DependencyRangeHasMissingExactMatch(ResolvedDependencyKey dependency)
        {
            // Ignore floating
            if (dependency.Range.IsFloating)
            {
                return false;
            }

            // Ignore projects
            if (dependency.Child.Type != LibraryType.Package)
            {
                return false;
            }

            return (!dependency.Range.IsMinInclusive || dependency.Range.MinVersion != dependency.Child.Version);
        }

        /// <summary>
        /// True if the range has an obtainable version for the lower bound.
        /// </summary>
        public static bool HasMissingLowerBound(VersionRange range)
        {
            // Ignore floating
            if (range.IsFloating)
            {
                return false;
            }

            return !range.IsMinInclusive || !range.HasLowerBound;
        }

        /// <summary>
        /// Log upgrade warnings from the graphs.
        /// </summary>
        public static IEnumerable<RestoreLogMessage> GetDependenciesAboveUpperBounds(List<IndexedRestoreTargetGraph> graphs, ILogger logger)
        {
            var messages = new List<RestoreLogMessage>();

            foreach (var indexedGraph in graphs)
            {
                var graph = indexedGraph.Graph;

                foreach (var node in graph.Flattened)
                {
                    List<LibraryDependency> dependencies = node.Data?.Dependencies;
                    if (dependencies == null)
                    {
                        continue;
                    }

                    foreach (var dependency in dependencies)
                    {
                        // Check if the dependency has an upper bound
                        var dependencyRange = dependency.LibraryRange.VersionRange;
                        var upperBound = dependencyRange?.MaxVersion;
                        if (upperBound != null)
                        {
                            var dependencyId = dependency.Name;

                            // If the version does not exist then it was not resolved or is a project and should be skipped.
                            var match = indexedGraph.GetItemById(dependencyId, LibraryType.Package);
                            if (match != null)
                            {
                                var actualVersion = match.Key.Version;

                                // If the upper bound is included then require that the version be higher than the upper bound to fail
                                // If the upper bound is not included, then an exact match on the upperbound is a failure
                                var compare = dependencyRange.IsMaxInclusive ? 1 : 0;

                                if (VersionComparer.VersionRelease.Compare(actualVersion, upperBound) >= compare)
                                {
                                    // True if the package already has an NU1107 error, NU1608 would be redundant here.
                                    if (!indexedGraph.HasErrors(dependencyId))
                                    {
                                        var parent = DiagnosticUtility.FormatIdentity(node.Key);
                                        var child = DiagnosticUtility.FormatDependency(dependencyId, dependencyRange);
                                        var actual = DiagnosticUtility.FormatIdentity(match.Key);

                                        var message = string.Format(CultureInfo.CurrentCulture,
                                            Strings.Warning_VersionAboveUpperBound,
                                            parent,
                                            child,
                                            actual);

                                        messages.Add(RestoreLogMessage.CreateWarning(NuGetLogCode.NU1608, message, dependencyId, graph.TargetGraphName));
                                    }
                                }
                            }
                        }
                    }
                }
            }

            // Merge log messages
            return DiagnosticUtility.MergeOnTargetGraph(messages);
        }

        private static bool IsNonFloatingPackageDependency(this LibraryDependency dependency)
        {
            return (dependency.LibraryRange.TypeConstraintAllows(LibraryDependencyTarget.Package)
                && dependency.LibraryRange.VersionRange != null && !dependency.LibraryRange.VersionRange.IsFloating);
        }

        /// <summary>
        /// Create target graph names for each framework the dependency exists under.
        /// </summary>
        private static string[] GetDependencyTargetGraphs(PackageSpec spec, LibraryDependency dependency)
        {
            // Add all tfms where the dependency is found and use the alias name if available.
            return spec.TargetFrameworks
                .Where(e => e.Dependencies.Contains(dependency))
                .Select(e => string.IsNullOrEmpty(e.TargetAlias) ? e.FrameworkName.ToString() : e.TargetAlias)
                .ToArray();
        }

    }
}