File: src\UsageReport\WritePackageUsageData.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 Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NuGet.Packaging.Core;
using NuGet.Versioning;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
using Task = Microsoft.Build.Utilities.Task;
 
namespace Microsoft.DotNet.SourceBuild.Tasks.UsageReport
{
    public class WritePackageUsageData : Microsoft.Build.Utilities.Task
    {
        public string[] RestoredPackageFiles { get; set; }
        public string[] TarballPrebuiltPackageFiles { get; set; }
        public string[] ReferencePackageFiles { get; set; }
        public string[] SourceBuiltPackageFiles { get; set; }
 
        /// <summary>
        /// Specific PackageInfo items to check for usage. An alternative to passing lists of nupkgs
        /// when the nupkgs have already been parsed to get package info items.
        ///
        /// %(Identity): Path to the original nupkg.
        /// %(PackageId): Identity of the package.
        /// %(PackageVersion): Version of the package.
        /// </summary>
        public ITaskItem[] NuGetPackageInfos { get; set; }
 
        /// <summary>
        /// runtime.json files (from Microsoft.NETCore.Platforms) to use to look for the set of all
        /// possible runtimes. This is used to determine which part of a package id is its
        /// 'runtime.{rid}.' prefix, if it has the prefix.
        /// </summary>
        public string[] PlatformsRuntimeJsonFiles { get; set; }
 
        /// <summary>
        /// Keep track of the RID built that caused these usages.
        /// </summary>
        public string TargetRid { get; set; }
 
        /// <summary>
        /// Project directories to scan for project.assets.json files. If these directories contain
        /// one another, the project.assets.json files is reported as belonging to the first project
        /// directory that contains it. For useful results, put the leafmost directories first.
        ///
        /// This isn't used here, but it's included in the usage data so report generation can
        /// happen independently of commits that add/remove submodules.
        /// </summary>
        public string[] ProjectDirectories { get; set; }
 
        /// <summary>
        /// A root dir that contains all ProjectDirectories. This is used to find the relative path
        /// of each usage.
        /// </summary>
        [Required]
        public string RootDir { get; set; }
 
        /// <summary>
        /// project.assets.json files to ignore, for example, because they are checked-in assets not
        /// generated during source-build and cause false positives.
        /// </summary>
        public string[] IgnoredProjectAssetsJsonFiles { get; set; }
 
        /// <summary>
        /// Output usage data JSON file path.
        /// </summary>
        [Required]
        public string DataFile { get; set; }
 
        /// <summary>
        /// If passed, the path of the archive file to generate that includes a copy of all
        /// project.asset.json files found.
        /// </summary>
        public string ProjectAssetsJsonArchiveFile { get; set; }
 
        public override bool Execute()
        {
            DateTime startTime = DateTime.Now;
            Log.LogMessage(MessageImportance.High, "Writing package usage data...");
 
            string[] projectDirectoriesOutsideRoot = ProjectDirectories.NullAsEmpty()
                .Where(dir => !dir.StartsWith(RootDir, StringComparison.Ordinal))
                .ToArray();
 
            if (projectDirectoriesOutsideRoot.Any())
            {
                throw new ArgumentException(
                    $"All ProjectDirectories must be in RootDir '{RootDir}', but found " +
                    string.Join(", ", projectDirectoriesOutsideRoot));
            }
 
            Log.LogMessage(MessageImportance.Low, "Finding set of RIDs...");
 
            string[] possibleRids = PlatformsRuntimeJsonFiles.NullAsEmpty()
                .SelectMany(ReadRidsFromRuntimeJson)
                .Distinct()
                .ToArray();
 
            Log.LogMessage(MessageImportance.Low, "Reading package identities...");
 
            PackageIdentity[] restored = RestoredPackageFiles.NullAsEmpty()
                .Select(ReadNuGetPackageInfos.ReadIdentity)
                .Distinct()
                .ToArray();
 
            PackageIdentity[] tarballPrebuilt = TarballPrebuiltPackageFiles.NullAsEmpty()
                .Select(ReadNuGetPackageInfos.ReadIdentity)
                .Distinct()
                .ToArray();
 
            PackageIdentity[] referencePackages = ReferencePackageFiles.NullAsEmpty()
                .Select(ReadNuGetPackageInfos.ReadIdentity)
                .Distinct()
                .ToArray();
 
            PackageIdentity[] sourceBuilt = SourceBuiltPackageFiles.NullAsEmpty()
                .Select(ReadNuGetPackageInfos.ReadIdentity)
                .Distinct()
                .ToArray();
 
            IEnumerable<PackageIdentity> prebuilt = restored.Except(sourceBuilt).Except(referencePackages);
 
            PackageIdentity[] toCheck = NuGetPackageInfos.NullAsEmpty()
                .Select(item => new PackageIdentity(
                    item.GetMetadata("PackageId"),
                    NuGetVersion.Parse(item.GetMetadata("PackageVersion"))))
                .Concat(prebuilt)
                .ToArray();
 
            Log.LogMessage(MessageImportance.Low, "Finding project.assets.json files...");
 
            string[] assetFiles = Directory
                .GetFiles(RootDir, "project.assets.json", SearchOption.AllDirectories)
                .Select(GetPathRelativeToRoot)
                .Except(IgnoredProjectAssetsJsonFiles.NullAsEmpty().Select(GetPathRelativeToRoot))
                .ToArray();
 
            if (!string.IsNullOrEmpty(ProjectAssetsJsonArchiveFile))
            {
                Log.LogMessage(MessageImportance.Low, "Archiving project.assets.json files...");
 
                Directory.CreateDirectory(Path.GetDirectoryName(ProjectAssetsJsonArchiveFile));
 
                using (var projectAssetArchive = new ZipArchive(
                    File.Open(
                        ProjectAssetsJsonArchiveFile,
                        FileMode.Create,
                        FileAccess.ReadWrite),
                    ZipArchiveMode.Create))
                {
                    // Only one entry can be open at a time, so don't do this during the Parallel
                    // ForEach later.
                    foreach (var relativePath in assetFiles)
                    {
                        using (var stream = File.OpenRead(Path.Combine(RootDir, relativePath)))
                        using (Stream entryWriter = projectAssetArchive
                            .CreateEntry(relativePath, CompressionLevel.Optimal)
                            .Open())
                        {
                            stream.CopyTo(entryWriter);
                        }
                    }
                }
            }
 
            Log.LogMessage(MessageImportance.Low, "Reading usage info...");
 
            var usages = new ConcurrentBag<Usage>();
 
            Parallel.ForEach(
                assetFiles,
                assetFile =>
                {
                    JObject jObj;
 
                    using (var file = File.OpenRead(Path.Combine(RootDir, assetFile)))
                    using (var reader = new StreamReader(file))
                    using (var jsonReader = new JsonTextReader(reader))
                    {
                        jObj = (JObject)JToken.ReadFrom(jsonReader);
                    }
 
                    var properties = new HashSet<string>(
                        jObj.SelectTokens("$.targets.*").Children()
                            .Concat(jObj.SelectToken("$.libraries"))
                            .Select(t => ((JProperty)t).Name)
                            .Distinct(), 
                        StringComparer.OrdinalIgnoreCase);
 
                    var directDependencies = jObj.SelectTokens("$.project.frameworks.*.dependencies").Children().Select(dep =>
                        new
                        {
                            name = ((JProperty)dep).Name,
                            target = dep.SelectToken("$..target")?.ToString(),
                            version = VersionRange.Parse(dep.SelectToken("$..version")?.ToString()),
                            autoReferenced = dep.SelectToken("$..autoReferenced")?.ToString() == "True",
                        })
                        .ToArray();
 
                    foreach (var identity in toCheck
                        .Where(id => properties.Contains(id.Id + "/" + id.Version.OriginalVersion)))
                    {
                        var directDependency =
                            directDependencies?.FirstOrDefault(
                                d => d.name == identity.Id && 
                                     d.version.Satisfies(identity.Version));
                        usages.Add(Usage.Create(
                            assetFile,
                            identity,
                            directDependency != null,
                            directDependency?.autoReferenced == true,
                            possibleRids));
                    }
                });
 
            Log.LogMessage(MessageImportance.Low, "Searching for unused packages...");
 
            foreach (PackageIdentity restoredWithoutUsagesFound in
                toCheck.Except(usages.Select(u => u.PackageIdentity)))
            {
                usages.Add(Usage.Create(
                    null,
                    restoredWithoutUsagesFound,
                    false,
                    false,
                    possibleRids));
            }
 
            // Packages that were included in the tarball as prebuilts, but weren't even restored.
            PackageIdentity[] neverRestoredTarballPrebuilts = tarballPrebuilt
                .Except(restored)
                .ToArray();
 
            Log.LogMessage(MessageImportance.Low, $"Writing data to '{DataFile}'...");
 
            var data = new UsageData
            {
                CreatedByRid = TargetRid,
                Usages = usages.ToArray(),
                NeverRestoredTarballPrebuilts = neverRestoredTarballPrebuilts,
                ProjectDirectories = ProjectDirectories
                    ?.Select(GetPathRelativeToRoot)
                    .ToArray()
            };
 
            Directory.CreateDirectory(Path.GetDirectoryName(DataFile));
            File.WriteAllText(DataFile, data.ToXml().ToString());
 
            Log.LogMessage(
                MessageImportance.High,
                $"Writing package usage data... done. Took {DateTime.Now - startTime}");
 
            return !Log.HasLoggedErrors;
        }
 
        private string GetPathRelativeToRoot(string path)
        {
            if (path.StartsWith(RootDir))
            {
                return path.Substring(RootDir.Length).Replace(Path.DirectorySeparatorChar, '/');
            }
 
            throw new ArgumentException($"Path '{path}' is not within RootDir '{RootDir}'");
        }
 
        private static string[] ReadRidsFromRuntimeJson(string path)
        {
            var root = JObject.Parse(File.ReadAllText(path));
            return root["runtimes"]
                .Values<JProperty>()
                .Select(o => o.Name)
                .ToArray();
        }
    }
}