From 129eb42f3e08c4727d0f13a30159287da48f5638 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:34:28 +0200 Subject: [PATCH] Adopt MSBuild partial ProjectInstance evaluation in CLI hot paths Use ProjectInstance.FromFile/FromProjectRootElement with a ProjectOptions EvaluationStage (Properties or Items) to stop evaluation after the pass that produces the data each site needs, avoiding unnecessary full evaluation. ProjectInstance is uncached and read-only, so partial instances cannot be silently upgraded to full or serve stale state (unlike the mutable, ProjectCollection-cached Project type). - MsbuildProject/ReleasePropertyProjectLocator: stage Properties. - ReferenceListCommand/SolutionAddCommand: stage Items. - Add ProjectInstance overloads of GetRuntimeIdentifiers, GetTargetFrameworks, and GetPropertyCommaSeparatedValues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 008efa7a-b4fc-4f33-9dc7-d37d3f0e93c3 --- .../Reference/List/ReferenceListCommand.cs | 7 +++- .../Solution/Add/SolutionAddCommand.cs | 9 ++++- .../Extensions/ProjectInstanceExtensions.cs | 31 ++++++++++++++++ src/Cli/dotnet/MsbuildProject.cs | 37 ++++++++++++------- .../dotnet/ReleasePropertyProjectLocator.cs | 11 +++++- 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/Cli/dotnet/Commands/Reference/List/ReferenceListCommand.cs b/src/Cli/dotnet/Commands/Reference/List/ReferenceListCommand.cs index 8a392e35a610..425d891a74c4 100644 --- a/src/Cli/dotnet/Commands/Reference/List/ReferenceListCommand.cs +++ b/src/Cli/dotnet/Commands/Reference/List/ReferenceListCommand.cs @@ -4,6 +4,7 @@ #nullable disable using System.CommandLine; +using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; using Microsoft.DotNet.Cli.Commands.Hidden.List.Reference; @@ -49,7 +50,11 @@ public override int Execute() return 0; } - ProjectInstance projectInstance = new(msbuildProj.ProjectRootElement); + // Only ProjectReference items (and no targets) are read below, so stop after the Items pass + // instead of running a full evaluation. + ProjectInstance projectInstance = ProjectInstance.FromProjectRootElement( + msbuildProj.ProjectRootElement, + new ProjectOptions { EvaluationStage = ProjectEvaluationStage.Items }); Reporter.Output.WriteLine($"{CliStrings.ProjectReferenceOneOrMore}"); Reporter.Output.WriteLine(new string('-', CliStrings.ProjectReferenceOneOrMore.Length)); foreach (var item in projectInstance.GetItems("ProjectReference")) diff --git a/src/Cli/dotnet/Commands/Solution/Add/SolutionAddCommand.cs b/src/Cli/dotnet/Commands/Solution/Add/SolutionAddCommand.cs index c301021fd306..9f5a17f45de1 100644 --- a/src/Cli/dotnet/Commands/Solution/Add/SolutionAddCommand.cs +++ b/src/Cli/dotnet/Commands/Solution/Add/SolutionAddCommand.cs @@ -4,6 +4,8 @@ using System.CommandLine; using System.Diagnostics; using Microsoft.Build.Construction; +using Microsoft.Build.Definition; +using Microsoft.Build.Evaluation; using Microsoft.Build.Exceptions; using Microsoft.Build.Execution; using Microsoft.DotNet.Cli.Extensions; @@ -174,7 +176,12 @@ private void AddProject(SolutionModel solution, string fullProjectPath, ISolutio return; } - ProjectInstance projectInstance = new ProjectInstance(projectRootElement); + // Only evaluated properties (ProjectGuid, Platforms, Configurations, DefaultProjectTypeGuid) and + // ProjectReference items are read from this instance, never targets, so stop after the Items pass + // instead of running a full evaluation. + ProjectInstance projectInstance = ProjectInstance.FromProjectRootElement( + projectRootElement, + new ProjectOptions { EvaluationStage = ProjectEvaluationStage.Items }); string projectTypeGuid = solution.ProjectTypes.FirstOrDefault(t => t.Extension == Path.GetExtension(fullProjectPath))?.ProjectTypeId.ToString() ?? projectRootElement.GetProjectTypeGuid() ?? projectInstance.GetDefaultProjectTypeGuid(); diff --git a/src/Cli/dotnet/Extensions/ProjectInstanceExtensions.cs b/src/Cli/dotnet/Extensions/ProjectInstanceExtensions.cs index 749007923950..8bbf874c556d 100644 --- a/src/Cli/dotnet/Extensions/ProjectInstanceExtensions.cs +++ b/src/Cli/dotnet/Extensions/ProjectInstanceExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Execution; +using NuGet.Frameworks; namespace Microsoft.DotNet.Cli.Extensions; @@ -47,4 +48,34 @@ public static IEnumerable GetConfigurations(this ProjectInstance project .Where(c => !string.IsNullOrWhiteSpace(c)) .DefaultIfEmpty("Debug"); } + + public static IEnumerable GetRuntimeIdentifiers(this ProjectInstance projectInstance) + { + return projectInstance + .GetPropertyCommaSeparatedValues("RuntimeIdentifier") + .Concat(projectInstance.GetPropertyCommaSeparatedValues("RuntimeIdentifiers")) + .Select(value => value.ToLower()) + .Distinct(); + } + + public static IEnumerable GetTargetFrameworks(this ProjectInstance projectInstance) + { + var targetFrameworksStrings = projectInstance + .GetPropertyCommaSeparatedValues("TargetFramework") + .Union(projectInstance.GetPropertyCommaSeparatedValues("TargetFrameworks")) + .Select((value) => value.ToLower()); + + var uniqueTargetFrameworkStrings = new HashSet(targetFrameworksStrings); + + return uniqueTargetFrameworkStrings + .Select((frameworkString) => NuGetFramework.Parse(frameworkString)); + } + + public static IEnumerable GetPropertyCommaSeparatedValues(this ProjectInstance projectInstance, string propertyName) + { + return projectInstance.GetPropertyValue(propertyName) + .Split(';') + .Select((value) => value.Trim()) + .Where((value) => !string.IsNullOrEmpty(value)); + } } diff --git a/src/Cli/dotnet/MsbuildProject.cs b/src/Cli/dotnet/MsbuildProject.cs index 3b0247a25a13..965d3da4858c 100644 --- a/src/Cli/dotnet/MsbuildProject.cs +++ b/src/Cli/dotnet/MsbuildProject.cs @@ -9,7 +9,9 @@ #if !CLI_AOT using Microsoft.Build.Construction; +using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; using Microsoft.Build.Exceptions; using Microsoft.Build.Framework; using Microsoft.Build.Logging; @@ -40,6 +42,7 @@ public static bool TryGetProjectFileFromDirectory(string projectDirectory, [NotN private List _cachedTfms = null; private IEnumerable cachedRuntimeIdentifiers; private IEnumerable cachedConfigurations; + private ProjectInstance _cachedEvaluatedProject; private readonly bool _interactive = false; private MsbuildProject(ProjectCollection projects, ProjectRootElement project, bool interactive) @@ -153,7 +156,7 @@ public IEnumerable GetTargetFrameworks() public IEnumerable GetConfigurations() { - return cachedConfigurations ??= GetEvaluatedProject().GetConfigurations(); + return cachedConfigurations ??= GetEvaluatedProject().GetPropertyCommaSeparatedValues("Configurations"); } public bool CanWorkOnFramework(NuGetFramework framework) @@ -182,29 +185,37 @@ public bool IsTargetingFramework(NuGetFramework framework) return false; } - private Project GetEvaluatedProject() + private ProjectInstance GetEvaluatedProject() { + // Reuse a single evaluation across the RID/TFM/Configuration getters. A ProjectInstance is a + // standalone snapshot that is never stored in the ProjectCollection's loaded-project cache, so + // (unlike a cached Project) it can never be silently re-evaluated to Full by a later LoadProject. + if (_cachedEvaluatedProject is not null) + { + return _cachedEvaluatedProject; + } + try { - Project project; + // Only evaluated properties (RuntimeIdentifier(s), TargetFramework(s), Configurations) are read + // from the returned instance, never items or targets, so stop after the Properties pass instead + // of running a full evaluation. + var options = new ProjectOptions + { + ProjectCollection = _projects, + EvaluationStage = ProjectEvaluationStage.Properties, + }; if (_interactive) { // NuGet need this environment variable to call plugin dll Environment.SetEnvironmentVariable("DOTNET_HOST_PATH", new Muxer().MuxerPath); // Even during evaluation time, the SDK resolver may need to output auth instructions, so set a logger. _projects.RegisterLogger(new ConsoleLogger(LoggerVerbosity.Minimal)); - project = _projects.LoadProject( - ProjectRootElement.FullPath, - new Dictionary - { ["NuGetInteractive"] = "true" }, - null); - } - else - { - project = _projects.LoadProject(ProjectRootElement.FullPath); + options.GlobalProperties = new Dictionary + { ["NuGetInteractive"] = "true" }; } - return project; + return _cachedEvaluatedProject = ProjectInstance.FromFile(ProjectRootElement.FullPath, options); } catch (InvalidProjectFileException e) { diff --git a/src/Cli/dotnet/ReleasePropertyProjectLocator.cs b/src/Cli/dotnet/ReleasePropertyProjectLocator.cs index d6000de27f17..7c0aab32bdd4 100644 --- a/src/Cli/dotnet/ReleasePropertyProjectLocator.cs +++ b/src/Cli/dotnet/ReleasePropertyProjectLocator.cs @@ -5,6 +5,7 @@ using System.CommandLine; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; using Microsoft.DotNet.Cli.Commands.Run; @@ -241,7 +242,15 @@ private bool IsUnanalyzableProjectInSolution(SolutionProjectModel project, strin { try { - return new ProjectInstance(projectPath, globalProperties, "Current"); + // Only evaluated property values (PublishRelease/PackRelease and FullPath) are read from the + // returned instance, never items or targets, so stop after the Properties pass instead of + // running a full evaluation (which additionally globs items and registers targets). + return ProjectInstance.FromFile(projectPath, new ProjectOptions + { + GlobalProperties = globalProperties, + ToolsVersion = "Current", + EvaluationStage = ProjectEvaluationStage.Properties, + }); } catch (Exception e) // Catch failed file access, or invalid project files that cause errors when read into memory, {