diff --git a/src/Build.UnitTests/BackEnd/BuildRequestConfiguration_Tests.cs b/src/Build.UnitTests/BackEnd/BuildRequestConfiguration_Tests.cs index c35fc6a2b3f..940b37782ce 100644 --- a/src/Build.UnitTests/BackEnd/BuildRequestConfiguration_Tests.cs +++ b/src/Build.UnitTests/BackEnd/BuildRequestConfiguration_Tests.cs @@ -576,5 +576,112 @@ private void TestSkipIsolationConstraints(string glob, string referencePath, boo configuration.ShouldSkipIsolationConstraintsForReference(referencePath).ShouldBe(expectedOutput); } + + [Fact] + public void TestProjectEvaluationIdPreservedAcrossTranslation() + { + string projectBody = """ + + + + """.Cleanup(); + + using var collection = new ProjectCollection(); + using ProjectFromString projectFromString = new( + projectBody, + new Dictionary(), + ObjectModelHelpers.MSBuildDefaultToolsVersion, + collection); + Project project = projectFromString.Project; + project.FullPath = "foo"; + ProjectInstance instance = project.CreateProjectInstance(); + + BuildRequestConfiguration configuration = new( + new BuildRequestData(instance, [], null, BuildRequestDataFlags.None, propertiesToTransfer: []), "2.0") + { + ConfigurationId = 1, + }; + + // The evaluation ID should be set from the project instance. + int expectedEvalId = instance.EvaluationId; + configuration.ProjectEvaluationId.ShouldBe(expectedEvalId); + expectedEvalId.ShouldNotBe(BuildEventContext.InvalidEvaluationId); + + ((ITranslatable)configuration).Translate(TranslationHelpers.GetWriteTranslator()); + INodePacket packet = BuildRequestConfiguration.FactoryForDeserialization(TranslationHelpers.GetReadTranslator()); + + BuildRequestConfiguration deserializedConfig = packet as BuildRequestConfiguration; + deserializedConfig.ShouldNotBeNull(); + deserializedConfig.ProjectEvaluationId.ShouldBe(expectedEvalId); + } + + [Fact] + public void TestProjectEvaluationIdPreservedInShallowClone() + { + string projectBody = """ + + + + """.Cleanup(); + + using var collection = new ProjectCollection(); + using ProjectFromString projectFromString = new( + projectBody, + new Dictionary(), + ObjectModelHelpers.MSBuildDefaultToolsVersion, + collection); + Project project = projectFromString.Project; + project.FullPath = "foo"; + ProjectInstance instance = project.CreateProjectInstance(); + + BuildRequestConfiguration original = new(new BuildRequestData(instance, [], null), "2.0") + { + ConfigurationId = 1, + }; + + int expectedEvalId = instance.EvaluationId; + original.ProjectEvaluationId.ShouldBe(expectedEvalId); + + BuildRequestConfiguration clone = original.ShallowCloneWithNewId(2); + clone.ProjectEvaluationId.ShouldBe(expectedEvalId); + } + + + [Fact] + public void TestProjectEvaluationIdPreservedAcrossTranslateForFutureUse() + { + string projectBody = """ + + + + """.Cleanup(); + + using var collection = new ProjectCollection(); + using ProjectFromString projectFromString = new( + projectBody, + new Dictionary(), + ObjectModelHelpers.MSBuildDefaultToolsVersion, + collection); + Project project = projectFromString.Project; + project.FullPath = "foo"; + ProjectInstance instance = project.CreateProjectInstance(); + + BuildRequestConfiguration configuration = new(new BuildRequestData(instance, [], null), "2.0") + { + ConfigurationId = 1, + }; + + int expectedEvalId = instance.EvaluationId; + configuration.ProjectEvaluationId.ShouldBe(expectedEvalId); + + // TranslateForFutureUse uses a different serialization path. + configuration.TranslateForFutureUse(TranslationHelpers.GetWriteTranslator()); + ITranslator reader = TranslationHelpers.GetReadTranslator(); + + BuildRequestConfiguration deserialized = new(); + deserialized.TranslateForFutureUse(reader); + + deserialized.ProjectEvaluationId.ShouldBe(expectedEvalId); + } } } diff --git a/src/Build.UnitTests/BackEnd/BuildResult_Tests.cs b/src/Build.UnitTests/BackEnd/BuildResult_Tests.cs index 1b94ac3357f..be5d2c61db8 100644 --- a/src/Build.UnitTests/BackEnd/BuildResult_Tests.cs +++ b/src/Build.UnitTests/BackEnd/BuildResult_Tests.cs @@ -10,6 +10,7 @@ using Microsoft.Build.Execution; using Microsoft.Build.Framework; using Microsoft.Build.Unittest; +using Shouldly; using Xunit; using TaskItem = Microsoft.Build.Execution.ProjectItemInstance.TaskItem; @@ -343,6 +344,22 @@ public void TestTranslation() Assert.True(TranslationHelpers.CompareCollections(result["omega"].Items, deserializedResult["omega"].Items, TaskItemComparer.Instance)); } + [Fact] + public void TestTranslationPreservesEvaluationId() + { + BuildRequest request = new(1, 1, 2, ["Build"], null, new BuildEventContext(1, 1, 2, 3, 4, 5), null); + BuildResult result = new(request, new BuildAbortedException()) + { + EvaluationId = 42, + }; + + ((ITranslatable)result).Translate(TranslationHelpers.GetWriteTranslator()); + INodePacket packet = BuildResult.FactoryForDeserialization(TranslationHelpers.GetReadTranslator()); + BuildResult deserializedResult = (packet as BuildResult)!; + + deserializedResult.EvaluationId.ShouldBe(42); + } + private BuildRequest CreateNewBuildRequest(int configurationId, string[] targets) { return new BuildRequest(1 /* submissionId */, _nodeRequestId++, configurationId, targets, null, BuildEventContext.Invalid, null); diff --git a/src/Build.UnitTests/TerminalLogger_Tests.cs b/src/Build.UnitTests/TerminalLogger_Tests.cs index 68928d9b783..e6739296e99 100644 --- a/src/Build.UnitTests/TerminalLogger_Tests.cs +++ b/src/Build.UnitTests/TerminalLogger_Tests.cs @@ -31,7 +31,7 @@ internal sealed class MockBuildEventSink(int nodeNumber) : IBuildEventSink, IEve public bool HaveLoggedBuildFinishedEvent { get; set; } void IBuildEventSink.Consume(BuildEventArgs buildEvent, int sinkId) => (this as IBuildEventSink).Consume(buildEvent); - + void IBuildEventSink.Consume(BuildEventArgs buildEvent) { // map the incoming build event to the appropriate event handler @@ -169,7 +169,7 @@ public TerminalLogger_Tests(ITestOutputHelper outputHelper) { _outputHelper = outputHelper; _mockTerminal = new Terminal(_outputWriter); - + _terminallogger = new TerminalLogger(_mockTerminal); _terminallogger.Initialize(_centralNodeEventSource, _nodeCount); _terminallogger._createStopwatch = () => new MockStopwatch(); @@ -916,11 +916,11 @@ public void TestTerminalLoggerTogetherWithOtherLoggers() string logFileWithoutTL = env.ExpectFile(".binlog").Path; // Execute MSBuild with binary, file and terminal loggers - RunnerUtilities.ExecMSBuild($"{projectFile.Path} /bl:{logFileWithTL} -flp:logfile={Path.Combine(logFolder.Path, "logFileWithTL.log")};verbosity=diagnostic -tl:on", out bool success, outputHelper: _outputHelper); + RunnerUtilities.ExecMSBuild($"{projectFile.Path} /bl:{logFileWithTL} -flp:logfile={Path.Combine(logFolder.Path, "logFileWithTL.log")};verbosity=diagnostic -tl:on", out bool success, outputHelper: _outputHelper); success.ShouldBeTrue(); // Execute MSBuild with binary and file loggers - RunnerUtilities.ExecMSBuild($"{projectFile.Path} /bl:{logFileWithoutTL} -flp:logfile={Path.Combine(logFolder.Path, "logFileWithoutTL.log")};verbosity=diagnostic", out success, outputHelper: _outputHelper); + RunnerUtilities.ExecMSBuild($"{projectFile.Path} /bl:{logFileWithoutTL} -flp:logfile={Path.Combine(logFolder.Path, "logFileWithoutTL.log")};verbosity=diagnostic", out success, outputHelper: _outputHelper); success.ShouldBeTrue(); // Read the binary log and replay into mockLogger @@ -1029,7 +1029,7 @@ public void ReplayBinaryLogWithFewerNodesThanOriginalBuild() { // Create multiple projects that will build in parallel TransientTestFolder logFolder = env.CreateFolder(createFolder: true); - + // Create three simple projects TransientTestFile project1 = env.CreateFile(logFolder, "project1.proj", @" @@ -1037,21 +1037,21 @@ public void ReplayBinaryLogWithFewerNodesThanOriginalBuild() "); - + TransientTestFile project2 = env.CreateFile(logFolder, "project2.proj", @" "); - + TransientTestFile project3 = env.CreateFile(logFolder, "project3.proj", @" "); - + // Create a solution file that builds all projects in parallel string solutionContents = $@" @@ -1136,5 +1136,30 @@ public async Task DisplayNodesRestoresStatusAfterMSBuildTaskYields_TestProject(b await Verify(_outputWriter.ToString(), _settings).UniqueForOSPlatform().UseParameters(runOnCentralNode); } + + [Fact] + public void MetaprojProjectStartedDoesNotCrash() + { +#if DEBUG + // Metaproj files (generated for solution multi-targeting builds) are never evaluated, + // so they have no matching ProjectEvaluationFinished event. TerminalLogger should + // handle ProjectStarted for metaproj files without hitting the Debug.Assert that + // checks for prior evaluation info. In Release mode this test is a no-op because + // Debug.Assert is compiled out. + string metaprojFile = NativeMethods.IsUnixLike ? "/src/solution.sln.metaproj" : @"C:\src\solution.sln.metaproj"; + + BuildEventContext buildContext = MakeBuildEventContext(evalId: -1, projectContextId: 10); + + _centralNodeEventSource.InvokeBuildStarted(MakeBuildStartedEventArgs()); + + Should.NotThrow(() => + { + _centralNodeEventSource.InvokeProjectStarted(MakeProjectStartedEventArgs(metaprojFile, "Build", buildEventContext: buildContext)); + _centralNodeEventSource.InvokeProjectFinished(MakeProjectFinishedEventArgs(metaprojFile, true, buildEventContext: buildContext)); + }); + + _centralNodeEventSource.InvokeBuildFinished(MakeBuildFinishedEventArgs(true)); +#endif + } } } diff --git a/src/Build/BackEnd/BuildManager/BuildManager.cs b/src/Build/BackEnd/BuildManager/BuildManager.cs index 1cfaff635a1..3a871f1cca4 100644 --- a/src/Build/BackEnd/BuildManager/BuildManager.cs +++ b/src/Build/BackEnd/BuildManager/BuildManager.cs @@ -2689,6 +2689,13 @@ private void HandleResult(int node, BuildResult result) configuration.ProjectTargets ??= result.ProjectTargets; } + // Update the evaluation ID if it's valid - this propagates the eval ID + // from worker nodes to the central node for cached result scenarios. + if (result.EvaluationId != BuildEventContext.InvalidEvaluationId) + { + configuration.ProjectEvaluationId = result.EvaluationId; + } + // Only report results to the project cache services if it's the result for a build submission. // Note that graph builds create a submission for each node in the graph, so each node in the graph will be // handled here. This intentionally mirrors the behavior for cache requests, as it doesn't make sense to diff --git a/src/Build/BackEnd/Components/Logging/NodeLoggingContext.cs b/src/Build/BackEnd/Components/Logging/NodeLoggingContext.cs index e03c8ed13e7..efe2d213ddd 100644 --- a/src/Build/BackEnd/Components/Logging/NodeLoggingContext.cs +++ b/src/Build/BackEnd/Components/Logging/NodeLoggingContext.cs @@ -79,10 +79,8 @@ internal ProjectLoggingContext LogProjectStarted(BuildRequest request, BuildRequ { ErrorUtilities.VerifyThrow(this.IsValid, "Build not started."); - // If we can retrieve the evaluationId from the project, do so. Don't if it's not available or - // if we'd have to retrieve it from the cache in order to access it. - // Order is important here because the Project getter will throw if IsCached. - int evaluationId = (configuration != null && !configuration.IsCached && configuration.Project != null) ? configuration.Project.EvaluationId : BuildEventContext.InvalidEvaluationId; + // Use the persisted ProjectEvaluationId which remains available even when the project is cached. + int evaluationId = configuration?.ProjectEvaluationId ?? BuildEventContext.InvalidEvaluationId; return new ProjectLoggingContext(this, request, configuration.ProjectFullPath, configuration.ToolsVersion, evaluationId); } diff --git a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs index 5f337c99148..07e6b206528 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs @@ -867,6 +867,8 @@ private async Task RequestThreadProc(bool setThreadParameters) { ErrorUtilities.VerifyThrow(result == null, "Result already set when exception was thrown."); result = new BuildResult(_requestEntry.Request, thrownException); + // Populate the evaluation ID from the configuration for sending to the central node. + result.EvaluationId = _requestEntry.RequestConfiguration.ProjectEvaluationId; } ReportResultAndCleanUp(result); @@ -1021,7 +1023,10 @@ private BuildResult[] GetResultsForContinuation(FullyQualifiedBuildRequest[] req results = new Dictionary(); for (int i = 0; i < requests.Length; i++) { - results[i] = new BuildResult(new BuildRequest(), new BuildAbortedException()); + var abortResult = new BuildResult(new BuildRequest(), new BuildAbortedException()); + // Populate the evaluation ID from the configuration for sending to the central node. + abortResult.EvaluationId = _requestEntry.RequestConfiguration.ProjectEvaluationId; + results[i] = abortResult; } } @@ -1244,6 +1249,9 @@ private async Task BuildProject() BuildResult result = await _targetBuilder.BuildTargets(_projectLoggingContext, _requestEntry, this, allTargets, _requestEntry.RequestConfiguration.BaseLookup, _cancellationTokenSource.Token); + // Populate the evaluation ID from the configuration for sending to the central node. + result.EvaluationId = _requestEntry.RequestConfiguration.ProjectEvaluationId; + UpdateStatisticsPostBuild(); result = _requestEntry.Request.ProxyTargets == null @@ -1334,7 +1342,7 @@ private void UpdateStatisticsPostBuild() bool isFromNuget, isMetaprojTarget, isCustom; - if (IsMetaprojTargetPath(projectTargetInstance.Value.FullPath)) + if (FileUtilities.IsMetaprojectFilename(projectTargetInstance.Value.FullPath)) { isMetaprojTarget = true; isFromNuget = false; @@ -1388,8 +1396,6 @@ void CollectTasksStats(TaskRegistry taskRegistry) } } - private static bool IsMetaprojTargetPath(string targetPath) => targetPath.EndsWith(".metaproj", StringComparison.OrdinalIgnoreCase); - /// /// Saves the current operating environment (working directory and environment variables) /// from the request's to the configuration for later restoration. diff --git a/src/Build/BackEnd/Shared/BuildRequestConfiguration.cs b/src/Build/BackEnd/Shared/BuildRequestConfiguration.cs index 2d40523d648..8d1dc211da6 100644 --- a/src/Build/BackEnd/Shared/BuildRequestConfiguration.cs +++ b/src/Build/BackEnd/Shared/BuildRequestConfiguration.cs @@ -140,6 +140,11 @@ internal class BuildRequestConfiguration : IEquatable /// private string _savedCurrentDirectory; + /// + /// Saves the evaluation ID for the project so that it's accessible even when the underlying Project becomes cached. + /// + private int _projectEvaluationId = BuildEventContext.InvalidEvaluationId; + #endregion /// @@ -186,6 +191,7 @@ internal BuildRequestConfiguration(int configId, BuildRequestData data, string d _projectInitialTargets = data.ProjectInstance.InitialTargets; _projectDefaultTargets = data.ProjectInstance.DefaultTargets; _projectTargets = GetProjectTargets(data.ProjectInstance.Targets); + _projectEvaluationId = data.ProjectInstance.EvaluationId; if (data.PropertiesToTransfer != null) { _transferredProperties = new List(); @@ -223,6 +229,7 @@ internal BuildRequestConfiguration(int configId, ProjectInstance instance) _projectInitialTargets = instance.InitialTargets; _projectDefaultTargets = instance.DefaultTargets; _projectTargets = GetProjectTargets(instance.Targets); + _projectEvaluationId = instance.EvaluationId; IsCacheable = false; } @@ -247,6 +254,7 @@ private BuildRequestConfiguration(int configId, BuildRequestConfiguration other) IsCacheable = other.IsCacheable; _configId = configId; RequestedTargets = other.RequestedTargets; + _projectEvaluationId = other._projectEvaluationId; } /// @@ -289,6 +297,15 @@ internal BuildRequestConfiguration() /// public bool IsCached { get; private set; } + /// + /// The evaluation ID for this project, persisted so it remains available even when the project is cached. + /// + public int ProjectEvaluationId + { + get => _projectEvaluationId; + internal set => _projectEvaluationId = value; + } + /// /// Flag indicating if this configuration represents a traversal project. Traversal projects /// are projects which typically do little or no work themselves, but have references to other @@ -424,6 +441,7 @@ private void SetProjectBasedState(ProjectInstance project) _projectInitialTargets = null; _projectTargets = null; + _projectEvaluationId = _project.EvaluationId; ProjectDefaultTargets = _project.DefaultTargets; ProjectInitialTargets = _project.InitialTargets; ProjectTargets = GetProjectTargets(_project.Targets); @@ -941,6 +959,7 @@ public void Translate(ITranslator translator) translator.Translate(ref _resultsNodeId); translator.Translate(ref _savedCurrentDirectory); translator.TranslateDictionary(ref _savedEnvironmentVariables, CommunicationsUtilities.EnvironmentVariableComparer); + translator.Translate(ref _projectEvaluationId); // if the entire state is translated, then the transferred state represents the full evaluation data if (translator.Mode == TranslationDirection.ReadFromStream && _transferredState?.TranslateEntireState == true) @@ -959,6 +978,7 @@ internal void TranslateForFutureUse(ITranslator translator) translator.Translate(ref _projectInitialTargets); translator.Translate(ref _projectTargets); translator.TranslateDictionary(ref _globalProperties, ProjectPropertyInstance.FactoryForDeserialization); + translator.Translate(ref _projectEvaluationId); } /// diff --git a/src/Build/BackEnd/Shared/BuildResult.cs b/src/Build/BackEnd/Shared/BuildResult.cs index 87f110cb8d8..e657cfc2e2c 100644 --- a/src/Build/BackEnd/Shared/BuildResult.cs +++ b/src/Build/BackEnd/Shared/BuildResult.cs @@ -86,7 +86,7 @@ public class BuildResult : BuildResultBase, INodePacket, IBuildResults /// /// Allows to serialize and deserialize different versions of the build result. /// - private int _version = Traits.Instance.EscapeHatches.DoNotVersionBuildResult ? 0 : 1; + private int _version = Traits.Instance.EscapeHatches.DoNotVersionBuildResult ? 0 : 2; /// /// The request caused a circular dependency in scheduling. @@ -145,6 +145,11 @@ public class BuildResult : BuildResultBase, INodePacket, IBuildResults /// private BuildRequestDataFlags _buildRequestDataFlags; + /// + /// The evaluation ID of the project used for this build. + /// + private int _evaluationId = BuildEventContext.InvalidEvaluationId; + private string? _schedulerInducedError; private HashSet? _projectTargets; @@ -426,6 +431,17 @@ public ProjectInstance? ProjectStateAfterBuild /// public BuildRequestDataFlags? BuildRequestDataFlags => (_version > 0) ? _buildRequestDataFlags : null; + /// + /// The evaluation ID of the project used for this build. + /// + internal int EvaluationId + { + [DebuggerStepThrough] + get => _evaluationId; + [DebuggerStepThrough] + set => _evaluationId = value; + } + /// /// Returns the node packet type. /// @@ -695,6 +711,12 @@ void ITranslatable.Translate(ITranslator translator) { translator.TranslateEnum(ref _buildRequestDataFlags, (int)_buildRequestDataFlags); } + + // Starting version 2 the _evaluationId field is present. + if (_version >= 2) + { + translator.Translate(ref _evaluationId); + } } /// diff --git a/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs b/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs index 0c9dcb341d1..bd1d71a89f5 100644 --- a/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs +++ b/src/Build/BuildCheck/Infrastructure/BuildCheckBuildEventHandler.cs @@ -70,7 +70,7 @@ private void HandleBuildSubmissionStartedEvent(BuildSubmissionStartedEventArgs e private void HandleProjectEvaluationFinishedEvent(ProjectEvaluationFinishedEventArgs eventArgs) { - if (!IsMetaProjFile(eventArgs.ProjectFile)) + if (!FileUtilities.IsMetaprojectFilename(eventArgs.ProjectFile)) { _buildCheckManager.ProcessEvaluationFinishedEventArgs( _checkContextFactory.CreateCheckContext(eventArgs.BuildEventContext!), @@ -82,7 +82,7 @@ private void HandleProjectEvaluationFinishedEvent(ProjectEvaluationFinishedEvent private void HandleProjectEvaluationStartedEvent(ProjectEvaluationStartedEventArgs eventArgs) { - if (!IsMetaProjFile(eventArgs.ProjectFile)) + if (!FileUtilities.IsMetaprojectFilename(eventArgs.ProjectFile)) { var checkContext = _checkContextFactory.CreateCheckContext(eventArgs.BuildEventContext!); _buildCheckManager.ProjectFirstEncountered( @@ -145,8 +145,6 @@ private void HandleProjectImportedEvent(ProjectImportedEventArgs eventArgs) _checkContextFactory.CreateCheckContext(GetBuildEventContext(eventArgs)), eventArgs); - private bool IsMetaProjFile(string? projectFile) => projectFile?.EndsWith(".metaproj", StringComparison.OrdinalIgnoreCase) == true; - private readonly BuildCheckTracingData _tracingData = new BuildCheckTracingData(); private void HandleBuildFinishedEvent(BuildFinishedEventArgs eventArgs) diff --git a/src/Build/Logging/TerminalLogger/TerminalLogger.cs b/src/Build/Logging/TerminalLogger/TerminalLogger.cs index d7903825030..950bafb7025 100644 --- a/src/Build/Logging/TerminalLogger/TerminalLogger.cs +++ b/src/Build/Logging/TerminalLogger/TerminalLogger.cs @@ -725,12 +725,18 @@ private void ProjectStarted(object sender, ProjectStartedEventArgs e) EvalContext evalContext = new(e.BuildEventContext); string? targetFramework = null; string? runtimeIdentifier = null; + if (_projectEvaluations.TryGetValue(evalContext, out EvalProjectInfo evalInfo)) { targetFramework = evalInfo.TargetFramework; runtimeIdentifier = evalInfo.RuntimeIdentifier; } - System.Diagnostics.Debug.Assert(evalInfo != default, "EvalProjectInfo should have been captured before ProjectStarted"); + + // Per-project metaproj files (e.g. MyProject.csproj.metaproj) are constructed + // directly without evaluation, so they won't have a matching ProjectEvaluationFinished event. + System.Diagnostics.Debug.Assert( + evalInfo != default || FileUtilities.IsMetaprojectFilename(e.ProjectFile), + "EvalProjectInfo should have been captured before ProjectStarted"); TerminalProjectInfo projectInfo = new(c, evalInfo, _createStopwatch?.Invoke()); _projects[c] = projectInfo; diff --git a/src/Framework/FileUtilities.cs b/src/Framework/FileUtilities.cs index c5bd6fcf376..b3e48a7ad1a 100644 --- a/src/Framework/FileUtilities.cs +++ b/src/Framework/FileUtilities.cs @@ -1340,7 +1340,7 @@ internal static bool IsDspFilename(string filename) /// /// Returns true if the specified filename is a metaproject file (.metaproj), otherwise false. /// - internal static bool IsMetaprojectFilename(string filename) + internal static bool IsMetaprojectFilename(string? filename) { return HasExtension(filename, ".metaproj"); } @@ -1350,14 +1350,14 @@ internal static bool IsBinaryLogFilename(string filename) return HasExtension(filename, ".binlog"); } - private static bool HasExtension(string filename, string extension) + private static bool HasExtension(string? filename, string extension) { if (String.IsNullOrEmpty(filename)) { return false; } - return filename.EndsWith(extension, PathComparison); + return filename!.EndsWith(extension, PathComparison); } ///