File: src\UsageReport\ValidateUsageAgainstBaseline.cs
Web Access
Project: src\src\Microsoft.DotNet.SourceBuild\tasks\Microsoft.DotNet.SourceBuild.Tasks.csproj (Microsoft.DotNet.SourceBuild.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using NuGet.Packaging.Core;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml.Linq;
 
namespace Microsoft.DotNet.SourceBuild.Tasks.UsageReport
{
    public class ValidateUsageAgainstBaseline : Microsoft.Build.Utilities.Task
    {
        [Required]
        public string DataFile { get; set; }
 
        /// <summary>
        /// The prebuilt baseline: an XML file that lists allowed prebuilt usage.
        /// </summary>
        public string BaselineDataFile { get; set; }
 
        /// <summary>
        /// A hint path used in error messages to tell the user where to update the prebuilt
        /// baseline data if a baseline validation error occurs. If there is no baseline data file
        /// at all yet, the error indicates that one should be created at this location if the
        /// prebuilt usage should be permitted.
        /// </summary>
        [Required]
        public string BaselineDataUpdateHintFile { get; set; }
 
        [Required]
        public string OutputBaselineFile { get; set; }
 
        [Required]
        public string OutputReportFile { get; set; }
 
        private readonly string _preBuiltDocMessage = "See https://aka.ms/dotnet/prebuilts " +
            "for guidance on what pre-builts are and how to eliminate them.";
 
        private readonly string _reviewRequestMessage = "When altering this file or making " +
            "other Source Build related changes, include @dotnet/source-build as a reviewer.";
 
        public override bool Execute()
        {
            string ReviewRequestComment = $"<!-- {_reviewRequestMessage} -->{Environment.NewLine}";
            string PreBuiltDocXmlComment = $"<!-- {_preBuiltDocMessage} -->{Environment.NewLine}";
            
            var used = UsageData.Parse(XElement.Parse(File.ReadAllText(DataFile)));
 
            string baselineText = string.IsNullOrEmpty(BaselineDataFile)
                ? "<UsageData />"
                : File.ReadAllText(BaselineDataFile);
 
            var baseline = UsageData.Parse(XElement.Parse(baselineText));
 
            UsageValidationData data = GetUsageValidationData(baseline, used);
 
            Directory.CreateDirectory(Path.GetDirectoryName(OutputBaselineFile));
            File.WriteAllText(OutputBaselineFile, ReviewRequestComment + PreBuiltDocXmlComment + Environment.NewLine + data.ActualUsageData.ToXml().ToString());
 
            Directory.CreateDirectory(Path.GetDirectoryName(OutputReportFile));
            File.WriteAllText(OutputReportFile, PreBuiltDocXmlComment + Environment.NewLine + data.Report.ToString());
 
            return !Log.HasLoggedErrors;
        }
 
        public UsageValidationData GetUsageValidationData(UsageData baseline, UsageData used)
        {
            // Remove prebuilts from the used data if the baseline says to ignore them. Do this
            // first, so the new generated baseline doesn't list usages that are ignored by a
            // pattern anyway.
            ApplyBaselineIgnorePatterns(used, baseline);
 
            // Find new, removed, and unchanged usage after filtering patterns.
            Comparison<PackageIdentity> diff = Compare(
                used.Usages.Select(u => u.GetIdentityWithoutRid()).Distinct(),
                baseline.Usages.Select(u => u.GetIdentityWithoutRid()).Distinct());
 
            var report = new XElement("BaselineComparison");
 
            bool tellUserToUpdateBaseline = false;
 
            if (diff.Added.Any())
            {
                tellUserToUpdateBaseline = true;
                Log.LogError(
                    $"{diff.Added.Length} new pre-builts discovered! Detailed usage " +
                    $"report can be found at {OutputReportFile}.{Environment.NewLine}{_preBuiltDocMessage}{Environment.NewLine}" +
                    $"Package IDs are:{Environment.NewLine}" + string.Join(Environment.NewLine, diff.Added.Select(u => u.ToString())));
 
                // In the report, list full usage info, not only identity.
                report.Add(
                    new XElement(
                        "New",
                        used.Usages
                            .Where(u => diff.Added.Contains(u.GetIdentityWithoutRid()))
                            .Select(u => u.ToXml())));
            }
 
            if (diff.Removed.Any())
            {
                tellUserToUpdateBaseline = true;
                Log.LogMessage(
                    MessageImportance.High,
                    $"{diff.Removed.Length} packages in baseline weren't used!");
 
                report.Add(new XElement("Removed", diff.Removed.Select(id => id.ToXElement())));
            }
 
            if (diff.Unchanged.Any())
            {
                Log.LogMessage(
                    MessageImportance.High,
                    $"{diff.Unchanged.Length} packages used as expected in the baseline.");
            }
 
            // Simplify the used data to what is necessary for a baseline, to reduce file size.
            foreach (var usage in used.Usages)
            {
                usage.AssetsFile = null;
            }
 
            used.ProjectDirectories = null;
            used.Usages = used.Usages.Distinct().ToArray();
 
            if (tellUserToUpdateBaseline)
            {
                string baselineNotFoundWarning = "";
                if (string.IsNullOrEmpty(BaselineDataFile))
                {
                    baselineNotFoundWarning =
                        $"not expected, because no baseline file exists at '{BaselineDataUpdateHintFile}'";
                }
                else
                {
                    baselineNotFoundWarning =
                        $"different from the baseline found at '{BaselineDataFile}'";
                }
 
                Log.LogMessage(
                    MessageImportance.High,
                    $"Prebuilt usages are {baselineNotFoundWarning}. If it's acceptable to " +
                    "update the baseline, copy the contents of the automatically generated " +
                    $"baseline '{OutputBaselineFile}'.");
            }
 
            return new UsageValidationData
            {
                Report = report,
                ActualUsageData = used
            };
        }
 
        private static void ApplyBaselineIgnorePatterns(UsageData actual, UsageData baseline)
        {
            Regex[] ignoreUsageRegexes = baseline.IgnorePatterns.NullAsEmpty()
                .Select(p => p.CreateRegex())
                .ToArray();
 
            actual.IgnorePatterns = baseline.IgnorePatterns;
 
            var ignoredUsages = actual.Usages
                .Where(usage =>
                {
                    string id = $"{usage.PackageIdentity.Id}/{usage.PackageIdentity.Version}";
                    return ignoreUsageRegexes.Any(r => r.IsMatch(id));
                })
                .ToArray();
 
            actual.Usages = actual.Usages.Except(ignoredUsages).ToArray();
        }
 
        private static Comparison<T> Compare<T>(IEnumerable<T> actual, IEnumerable<T> baseline)
        {
            return new Comparison<T>(actual.ToArray(), baseline.ToArray());
        }
 
        private class Comparison<T>
        {
            public T[] Added { get; }
            public T[] Removed { get; }
            public T[] Unchanged { get; }
 
            public Comparison(
                IEnumerable<T> actual,
                IEnumerable<T> baseline)
            {
                Added = actual.Except(baseline).ToArray();
                Removed = baseline.Except(actual).ToArray();
                Unchanged = actual.Intersect(baseline).ToArray();
            }
        }
    }
}