diff --git a/src/Build.UnitTests/Instance/ProjectInstance_ImportEdges_Tests.cs b/src/Build.UnitTests/Instance/ProjectInstance_ImportEdges_Tests.cs new file mode 100644 index 00000000000..80a8801be70 --- /dev/null +++ b/src/Build.UnitTests/Instance/ProjectInstance_ImportEdges_Tests.cs @@ -0,0 +1,201 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.Build.BackEnd; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.UnitTests; +using Microsoft.Build.UnitTests.BackEnd; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.UnitTests.OM.Instance +{ + /// + /// Tests for the import edge feature: structured import graph data on ProjectInstance + /// exposed to tasks via EngineServices.GetImportEdges(). + /// + public class ProjectInstance_ImportEdges_Tests + { + private readonly ITestOutputHelper _output; + + public ProjectInstance_ImportEdges_Tests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void ImportEdgesArePopulatedFromEvaluation() + { + using TestEnvironment env = TestEnvironment.Create(_output); + + string import2Content = ""; + var import2File = env.CreateFile("import2.targets", import2Content); + + string import1Content = $""" + + + + """; + var import1File = env.CreateFile("import1.targets", import1Content); + + string projectContent = $""" + + + + """; + var projectFile = env.CreateFile("test.proj", projectContent); + + using var collection = new ProjectCollection(); + var project = new Project(projectFile.Path, globalProperties: null, toolsVersion: null, collection); + ProjectInstance instance = project.CreateProjectInstance(); + + // Should have the flat import paths + instance.ImportPaths.ShouldContain(import1File.Path); + instance.ImportPaths.ShouldContain(import2File.Path); + + // Should have structured import edges + var edges = instance.ImportEdges; + edges.ShouldNotBeNull(); + edges.Count.ShouldBe(2); + + // Edge: project -> import1 + var edge1 = edges.First(e => e.ImportedProjectPath == import1File.Path); + edge1.ImportingProjectPath.ShouldBe(projectFile.Path); + edge1.SdkName.ShouldBeNull(); + + // Edge: import1 -> import2 + var edge2 = edges.First(e => e.ImportedProjectPath == import2File.Path); + edge2.ImportingProjectPath.ShouldBe(import1File.Path); + edge2.SdkName.ShouldBeNull(); + } + + [Fact] + public void ImportEdgesExcludeRootProject() + { + using TestEnvironment env = TestEnvironment.Create(_output); + + string projectContent = ""; + var projectFile = env.CreateFile("test.proj", projectContent); + + using var collection = new ProjectCollection(); + var project = new Project(projectFile.Path, globalProperties: null, toolsVersion: null, collection); + ProjectInstance instance = project.CreateProjectInstance(); + + // A project with no imports should have zero edges + instance.ImportEdges.ShouldNotBeNull(); + instance.ImportEdges.Count.ShouldBe(0); + } + + [Fact] + public void ImportEdgesSurviveDeepCopy() + { + using TestEnvironment env = TestEnvironment.Create(_output); + + string importContent = ""; + var importFile = env.CreateFile("import.targets", importContent); + + string projectContent = $""" + + + + """; + var projectFile = env.CreateFile("test.proj", projectContent); + + using var collection = new ProjectCollection(); + var project = new Project(projectFile.Path, globalProperties: null, toolsVersion: null, collection); + ProjectInstance original = project.CreateProjectInstance(); + ProjectInstance copy = original.DeepCopy(); + + copy.ImportEdges.ShouldNotBeNull(); + copy.ImportEdges.Count.ShouldBe(1); + copy.ImportEdges[0].ImportedProjectPath.ShouldBe(importFile.Path); + copy.ImportEdges[0].ImportingProjectPath.ShouldBe(projectFile.Path); + } + + [Fact] + public void ImportEdgesSerializedWhenPropertyIsSet() + { + using TestEnvironment env = TestEnvironment.Create(_output); + + string importContent = ""; + var importFile = env.CreateFile("import.targets", importContent); + + string projectContent = $""" + + + true + + + + """; + var projectFile = env.CreateFile("test.proj", projectContent); + + using var collection = new ProjectCollection(); + var project = new Project(projectFile.Path, globalProperties: null, toolsVersion: null, collection); + ProjectInstance original = project.CreateProjectInstance(); + original.TranslateEntireState = true; + + // Verify edges exist before serialization + original.ImportEdges.ShouldNotBeNull(); + original.ImportEdges.Count.ShouldBe(1); + + // Round-trip through serialization + ((ITranslatable)original).Translate(TranslationHelpers.GetWriteTranslator()); + ProjectInstance deserialized = ProjectInstance.FactoryForDeserialization(TranslationHelpers.GetReadTranslator()); + + // Edges should survive serialization when property is set + deserialized.ImportEdges.ShouldNotBeNull(); + deserialized.ImportEdges.Count.ShouldBe(1); + deserialized.ImportEdges[0].ImportedProjectPath.ShouldBe(importFile.Path); + deserialized.ImportEdges[0].ImportingProjectPath.ShouldBe(projectFile.Path); + deserialized.ImportEdges[0].SdkName.ShouldBeNull(); + } + + [Fact] + public void ImportEdgesNotSerializedWithoutProperty() + { + using TestEnvironment env = TestEnvironment.Create(_output); + + string importContent = ""; + var importFile = env.CreateFile("import.targets", importContent); + + string projectContent = $""" + + + + """; + var projectFile = env.CreateFile("test.proj", projectContent); + + using var collection = new ProjectCollection(); + var project = new Project(projectFile.Path, globalProperties: null, toolsVersion: null, collection); + ProjectInstance original = project.CreateProjectInstance(); + original.TranslateEntireState = true; + + // Edges exist on the original + original.ImportEdges.ShouldNotBeNull(); + original.ImportEdges.Count.ShouldBe(1); + + // Round-trip through serialization + ((ITranslatable)original).Translate(TranslationHelpers.GetWriteTranslator()); + ProjectInstance deserialized = ProjectInstance.FactoryForDeserialization(TranslationHelpers.GetReadTranslator()); + + // Without the opt-in property, edges should NOT be serialized + deserialized.ImportEdges.ShouldBeNull(); + } + + [Fact] + public void ProjectImportEdgeToString() + { + var edge = new ProjectImportEdge(@"C:\imported.targets", @"C:\project.csproj"); + edge.ToString().ShouldContain(@"C:\project.csproj"); + edge.ToString().ShouldContain(@"C:\imported.targets"); + edge.ToString().ShouldContain("->"); + + var sdkEdge = new ProjectImportEdge(@"C:\sdk.targets", @"C:\project.csproj", "Microsoft.NET.Sdk"); + sdkEdge.ToString().ShouldContain("SDK: Microsoft.NET.Sdk"); + } + } +} diff --git a/src/Build/BackEnd/Components/RequestBuilder/TaskHost.cs b/src/Build/BackEnd/Components/RequestBuilder/TaskHost.cs index 034569b6236..363c9ef66bb 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/TaskHost.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/TaskHost.cs @@ -943,6 +943,10 @@ public override bool LogsMessagesOfImportance(MessageImportance importance) public override bool IsOutOfProcRarNodeEnabled => _taskHost._host.BuildParameters.EnableRarNode; + /// + public override IReadOnlyList ImportEdges => + _taskHost._requestEntry.RequestConfiguration.Project?.ImportEdges; + #if FEATURE_REPORTFILEACCESSES /// /// Reports a file access from a task. diff --git a/src/Build/Instance/ProjectInstance.cs b/src/Build/Instance/ProjectInstance.cs index 4eb95dc09aa..5b68b3790c1 100644 --- a/src/Build/Instance/ProjectInstance.cs +++ b/src/Build/Instance/ProjectInstance.cs @@ -811,6 +811,7 @@ private ProjectInstance(ProjectInstance that, bool isImmutable, RequestedProject ImportPaths = new ObjectModel.ReadOnlyCollection(_importPaths); _importPathsIncludingDuplicates = that._importPathsIncludingDuplicates; ImportPathsIncludingDuplicates = new ObjectModel.ReadOnlyCollection(_importPathsIncludingDuplicates); + ImportEdges = that.ImportEdges; this.EvaluatedItemElements = that.EvaluatedItemElements; @@ -1260,6 +1261,18 @@ public IDictionary ItemDefinitions /// public IReadOnlyList ImportPathsIncludingDuplicates { get; private set; } + /// + /// Gets the structured import edges discovered during evaluation, representing the graph + /// of <Import> relationships between project files. + /// + /// + /// Each edge connects an importing file to the file it imported. The root project's own + /// entry is excluded; only real import relationships are represented. + /// May be on out-of-process nodes if the project did not opt in + /// to serializing import edges via the MSBuildProvideImportGraph property. + /// + internal IReadOnlyList ImportEdges { get; private set; } + /// /// DefaultTargets specified in the project, or /// the logically first target if no DefaultTargets is @@ -2536,8 +2549,10 @@ private void TranslateAllState(ITranslator translator) ProjectItemDefinitionInstance.FactoryForDeserialization, capacity => new RetrievableEntryHashSet(capacity, MSBuildNameIgnoreCaseComparer.Default)); - // ignore _importPaths/ImportPaths. Only used by public API users, not nodes - // ignore _importPathsIncludingDuplicates/ImportPathsIncludingDuplicates. Only used by public API users, not nodes + // ImportPaths/ImportPathsIncludingDuplicates are not serialized — only used by public API consumers. + // ImportEdges are conditionally serialized when MSBuildProvideImportGraph is set (see TranslateImportEdges). + + TranslateImportEdges(translator); } private void TranslateToolsetSpecificState(ITranslator translator) @@ -2549,6 +2564,69 @@ private void TranslateToolsetSpecificState(ITranslator translator) translator.Translate(ref _subToolsetVersion); } + /// + /// Conditionally serializes import edge data across nodes. + /// The data is only written when the project has opted in via the + /// MSBuildProvideImportGraph property. + /// + private void TranslateImportEdges(ITranslator translator) + { + bool hasImportEdges = false; + + if (translator.Mode == TranslationDirection.WriteToStream) + { + hasImportEdges = ImportEdges is { Count: > 0 } + && string.Equals( + _properties?.GetProperty(Constants.MSBuildProvideImportGraphPropertyName)?.EvaluatedValue, + "true", + StringComparison.OrdinalIgnoreCase); + } + + // Bidirectional: on write this persists the flag, on read it recovers it. + translator.Translate(ref hasImportEdges); + + if (!hasImportEdges) + { + return; + } + + if (translator.Mode == TranslationDirection.WriteToStream) + { + int count = ImportEdges.Count; + translator.Translate(ref count); + + for (int i = 0; i < count; i++) + { + ProjectImportEdge edge = ImportEdges[i]; + string importedPath = edge.ImportedProjectPath; + string importingPath = edge.ImportingProjectPath; + string sdkName = edge.SdkName; + translator.Translate(ref importedPath); + translator.Translate(ref importingPath); + translator.Translate(ref sdkName); + } + } + else + { + int count = 0; + translator.Translate(ref count); + + var edges = new List(count); + for (int i = 0; i < count; i++) + { + string importedPath = null; + string importingPath = null; + string sdkName = null; + translator.Translate(ref importedPath); + translator.Translate(ref importingPath); + translator.Translate(ref sdkName); + edges.Add(new ProjectImportEdge(importedPath, importingPath, sdkName)); + } + + ImportEdges = edges.AsReadOnly(); + } + } + private void TranslateProperties(ITranslator translator) { translator.TranslateDictionary(ref _environmentVariableProperties, ProjectPropertyInstance.FactoryForDeserialization); @@ -3352,17 +3430,26 @@ private void InitializeTargetsData(List defaultTargets, private void CreateImportsSnapshot(IList importClosure, IList importClosureWithDuplicates) { var importPaths = new List(Math.Max(0, importClosure.Count - 1) /* outer project */); + var importEdges = new List(Math.Max(0, importClosure.Count - 1)); + foreach (var resolvedImport in importClosure) { // Exclude outer project itself if (resolvedImport.ImportingElement != null) { - importPaths.Add(resolvedImport.ImportedProject.FullPath); + string importedPath = resolvedImport.ImportedProject.FullPath; + importPaths.Add(importedPath); + + importEdges.Add(new ProjectImportEdge( + importedPath, + resolvedImport.ImportingElement.ContainingProject.FullPath, + resolvedImport.SdkResult?.SdkReference.Name)); } } _importPaths = importPaths; ImportPaths = importPaths.AsReadOnly(); + ImportEdges = importEdges.AsReadOnly(); var importPathsIncludingDuplicates = new List(Math.Max(0, importClosureWithDuplicates.Count - 1) /* outer project */); foreach (var resolvedImport in importClosureWithDuplicates) diff --git a/src/Framework/Constants.cs b/src/Framework/Constants.cs index 36dbb4aab9f..4181e51a533 100644 --- a/src/Framework/Constants.cs +++ b/src/Framework/Constants.cs @@ -92,6 +92,13 @@ internal static class Constants internal const string MSBuildAllProjectsPropertyName = "MSBuildAllProjects"; + /// + /// Name of the MSBuild property that opts in to serializing the import graph + /// to out-of-process build nodes, making it available to tasks via + /// . + /// + internal const string MSBuildProvideImportGraphPropertyName = "MSBuildProvideImportGraph"; + internal const string TaskHostExplicitlyRequested = "TaskHostExplicitlyRequested"; } } diff --git a/src/Framework/EngineServices.cs b/src/Framework/EngineServices.cs index 76b3ccb839c..6968f573b13 100644 --- a/src/Framework/EngineServices.cs +++ b/src/Framework/EngineServices.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; namespace Microsoft.Build.Framework { @@ -55,5 +56,35 @@ public abstract class EngineServices public virtual bool IsTaskInputLoggingEnabled => throw new NotImplementedException(); public virtual bool IsOutOfProcRarNodeEnabled => throw new NotImplementedException(); + +#nullable enable + /// + /// Gets the import edges discovered during project evaluation, representing the graph of + /// <Import> relationships between project files. + /// + /// + /// A read-only list of values describing each import relationship, + /// or if the import graph is not available on this node. + /// + /// + /// + /// The import graph is always available when the task runs on the in-process node. + /// For out-of-process nodes, set the MSBuild property MSBuildProvideImportGraph to true + /// in your project to opt in to serializing import graph data across nodes. + /// + /// + /// Tasks can use pattern matching to access this property: + /// + /// if (BuildEngine is IBuildEngine10 { EngineServices.ImportEdges: IReadOnlyList<ProjectImportEdge> edges }) + /// { + /// // use edges + /// } + /// + /// The pattern naturally handles older engines (where the property is absent) and + /// out-of-proc nodes where the data was not serialized (where the property returns ). + /// + /// + public virtual IReadOnlyList? ImportEdges => null; +#nullable restore } } diff --git a/src/Framework/ProjectImportEdge.cs b/src/Framework/ProjectImportEdge.cs new file mode 100644 index 00000000000..9586e4870c7 --- /dev/null +++ b/src/Framework/ProjectImportEdge.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.Build.Framework +{ + /// + /// Represents a single import relationship discovered during project evaluation. + /// Each edge connects an importing project file to the file it imported. + /// + /// + /// A collection of these edges forms the import tree for a project. + /// Each imported file appears at most once — when multiple files import the same + /// file, only the first occurrence (in depth-first evaluation order) is recorded. + /// The root project itself is not represented as an edge; only actual + /// import relationships are included. + /// + /// Obtain import edges at task execution time via + /// . + /// + /// + /// Full path of the imported project file. + /// Full path of the project file that contains the <Import> element, or if this is a direct import from the root project. + /// The SDK name if this import was resolved via an SDK reference (e.g. "Microsoft.NET.Sdk"); otherwise . + public readonly record struct ProjectImportEdge( + string ImportedProjectPath, + string? ImportingProjectPath, + string? SdkName = null) + { + /// + public override string ToString() + { + string arrow = ImportingProjectPath is null + ? $"[root] -> {ImportedProjectPath}" + : $"{ImportingProjectPath} -> {ImportedProjectPath}"; + + return SdkName is null ? arrow : $"{arrow} (SDK: {SdkName})"; + } + } +}