File: Commands\Run\FileBasedAppRunPlan.cs
Web Access
Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.DotNet.Cli.Utils;

namespace Microsoft.DotNet.Cli.Commands.Run;

/// <summary>
/// Computes shared managed and Native AOT build and launch decisions for file-based applications.
/// </summary>
internal static class FileBasedAppRunPlan
{
    /// <summary>The marker written when a file-based application build starts.</summary>
    internal const string BuildStartCacheFileName = "build-start.cache";

    /// <summary>The cache entry written after a successful file-based application build.</summary>
    internal const string BuildSuccessCacheFileName = "build-success.cache";

    /// <summary>
    /// <c>IsMSBuildFile</c> is <see langword="true"/> if the presence of the implicit build file
    /// implies that CSC is not enough and MSBuild is needed to build the project, i.e., the file
    /// alone can affect MSBuild props or targets.
    /// </summary>
    /// <remarks>
    /// For example, the simple programs our CSC optimized path handles do not need NuGet restore,
    /// hence we can ignore NuGet config files.
    /// </remarks>
    private static readonly ImmutableArray<(string Name, bool IsMSBuildFile)> s_implicitBuildFiles =
    [
        ("global.json", false),

        // All these casings are recognized on case-sensitive platforms:
        // https://github.com/NuGet/NuGet.Client/blob/ab6b96fd9ba07ed3bf629ee389799ca4fb9a20fb/src/NuGet.Core/NuGet.Configuration/Settings/Settings.cs#L32-L37
        ("nuget.config", false),
        ("NuGet.config", false),
        ("NuGet.Config", false),

        ("Directory.Build.props", true),
        ("Directory.Build.targets", true),
        ("Directory.Packages.props", true),
        ("Directory.Build.rsp", true),
        ("MSBuild.rsp", true),
    ];

    /// <summary>
    /// For purposes of determining whether CSC is enough to build as opposed to full MSBuild,
    /// we can ignore properties that do not affect the build on their own.
    /// See also the <c>IsMSBuildFile</c> flag in <see cref="s_implicitBuildFiles"/>.
    /// </summary>
    /// <remarks>
    /// This is an <see cref="IEnumerable{T}"/> rather than <see cref="ImmutableArray{T}"/> to avoid boxing at the use site.
    /// </remarks>
    private static readonly IEnumerable<string> s_ignorableProperties =
    [
        // These are set by default by `dotnet run`, so at least these must be ignored otherwise the CSC optimization would not kick in by default.
        "NuGetInteractive",
        "_BuildNonexistentProjectsByDefault",
        "RestoreUseSkipNonexistentTargets",
        "ProvideCommandLineArgs",
    ];

    /// <summary>
    /// Computes the build level required by the current file-based application inputs.
    /// </summary>
    /// <param name="inputs">The current planning inputs.</param>
    /// <returns>The selected run plan.</returns>
    internal static RunPlan Analyze(FileBasedAppRunPlanInputs inputs)
    {
        BuildLevel buildLevel = AnalyzeBuildLevel(inputs, out FileBasedAppCacheInfo? cache);
        return buildLevel switch
        {
            BuildLevel.None => new RunPlan(RunTier.CachedLaunch, RunDecisionReason.CacheValid, cache),
            BuildLevel.Csc => new RunPlan(RunTier.DirectCompile, RunDecisionReason.DirectCompilationRequired, cache),
            BuildLevel.All => new RunPlan(RunTier.MSBuildBuild, RunDecisionReason.FullBuildRequired, cache),
            _ => throw new ArgumentOutOfRangeException(nameof(buildLevel)),
        };
    }

    /// <summary>
    /// Determines whether the Native AOT no-build path can reuse a synthetic CSC cache without full cache validation.
    /// </summary>
    /// <param name="entryPointFileFullPath">The fully qualified entry-point path.</param>
    /// <param name="artifactsPath">The application artifacts directory.</param>
    /// <returns>A launch-only plan when eligible; otherwise, a managed-fallback plan.</returns>
    internal static RunPlan AnalyzeAotNoBuildSynthetic(
        string entryPointFileFullPath,
        string artifactsPath)
    {
        var successCacheFile = new FileInfo(Path.Join(artifactsPath, BuildSuccessCacheFileName));
        if (!successCacheFile.Exists)
        {
            Reporter.Verbose.WriteLine("Falling back to the managed CLI because the build success cache does not exist.");
            return new RunPlan(RunTier.ManagedFallback, RunDecisionReason.NoBuildNotEligible, Cache: null);
        }

        RunFileBuildCacheEntry? previousEntry = ReadCacheEntry(successCacheFile.FullName);
        if (previousEntry is not
            {
                BuildLevel: BuildLevel.Csc,
                Run: null,
                BuildResultFile: null,
            } ||
            !previousEntry.CscArguments.IsDefaultOrEmpty)
        {
            Reporter.Verbose.WriteLine("Falling back to the managed CLI because the previous build was not synthetic CSC.");
            return new RunPlan(RunTier.ManagedFallback, RunDecisionReason.NoBuildNotEligible, Cache: null);
        }

        if (!File.Exists(entryPointFileFullPath))
        {
            Reporter.Verbose.WriteLine("Falling back to the managed CLI because the entry point file is missing.");
            return new RunPlan(RunTier.ManagedFallback, RunDecisionReason.NoBuildNotEligible, Cache: null);
        }

        return new RunPlan(
            RunTier.LaunchOnly,
            RunDecisionReason.NoBuildSyntheticCache,
            Cache: null,
            new FileBasedAppLaunchInfo(
                GetCscBuiltProgramLaunchArtifacts(entryPointFileFullPath, artifactsPath).AppHost,
                artifactsPath));
    }

    /// <summary>
    /// Validates an authoritative cache entry and produces its launch contract when still current.
    /// </summary>
    /// <param name="entryPointFileFullPath">The fully qualified entry-point path.</param>
    /// <param name="artifactsPath">The application artifacts directory.</param>
    /// <param name="globalProperties">The effective global properties.</param>
    /// <param name="sdkVersion">The current SDK version.</param>
    /// <param name="runtimeVersion">The current runtime version.</param>
    /// <returns>A cached-launch plan when valid; otherwise, a managed-fallback plan.</returns>
    internal static RunPlan AnalyzeCachedLaunch(
        string entryPointFileFullPath,
        string artifactsPath,
        Dictionary<string, string> globalProperties,
        string sdkVersion,
        string runtimeVersion)
    {
        string successCachePath = Path.Join(artifactsPath, BuildSuccessCacheFileName);
        RunFileBuildCacheEntry? previousEntry = ReadCacheEntry(successCachePath);
        if (previousEntry is null)
        {
            return new RunPlan(RunTier.ManagedFallback, RunDecisionReason.CachedLaunchNotEligible, Cache: null);
        }

        var inputs = new FileBasedAppRunPlanInputs(
            EntryPointFileFullPath: entryPointFileFullPath,
            ArtifactsPath: artifactsPath,
            GlobalProperties: globalProperties,
            CanCache: true,
            Directives: previousEntry.Directives,
            SdkVersion: sdkVersion,
            RuntimeVersion: runtimeVersion,
            NoCache: false,
            GetCscInputPaths: static () => []);
        RunPlan analyzedPlan = Analyze(inputs);
        if (analyzedPlan is not { Tier: RunTier.CachedLaunch, Cache.PreviousEntry: { } validatedEntry } ||
            !validatedEntry.Directives.SequenceEqual(previousEntry.Directives))
        {
            return new RunPlan(
                RunTier.ManagedFallback,
                RunDecisionReason.CachedLaunchNotEligible,
                analyzedPlan.Cache);
        }

        if (validatedEntry.Run is { Command.Length: > 0 } runProperties)
        {
            return analyzedPlan with
            {
                Launch = new FileBasedAppLaunchInfo(runProperties.Command, artifactsPath, runProperties),
            };
        }

        if (validatedEntry is
            {
                BuildLevel: BuildLevel.Csc,
                Run: null,
                BuildResultFile: null,
            } &&
            validatedEntry.CscArguments.IsDefaultOrEmpty)
        {
            var launchArtifacts = GetCscBuiltProgramLaunchArtifacts(entryPointFileFullPath, artifactsPath);
            return analyzedPlan with
            {
                Launch = new FileBasedAppLaunchInfo(launchArtifacts.AppHost, artifactsPath),
            };
        }

        return new RunPlan(
            RunTier.ManagedFallback,
            RunDecisionReason.CachedLaunchNotEligible,
            analyzedPlan.Cache);
    }

    private static BuildLevel AnalyzeBuildLevel(
        FileBasedAppRunPlanInputs inputs,
        out FileBasedAppCacheInfo? cache)
    {
        if (inputs.NoCache)
        {
            Reporter.Verbose.WriteLine("Building because --no-cache was specified.");
            cache = ComputeCacheEntry(inputs);
            return BuildLevel.All;
        }

        if (!NeedsToBuild(inputs, out cache))
        {
            Reporter.Verbose.WriteLine("No need to build, the output is up to date. Cache: " + inputs.ArtifactsPath);
            return BuildLevel.None;
        }

        if (cache is null)
        {
            return BuildLevel.All;
        }

        if (cache.CanUseCscViaPreviousArguments)
        {
            Reporter.Verbose.WriteLine("We have CSC arguments from previous run. Skipping MSBuild and using CSC only.");

            // Keep the cached info for next time, so we can use CSC again.
            Debug.Assert(cache.PreviousEntry != null);
            cache.CurrentEntry.CscArguments = cache.PreviousEntry.CscArguments;
            cache.CurrentEntry.BuildResultFile = cache.PreviousEntry.BuildResultFile;
            cache.CurrentEntry.Run = cache.PreviousEntry.Run;
            return BuildLevel.Csc;
        }

        // Determine whether we can use CSC only or need to use MSBuild.
        RunFileBuildCacheEntry cacheEntry = cache.CurrentEntry;
        if (!cacheEntry.Directives.IsDefaultOrEmpty)
        {
            Reporter.Verbose.WriteLine("Using MSBuild because there are directives in the source file.");
            return BuildLevel.All;
        }

        var globalProperties = cacheEntry.GlobalProperties.Keys.Except(s_ignorableProperties, cacheEntry.GlobalProperties.Comparer);
        if (globalProperties.FirstOrDefault() is { } exampleKey)
        {
            string exampleValue = cacheEntry.GlobalProperties[exampleKey];
            Reporter.Verbose.WriteLine($"Using MSBuild because there are global properties, for example '{exampleKey}={exampleValue}'.");
            return BuildLevel.All;
        }

        if (cache.ExampleMSBuildFile is { } exampleMSBuildFile)
        {
            Debug.Assert(cacheEntry.ImplicitBuildFiles.Count != 0);
            Reporter.Verbose.WriteLine($"Using MSBuild because there are implicit build files, for example '{exampleMSBuildFile}'.");
            return BuildLevel.All;
        }

        foreach (string filePath in inputs.GetCscInputPaths())
        {
            if (!File.Exists(filePath))
            {
                Reporter.Verbose.WriteLine($"Using MSBuild because NuGet package file does not exist: {filePath}");
                return BuildLevel.All;
            }
        }

        Reporter.Verbose.WriteLine("Skipping MSBuild and using CSC only.");

        // Don't reuse CSC arguments, this is the simple CSC-only build where we use hard-coded CSC arguments.
        if (cache.PreviousEntry != null)
        {
            // If we reused CSC arguments in the previous run and want to use hard-coded CSC arguments
            // in this run, we cannot reuse the csc.rsp file.
            if (!cache.PreviousEntry.CscArguments.IsDefaultOrEmpty)
            {
                cache.InitialCanReuseAuxiliaryFiles = false;
            }

            cache.PreviousEntry.CscArguments = [];
            cache.PreviousEntry.BuildResultFile = null;
            cache.PreviousEntry.Run = null;
        }

        return BuildLevel.Csc;
    }

    /// <summary>
    /// Reads a successful-build cache entry.
    /// </summary>
    /// <param name="path">The cache file path.</param>
    /// <returns>The deserialized entry, or <see langword="null"/> when it cannot be read.</returns>
    internal static RunFileBuildCacheEntry? ReadCacheEntry(string path)
    {
        try
        {
            using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
            return JsonSerializer.Deserialize(stream, RunFileBuildCacheJsonSerializerContext.Default.RunFileBuildCacheEntry);
        }
        catch (Exception exception)
        {
            Reporter.Verbose.WriteLine($"Failed to deserialize cache entry ({path}): {exception.GetType().FullName}: {exception.Message}");
            return null;
        }
    }

    /// <summary>
    /// Collects implicit files that can affect a file-based application build while walking ancestor directories.
    /// </summary>
    /// <param name="startDirectory">The entry-point directory.</param>
    /// <param name="collectedPaths">Receives full paths of discovered implicit files.</param>
    /// <param name="exampleMSBuildFile">Receives one discovered file whose presence requires MSBuild.</param>
    internal static void CollectImplicitBuildFiles(
        DirectoryInfo startDirectory,
        HashSet<string> collectedPaths,
        out string? exampleMSBuildFile)
    {
        exampleMSBuildFile = null;
        for (DirectoryInfo? directory = startDirectory; directory != null; directory = directory.Parent)
        {
            foreach (var implicitBuildFile in s_implicitBuildFiles)
            {
                string implicitBuildFilePath = Path.Join(directory.FullName, implicitBuildFile.Name);
                if (File.Exists(implicitBuildFilePath))
                {
                    collectedPaths.Add(implicitBuildFilePath);
                    if (implicitBuildFile.IsMSBuildFile && exampleMSBuildFile is null)
                    {
                        exampleMSBuildFile = implicitBuildFilePath;
                    }
                }
            }
        }
    }

    /// <summary>
    /// Gets the synthetic CSC launch artifacts for a file-based application.
    /// </summary>
    /// <param name="entryPointFileFullPath">The fully qualified entry-point path.</param>
    /// <param name="artifactsPath">The application artifacts directory.</param>
    /// <returns>The apphost, assembly, and runtime-configuration paths.</returns>
    internal static (string AppHost, string Assembly, string RuntimeConfig) GetCscBuiltProgramLaunchArtifacts(
        string entryPointFileFullPath,
        string artifactsPath)
    {
        string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(entryPointFileFullPath);
        string binDirectory = Path.Join(artifactsPath, "bin", "debug");
        return (
            Path.Join(binDirectory, fileNameWithoutExtension + FileNameSuffixes.CurrentPlatform.Exe),
            Path.Join(binDirectory, fileNameWithoutExtension + ".dll"),
            Path.Join(binDirectory, fileNameWithoutExtension + FileNameSuffixes.RuntimeConfigJson));
    }

    /// <summary>
    /// Touching the artifacts folder ensures it is considered recently used and not removed by
    /// <see cref="Clean.FileBasedAppArtifacts.CleanFileBasedAppArtifactsCommand"/>.
    /// </summary>
    /// <param name="artifactsPath">The application artifacts directory.</param>
    internal static void MarkArtifactsPathUsed(string artifactsPath)
    {
        try
        {
            Directory.SetLastWriteTimeUtc(artifactsPath, DateTime.UtcNow);
        }
        catch (Exception exception)
        {
            Reporter.Verbose.WriteLine($"Cannot touch folder '{artifactsPath}': {exception}");
        }
    }

    /// <summary>
    /// Compute current cache entry - we need to do this always (except if we already know we will skip saving the cache):
    /// <list type="bullet">
    /// <item>if we can skip build, we still need to check everything in the cache entry (e.g., implicit build files)</item>
    /// <item>if we have to build, we need to have the cache entry to write it to the success cache file</item>
    /// </list>
    /// </summary>
    private static FileBasedAppCacheInfo? ComputeCacheEntry(FileBasedAppRunPlanInputs inputs)
    {
        if (!inputs.CanCache)
        {
            Reporter.Verbose.WriteLine("Skipping computing cache because there are project or ref directives.");
            return null;
        }

        var cacheEntry = new RunFileBuildCacheEntry(inputs.GlobalProperties)
        {
            Directives = inputs.Directives,
            SdkVersion = inputs.SdkVersion,
            RuntimeVersion = inputs.RuntimeVersion,
        };
        var entryPointFile = new FileInfo(inputs.EntryPointFileFullPath);
        DirectoryInfo? entryPointFileDirectory = entryPointFile.Directory;
        Debug.Assert(entryPointFileDirectory != null);
        CollectImplicitBuildFiles(entryPointFileDirectory, cacheEntry.ImplicitBuildFiles, out string? exampleMSBuildFile);

        return new FileBasedAppCacheInfo
        {
            EntryPointFile = entryPointFile,
            CurrentEntry = cacheEntry,
            ExampleMSBuildFile = exampleMSBuildFile,
        };
    }

    private static bool NeedsToBuild(
        FileBasedAppRunPlanInputs inputs,
        [NotNullWhen(returnValue: false)] out FileBasedAppCacheInfo? cache)
    {
        cache = ComputeCacheEntry(inputs);
        if (cache is null)
        {
            return true;
        }

        // Check cache files.
        var successCacheFile = new FileInfo(Path.Join(inputs.ArtifactsPath, BuildSuccessCacheFileName));
        if (!successCacheFile.Exists)
        {
            Reporter.Verbose.WriteLine("Building because cache file does not exist: " + successCacheFile.FullName);
            return true;
        }

        var startCacheFile = new FileInfo(Path.Join(inputs.ArtifactsPath, BuildStartCacheFileName));
        if (!startCacheFile.Exists)
        {
            Reporter.Verbose.WriteLine("Building because start cache file does not exist: " + startCacheFile.FullName);
            return true;
        }

        DateTime buildTimeUtc = successCacheFile.LastWriteTimeUtc;
        if (startCacheFile.LastWriteTimeUtc > buildTimeUtc)
        {
            Reporter.Verbose.WriteLine("Building because start cache file is newer than success cache file (previous build likely failed): " + startCacheFile.FullName);
            return true;
        }

        Debug.Assert(!cache.TriedDeserializingPreviousEntry);
        RunFileBuildCacheEntry? previousCacheEntry = ReadCacheEntry(successCacheFile.FullName);
        cache.TriedDeserializingPreviousEntry = true;
        if (previousCacheEntry is null)
        {
            cache.InitialCanReuseAuxiliaryFiles = false;
            Reporter.Verbose.WriteLine("Building because previous cache entry could not be deserialized: " + successCacheFile.FullName);
            return true;
        }

        cache.PreviousEntry = previousCacheEntry;
        RunFileBuildCacheEntry cacheEntry = cache.CurrentEntry;
        if (previousCacheEntry.Run is { Command: { } previousRunCommand } &&
            Path.IsPathFullyQualified(previousRunCommand) &&
            !File.Exists(previousRunCommand))
        {
            Reporter.Verbose.WriteLine("Building because the run output is missing: " + previousRunCommand);
            return true;
        }

        // Check that versions match.
        if (previousCacheEntry.SdkVersion != cacheEntry.SdkVersion)
        {
            cache.InitialCanReuseAuxiliaryFiles = false;
            Reporter.Verbose.WriteLine($"Building because previous SDK version ({previousCacheEntry.SdkVersion}) does not match current ({cacheEntry.SdkVersion}): {successCacheFile.FullName}");
            return true;
        }

        if (previousCacheEntry.RuntimeVersion != cacheEntry.RuntimeVersion)
        {
            cache.InitialCanReuseAuxiliaryFiles = false;
            Reporter.Verbose.WriteLine($"Building because previous runtime version ({previousCacheEntry.RuntimeVersion}) does not match current ({cacheEntry.RuntimeVersion}): {successCacheFile.FullName}");
            return true;
        }

        // Check that properties match.
        if (previousCacheEntry.GlobalProperties.Count != cacheEntry.GlobalProperties.Count)
        {
            Reporter.Verbose.WriteLine($"Building because previous global properties count ({previousCacheEntry.GlobalProperties.Count}) does not match current count ({cacheEntry.GlobalProperties.Count}): {successCacheFile.FullName}");
            return true;
        }

        foreach ((string key, string value) in cacheEntry.GlobalProperties)
        {
            if (!previousCacheEntry.GlobalProperties.TryGetValue(key, out string? otherValue) || value != otherValue)
            {
                Reporter.Verbose.WriteLine($"Building because previous global property \"{key}\" ({otherValue}) does not match current ({value}): {successCacheFile.FullName}");
                return true;
            }
        }

        FileInfo entryPointFile = cache.EntryPointFile;
        // If the source file does not exist, we want to build so proper errors are reported.
        if (!entryPointFile.Exists)
        {
            Reporter.Verbose.WriteLine("Building because entry point file is missing: " + entryPointFile.FullName);
            return true;
        }

        string? reasonToNotReuseCscArguments = GetReasonToNotReuseCscArguments(cache);
        FileSystemInfo targetFile = ResolveLinkTargetOrSelf(entryPointFile);
        // Check that the source file is not modified.
        // Only do this here if we cannot reuse CSC arguments (then checking this first is faster); otherwise we need to check implicit build files anyway.
        if (reasonToNotReuseCscArguments != null && targetFile.LastWriteTimeUtc > buildTimeUtc)
        {
            Reporter.Verbose.WriteLine("Compiling because entry point file is modified: " + targetFile.FullName);
            Reporter.Verbose.WriteLine(reasonToNotReuseCscArguments);
            return true;
        }

        // Check that implicit build files are not modified.
        foreach (string implicitBuildFilePath in previousCacheEntry.ImplicitBuildFiles)
        {
            FileSystemInfo implicitBuildFileInfo = ResolveLinkTargetOrSelf(new FileInfo(implicitBuildFilePath));
            if (!implicitBuildFileInfo.Exists || implicitBuildFileInfo.LastWriteTimeUtc > buildTimeUtc)
            {
                Reporter.Verbose.WriteLine("Building because implicit build file is missing or modified: " + implicitBuildFileInfo.FullName);
                return true;
            }
        }

        // Check that no new implicit build files are present.
        foreach (string implicitBuildFilePath in cacheEntry.ImplicitBuildFiles)
        {
            if (!previousCacheEntry.ImplicitBuildFiles.Contains(implicitBuildFilePath))
            {
                Reporter.Verbose.WriteLine("Building because new implicit build file is present: " + implicitBuildFilePath);
                return true;
            }
        }

        // Check that additional sources are not modified.
        // NOTE: We currently don't support the CSC-arg-reuse optimization through additional sources (i.e., we don't set `CanUseCscViaPreviousArguments=true` here).
        //       If that changes, we will also need to make sure `RunFileBuildCacheEntry.Directives` contains directives from other files
        //       (as that is used to determine whether we can reuse CSC args, see `GetReasonToNotReuseCscArguments`).
        foreach (string additionalSourcePath in previousCacheEntry.AdditionalSources)
        {
            FileSystemInfo additionalSourceFileInfo = ResolveLinkTargetOrSelf(new FileInfo(additionalSourcePath));
            if (!additionalSourceFileInfo.Exists || additionalSourceFileInfo.LastWriteTimeUtc > buildTimeUtc)
            {
                Reporter.Verbose.WriteLine("Building because additional source file is missing or modified: " + additionalSourceFileInfo.FullName);
                return true;
            }
        }

        // This must remain the last stale-input check before enabling replayed CSC arguments.
        if (reasonToNotReuseCscArguments == null && targetFile.LastWriteTimeUtc > buildTimeUtc)
        {
            cache.CanUseCscViaPreviousArguments = true;
            Reporter.Verbose.WriteLine("Compiling because entry point file is modified: " + targetFile.FullName);
            return true;
        }

        return false;
    }

    private static FileSystemInfo ResolveLinkTargetOrSelf(FileSystemInfo fileSystemInfo)
    {
        if (!fileSystemInfo.Exists)
        {
            return fileSystemInfo;
        }

        return fileSystemInfo.ResolveLinkTarget(returnFinalTarget: true) ?? fileSystemInfo;
    }

    private static string? GetReasonToNotReuseCscArguments(FileBasedAppCacheInfo cache)
    {
        if (cache.PreviousEntry?.CscArguments.IsDefaultOrEmpty != false)
        {
            return "No CSC arguments from previous run.";
        }
        else if (cache.PreviousEntry.Run == null)
        {
            return "We have CSC arguments but not run properties. That's unexpected.";
        }
        else if (cache.PreviousEntry.BuildResultFile == null)
        {
            return "We have CSC arguments but not build result file. That's unexpected.";
        }
        else if (!cache.PreviousEntry.Directives.SequenceEqual(cache.CurrentEntry.Directives))
        {
            return "Cannot use CSC arguments from previous run because directives changed.";
        }

        return null;
    }
}