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})";
+ }
+ }
+}