File: ApiCompatRunner.cs
Web Access
Project: src\src\Microsoft.DotNet.ApiCompat\src\Microsoft.DotNet.ApiCompat.csproj (Microsoft.DotNet.ApiCompat)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using McMaster.Extensions.CommandLineUtils;
using Microsoft.Cci;
using Microsoft.Cci.Comparers;
using Microsoft.Cci.Differs;
using Microsoft.Cci.Differs.Rules;
using Microsoft.Cci.Extensions;
using Microsoft.Cci.Extensions.CSharp;
using Microsoft.Cci.Filters;
using Microsoft.Cci.Mappings;
using Microsoft.Cci.Writers;
using System;
using System.Collections.Generic;
using System.Composition;
using System.Composition.Hosting;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
 
namespace Microsoft.DotNet.ApiCompat
{
    public class ApiCompatRunner
    {
        private readonly CommandLineApplication _app;
        private readonly TextWriter _outStream;
 
        public ApiCompatRunner() : this(null) { }
 
        public ApiCompatRunner(TextWriter outputStream)
        {
            _outStream = outputStream;
            _app = CreateApp();
            
            // Clear exit code from previous runs on the same domain given this is a static property.
            DifferenceWriter.ExitCode = 0;
        }
 
        public int Run(string[] args) => _app.Execute(args);
 
        private CommandLineApplication CreateApp()
        {
            var app = new CommandLineApplication
            {
                Name = "ApiCompat",
                FullName = "A command line tool to verify that two sets of APIs are compatible.",
                ResponseFileHandling = ResponseFileHandling.ParseArgsAsSpaceSeparated,
                Out = _outStream ?? Console.Out,
                Error = _outStream ?? Console.Error
            };
            app.HelpOption("-?|-h|--help");
            app.VersionOption("-v|--version", GetAssemblyVersion());
 
            CommandArgument contracts = app.Argument("contracts", "Comma delimited list of assemblies or directories of assemblies for all the contract assemblies.");
            contracts.IsRequired();
            CommandOption implDirs = app.Option("-i|--impl-dirs", "Comma delimited list of directories to find the implementation assemblies for each contract assembly.", CommandOptionType.SingleValue);
            implDirs.IsRequired(allowEmptyStrings: true);
            CommandOption baseline = app.Option("-b|--baseline", "Comma delimited list of files to skip known diffs.", CommandOptionType.SingleValue);
            CommandOption validateBaseline = app.Option("--validate-baseline", "Validates that baseline files don't have invalid/unused diffs.", CommandOptionType.NoValue);
            CommandOption mdil = app.Option("-m|--mdil", "Enforce MDIL servicing rules in addition to IL rules.", CommandOptionType.NoValue);
            CommandOption outFilePath = app.Option("-o|--out", "Output file path. Default is the console.", CommandOptionType.SingleValue);
            CommandOption leftOperand = app.Option("-l|--left-operand", "Name for left operand in comparison, default is 'contract'.", CommandOptionType.SingleValue);
            CommandOption rightOperand = app.Option("-r|--right-operand", "Name for right operand in comparison, default is 'implementation'.", CommandOptionType.SingleValue);
            CommandOption listRules = app.Option("--list-rules", "Outputs all the rules. If this options is supplied all other options are ignored.", CommandOptionType.NoValue);
            CommandOption remapFile = app.Option("--remap-file", "File with a list of type and/or namespace remappings to consider apply to names while diffing.", CommandOptionType.SingleValue);
            CommandOption skipGroupByAssembly = app.Option("--skip-group-by-assembly", "Skip grouping the differences by assembly instead of flattening the namespaces.", CommandOptionType.NoValue);
            CommandOption skipUnifyToLibPath = app.Option("--skip-unify-to-lib-path", "Skip unifying the assembly references to the loaded assemblies and the assemblies found in the given directories (contractDepends and implDirs).", CommandOptionType.NoValue);
            CommandOption resolveFx = app.Option("--resolve-fx", "If a contract or implementation dependency cannot be found in the given directories, fallback to try to resolve against the framework directory on the machine.", CommandOptionType.NoValue);
            CommandOption contractDepends = app.Option("--contract-depends", "Comma delimited list of directories used to resolve the dependencies of the contract assemblies.", CommandOptionType.SingleValue);
            CommandOption contractCoreAssembly = app.Option("--contract-core-assembly", "Simple name for the core assembly to use.", CommandOptionType.SingleValue);
            CommandOption ignoreDesignTimeFacades = app.Option("--ignore-design-time-facades", "Ignore design time facades in the contract set while analyzing.", CommandOptionType.NoValue);
            CommandOption warnOnIncorrectVersion = app.Option("--warn-on-incorrect-version", "Warn if the contract version number doesn't match the found implementation version number.", CommandOptionType.NoValue);
            CommandOption warnOnMissingAssemblies = app.Option("--warn-on-missing-assemblies", "Warn if the contract assembly cannot be found in the implementation directories. Default is to error and not do analysis.", CommandOptionType.NoValue);
            CommandOption excludeNonBrowsable = app.Option("--exclude-non-browsable", "When MDIL servicing rules are not being enforced, exclude validation on types that are marked with EditorBrowsable(EditorBrowsableState.Never).", CommandOptionType.NoValue);
            CommandOption excludeAttributes = app.Option("--exclude-attributes", "Comma delimited list of files with types in DocId format of which attributes to exclude.", CommandOptionType.SingleValue);
            CommandOption enforceOptionalRules = app.Option("--enforce-optional-rules", "Enforce optional rules, in addition to the mandatory set of rules.", CommandOptionType.NoValue);
            CommandOption allowDefaultInterfaceMethods = app.Option("--allow-default-interface-methods", "Allow default interface methods additions to not be considered breaks. This flag should only be used if you know your consumers support DIM", CommandOptionType.NoValue);
            CommandOption respectInternals = app.Option(
                "--respect-internals",
                "Include both internal and public APIs if assembly contains an InternalsVisibleTo attribute. Otherwise, include only public APIs.",
                CommandOptionType.NoValue);
 
            // --exclude-compiler-generated is recommended if the same option was passed to GenAPI.
            //
            // For one thing, comparing compiler-generated attributes, especially `CompilerGeneratedAttribute` itself,
            // on members leads to numerous false incompatibilities e.g. { get; set; } properties result in two
            // compiler-generated methods but GenAPI produces `{ get { throw null; } set { } }` i.e. explicit methods.
            CommandOption excludeCompilerGenerated = app.Option(
                "--exclude-compiler-generated",
                "Exclude APIs marked with a CompilerGenerated attribute.",
                CommandOptionType.NoValue);
 
            app.OnExecute(() =>
            {
                string leftOperandValue = leftOperand.HasValue() ? leftOperand.Value() : "contract";
                string rightOperandValue = rightOperand.HasValue() ? rightOperand.Value() : "implementation";
 
                if (listRules.HasValue())
                {
                    CompositionHost c = GetCompositionHost();
                    ExportCciSettings.StaticSettings = CciComparers.Default.GetEqualityComparer<ITypeReference>();
 
                    var rules = c.GetExports<IDifferenceRule>();
 
                    foreach (var rule in rules.OrderBy(r => r.GetType().Name, StringComparer.OrdinalIgnoreCase))
                    {
                        string ruleName = rule.GetType().Name;
 
                        if (IsOptionalRule(rule))
                            ruleName += " (optional)";
 
                        Console.WriteLine(ruleName);
                    }
 
                    return 0;
                }
 
                using (TextWriter output = _outStream ?? GetOutput(outFilePath.Value()))
                {
                    if (DifferenceWriter.ExitCode != 0)
                        return 0;
 
                    if (output != Console.Out)
                        Trace.Listeners.Add(new TextWriterTraceListener(output) { Filter = new EventTypeFilter(SourceLevels.Error | SourceLevels.Warning) });
 
                    try
                    {
                        BaselineDifferenceFilter filter = GetBaselineDifferenceFilter(HostEnvironment.SplitPaths(baseline.Value()), validateBaseline.HasValue());
                        NameTable sharedNameTable = new NameTable();
                        HostEnvironment contractHost = new HostEnvironment(sharedNameTable);
                        contractHost.UnableToResolve += (sender, e) => Trace.TraceError($"Unable to resolve assembly '{e.Unresolved}' referenced by the {leftOperandValue} assembly '{e.Referrer}'.");
                        contractHost.ResolveAgainstRunningFramework = resolveFx.HasValue();
                        contractHost.UnifyToLibPath = !skipUnifyToLibPath.HasValue();
                        contractHost.AddLibPaths(HostEnvironment.SplitPaths(contractDepends.Value()));
                        IEnumerable<IAssembly> contractAssemblies = contractHost.LoadAssemblies(contracts.Value, contractCoreAssembly.Value());
 
                        if (ignoreDesignTimeFacades.HasValue())
                            contractAssemblies = contractAssemblies.Where(a => !a.IsFacade());
 
                        HostEnvironment implHost = new HostEnvironment(sharedNameTable);
                        implHost.UnableToResolve += (sender, e) => Trace.TraceError($"Unable to resolve assembly '{e.Unresolved}' referenced by the {rightOperandValue} assembly '{e.Referrer}'.");
                        implHost.ResolveAgainstRunningFramework = resolveFx.HasValue();
                        implHost.UnifyToLibPath = !skipUnifyToLibPath.HasValue();
                        implHost.AddLibPaths(HostEnvironment.SplitPaths(implDirs.Value()));
                        if (warnOnMissingAssemblies.HasValue())
                            implHost.LoadErrorTreatment = ErrorTreatment.TreatAsWarning;
 
                        // The list of contractAssemblies already has the core assembly as the first one (if _contractCoreAssembly was specified).
                        IEnumerable<IAssembly> implAssemblies = implHost.LoadAssemblies(contractAssemblies.Select(a => a.AssemblyIdentity), warnOnIncorrectVersion.HasValue());
 
                        // Exit after loading if the code is set to non-zero
                        if (DifferenceWriter.ExitCode != 0)
                            return 0;
 
                        var includeInternals = respectInternals.HasValue() &&
                            contractAssemblies.Any(assembly => assembly.Attributes.HasAttributeOfType(
                                "System.Runtime.CompilerServices.InternalsVisibleToAttribute"));
                        ICciDifferenceWriter writer = GetDifferenceWriter(
                            output,
                            filter,
                            enforceOptionalRules.HasValue(),
                            mdil.HasValue(),
                            excludeNonBrowsable.HasValue(),
                            includeInternals,
                            excludeCompilerGenerated.HasValue(),
                            remapFile.Value(),
                            !skipGroupByAssembly.HasValue(),
                            leftOperandValue,
                            rightOperandValue,
                            excludeAttributes.Value(),
                            allowDefaultInterfaceMethods.HasValue());
                        writer.Write(implDirs.Value(), implAssemblies, contracts.Value, contractAssemblies);
 
                        return 0;
                    }
                    catch (FileNotFoundException)
                    {
                        // FileNotFoundException will be thrown by GetBaselineDifferenceFilter if it doesn't find the baseline file
                        // OR if GetComparers doesn't find the remap file.
                        return 2;
                    }
                }
            });
 
            return app;
        }
 
        private static ICciDifferenceWriter GetDifferenceWriter(TextWriter writer,
            IDifferenceFilter filter,
            bool enforceOptionalRules,
            bool mdil,
            bool excludeNonBrowsable,
            bool includeInternals,
            bool excludeCompilerGenerated,
            string remapFile,
            bool groupByAssembly,
            string leftOperand,
            string rightOperand,
            string excludeAttributes,
            bool allowDefaultInterfaceMethods)
        {
            CompositionHost container = GetCompositionHost();
 
            bool RuleFilter(IDifferenceRuleMetadata ruleMetadata)
            {
                if (ruleMetadata.OptionalRule && !enforceOptionalRules)
                    return false;
 
                if (ruleMetadata.MdilServicingRule && !mdil)
                    return false;
                return true;
            }
 
            if (mdil && excludeNonBrowsable)
            {
                Trace.TraceWarning("Enforcing MDIL servicing rules and exclusion of non-browsable types are both enabled, but they are not compatible so non-browsable types will not be excluded.");
            }
 
            if (includeInternals && (mdil || excludeNonBrowsable))
            {
                Trace.TraceWarning("Enforcing MDIL servicing rules or exclusion of non-browsable types are enabled " +
                    "along with including internals -- an incompatible combination. Internal members will not be included.");
            }
 
            var cciFilter = GetCciFilter(mdil, excludeNonBrowsable, includeInternals, excludeCompilerGenerated);
            var settings = new MappingSettings
            {
                Comparers = GetComparers(remapFile),
                DiffFactory = new ElementDifferenceFactory(container, RuleFilter),
                DiffFilter = GetDiffFilter(cciFilter),
                Filter = cciFilter,
                GroupByAssembly = groupByAssembly,
                IncludeForwardedTypes = true,
            };
 
            if (filter == null)
            {
                filter = new DifferenceFilter<IncompatibleDifference>();
            }
 
            var diffWriter = new DifferenceWriter(writer, settings, filter);
            ExportCciSettings.StaticSettings = settings.TypeComparer;
            ExportCciSettings.StaticOperands = new DifferenceOperands()
            {
                Contract = leftOperand,
                Implementation = rightOperand
            };
            ExportCciSettings.StaticAttributeFilter = GetAttributeFilter(HostEnvironment.SplitPaths(excludeAttributes));
            ExportCciSettings.StaticRuleSettings = new RuleSettings { AllowDefaultInterfaceMethods = allowDefaultInterfaceMethods };
 
            // Always compose the diff writer to allow it to import or provide exports
            container.SatisfyImports(diffWriter);
 
            return diffWriter;
        }
 
        private static BaselineDifferenceFilter GetBaselineDifferenceFilter(string[] baselineFileNames, bool validateBaseline)
        {
            BaselineDifferenceFilter baselineDifferenceFilter = null;
 
            AddFiles(baselineFileNames, (file) =>
                (baselineDifferenceFilter ??= new BaselineDifferenceFilter(new DifferenceFilter<IncompatibleDifference>(), validateBaseline)).AddBaselineFile(file));
 
            return baselineDifferenceFilter;
        }
 
        private static AttributeFilter GetAttributeFilter(string[] ignoreAttributeFileNames)
        {
            AttributeFilter attributeFilter = new AttributeFilter();
 
            AddFiles(ignoreAttributeFileNames, (file) => attributeFilter.AddIgnoreAttributeFile(file));
 
            return attributeFilter;
        }
 
        private static void AddFiles(string[] files, System.Action<string> addFile)
        {
            foreach (string file in files)
            {
                if (!string.IsNullOrEmpty(file))
                {
                    if (!File.Exists(file))
                    {
                        throw new FileNotFoundException("File {0} was not found!", file);
                    }
 
                    addFile(file);
                }
            }
        }
 
        private static TextWriter GetOutput(string outFilePath)
        {
            if (string.IsNullOrWhiteSpace(outFilePath))
                return Console.Out;
 
            const int NumRetries = 10;
            string exceptionMessage = null;
            for (int retries = 0; retries < NumRetries; retries++)
            {
                try
                {
                    return new StreamWriter(File.OpenWrite(outFilePath));
                }
                catch (Exception e)
                {
                    exceptionMessage = e.Message;
                    System.Threading.Thread.Sleep(100);
                }
            }
 
            Trace.TraceError("Cannot open output file '{0}': {1}", outFilePath, exceptionMessage);
            return Console.Out;
        }
 
        private static CompositionHost GetCompositionHost()
        {
            var configuration = new ContainerConfiguration().WithAssembly(typeof(Program).GetTypeInfo().Assembly);
            return configuration.CreateContainer();
        }
 
        private static ICciComparers GetComparers(string remapFile)
        {
            if (!string.IsNullOrEmpty(remapFile))
            {
                if (!File.Exists(remapFile))
                {
                    throw new FileNotFoundException("ERROR: RemapFile {0} was not found!", remapFile);
                }
                return new NamespaceRemappingComparers(remapFile);
            }
            return CciComparers.Default;
        }
 
        private static ICciFilter GetCciFilter(
            bool enforcingMdilRules,
            bool excludeNonBrowsable,
            bool includeInternals,
            bool excludeCompilerGenerated)
        {
            ICciFilter includeFilter;
            if (enforcingMdilRules)
            {
                includeFilter = new MdilPublicOnlyCciFilter()
                {
                    IncludeForwardedTypes = true
                };
            }
            else if (excludeNonBrowsable)
            {
                includeFilter = new PublicEditorBrowsableOnlyCciFilter()
                {
                    IncludeForwardedTypes = true
                };
            }
            else if (includeInternals)
            {
                includeFilter = new InternalsAndPublicCciFilter();
            }
            else
            {
                includeFilter = new PublicOnlyCciFilter()
                {
                    IncludeForwardedTypes = true
                };
            }
 
            if (excludeCompilerGenerated)
            {
                includeFilter = new IntersectionFilter(includeFilter, new ExcludeCompilerGeneratedCciFilter());
            }
 
            return includeFilter;
        }
 
        private static IMappingDifferenceFilter GetDiffFilter(ICciFilter filter) =>
            new MappingDifferenceFilter(GetIncludeFilter(), filter);
 
        private static Func<DifferenceType, bool> GetIncludeFilter() => (d => d != DifferenceType.Unchanged);
 
        private static bool IsOptionalRule(IDifferenceRule rule) =>
            rule.GetType().GetTypeInfo().GetCustomAttribute<ExportDifferenceRuleAttribute>().OptionalRule;
 
        private static string GetAssemblyVersion() => typeof(Program).Assembly.GetName().Version.ToString();
    }
}