diff --git a/src/private/app/workflow-delivery/Program.cs b/src/private/app/workflow-delivery/Program.cs index f4b2c8ca9..979ea4629 100644 --- a/src/private/app/workflow-delivery/Program.cs +++ b/src/private/app/workflow-delivery/Program.cs @@ -133,6 +133,8 @@ JsonException or ArgumentException or UnauthorizedAccessException or TimeoutExce [JsonSerializable(typeof(CiComparison))] [JsonSerializable(typeof(ControlTestInputRequest))] [JsonSerializable(typeof(ControlTestInputResponse))] +[JsonSerializable(typeof(DotNetGraphRequest))] +[JsonSerializable(typeof(DotNetGraphResponse))] [JsonSerializable(typeof(NodePlanRequest))] [JsonSerializable(typeof(NodeGroupRequest))] [JsonSerializable(typeof(NodeGroupReadback))] diff --git a/src/private/app/workflow-delivery/README.md b/src/private/app/workflow-delivery/README.md index 25bba82c0..c41451208 100644 --- a/src/private/app/workflow-delivery/README.md +++ b/src/private/app/workflow-delivery/README.md @@ -188,6 +188,42 @@ and controlled-result/actual CLI tests cover this narrow projection. It does not discover project membership, imports, references, other item/target inputs or required dimensions. It does not qualify complete .NET facts or CI group cutover. +### Native .NET Graph Task + +[`DotNetGraphTask`](Repository/DotNetGraphTask.cs) is an `ITask` entry supplied by +the existing application. A completely built application is loaded with +`UsingTask` inside ordinary endpoint-selected `dotnet msbuild`. The typed request +supplies the exact revision, absolute endpoint/traversal paths, Debug CI locked +operation globals, Git-derived committed paths and a new private response path. +Caller TFM/RID selector names are rejected case-insensitively before discovery; +native-propagated TFM/RID globals remain in each node's complete identity. +MSBuild constructs the `ProjectGraph`; wrapper properties do not become graph +globals. Project paths plus complete global maps identify native nodes. The +response retains outer/inner nodes, configuration/TFM/RID and native reference +edges, including analyzer references. Endpoint-dependent semantic globals fail. + +[`DotNetOwnership`](Repository/DotNetOwnership.cs) projects ordinary committed +paths into the nearest managed project directory; projects sharing that directory +retain their own ownership. The selected SDK's `MSBuildGlob` matches effective +`DefaultItemExcludes` and `DefaultExcludesInProjectFolder`. Escaped list boundaries +are preserved before official unescaping. Illegal file specifications and encoded +literal wildcards remain unsupported instead of broadening ownership. No SDK +defaults are reconstructed. Each variant retains its own owned paths for later +union by the fact assembler. + +The response separately retains main projects, native imports and supported +evaluated source/resource/additional/analyzer/XAML items with their full paths, +roles, evaluation stage and consuming native node. Explicit input consumers remain +independent of ordinary ownership, including an embedded resource excluded by a +default file rule. Environmental and generated paths remain physical native facts +for later role binding; they are not asserted to be committed source inputs. + +This is a partial native fact producer, exercised by isolated actual-process +contract tests. Locked endpoint preparation/configuration, complete finite input +coverage, repository producer adapters, native test output/capability, exact Git +binding, both-endpoint assembly, selected execution and workflow transfer remain +pending. No CLI caller or general .NET CI cutover uses this task yet. + [`ControlTestInputs`](Repository/ControlTestInputs.cs) exposes one concrete root-CI consumer through `ci control-inputs`: the control test project's embedded resources, including the npm fixture version file. Git confirms the exact comparison, diff --git a/src/private/app/workflow-delivery/Repository/DotNetGraph.cs b/src/private/app/workflow-delivery/Repository/DotNetGraph.cs new file mode 100644 index 000000000..b6d16c531 --- /dev/null +++ b/src/private/app/workflow-delivery/Repository/DotNetGraph.cs @@ -0,0 +1,112 @@ +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; + +namespace WorkflowDelivery.Repository; + +internal sealed record DotNetGraphRequest(string Revision, string Root, string Entry, + Dictionary Globals, string Destination, string[] CommittedPaths); +internal sealed record DotNetNodeIdentity(string Project, Dictionary Globals); +internal sealed record DotNetGraphNode(DotNetNodeIdentity Identity, bool OuterBuild, + MsBuildDimension Dimension, string TargetFrameworks, string RuntimeIdentifiers, + string[] OwnedPaths); +internal sealed record DotNetGraphEdge(DotNetNodeIdentity Consumer, DotNetNodeIdentity Dependency); +internal sealed record DotNetGraphInput(string Path, string Role, string Stage, + DotNetNodeIdentity Consumer); +internal sealed record DotNetGraphResponse(string Revision, string Root, string Entry, + DotNetGraphNode[] Nodes, DotNetGraphEdge[] Edges, DotNetGraphInput[] Inputs); + +// Called only inside the endpoint-selected MSBuild process. MSBuild owns graph construction. +internal static class DotNetGraph +{ + private static readonly string[] CallerVariantSelectors = ["TargetFramework", + "TargetFrameworks", "RuntimeIdentifier", "RuntimeIdentifiers"]; + private static readonly string[] EvaluatedInputTypes = ["Compile", "EmbeddedResource", + "Content", "None", "AdditionalFiles", "Analyzer", "EditorConfigFiles", + "GlobalAnalyzerConfigFiles", "ApplicationDefinition", "Page", "Resource", + "SplashScreen"]; + + internal static DotNetGraphResponse Read(DotNetGraphRequest request) + { + if (request.Revision.Length != 40 || + request.Revision.Any(value => !char.IsAsciiHexDigit(value))) + throw new InvalidDataException("Native graph requires an exact endpoint revision."); + string root = Path.TrimEndingDirectorySeparator(Absolute(request.Root)); + string entry = Absolute(request.Entry); + string relative = Path.GetRelativePath(root, entry); + if (Path.IsPathRooted(relative) || relative == ".." || + relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new InvalidDataException("Native graph entry is outside its endpoint."); + if (!File.Exists(entry)) + throw new InvalidDataException("Native graph entry is unavailable."); + if (request.Globals.Keys.Any(name => CallerVariantSelectors.Contains(name, + StringComparer.OrdinalIgnoreCase))) + throw new InvalidDataException( + "Native graph discovery does not accept caller variant selectors."); + if (!request.Globals.TryGetValue("Configuration", out string? configuration) || + configuration != "Debug" || + !request.Globals.TryGetValue("ContinuousIntegrationBuild", out string? ci) || + ci != "true" || + !request.Globals.TryGetValue("RestoreLockedMode", out string? locked) || + locked != "true") + throw new InvalidDataException("Native graph requires the supported CI operation."); + var graph = new ProjectGraph(entry, request.Globals); + var identities = graph.ProjectNodes.ToDictionary(node => node, + node => Identity(node.ProjectInstance)); + var ownership = new DotNetOwnership(root, graph.ProjectNodes.Select(node => + node.ProjectInstance), request.CommittedPaths); + DotNetGraphNode[] nodes = graph.ProjectNodes.Select(node => + { + ProjectInstance project = node.ProjectInstance; + string outer = project.GetPropertyValue("IsCrossTargetingBuild"); + if (outer != "" && !bool.TryParse(outer, out _)) + throw new InvalidDataException("Unsupported native outer-build identity."); + return new DotNetGraphNode(identities[node], + string.Equals(outer, "true", StringComparison.OrdinalIgnoreCase), + new(project.GetPropertyValue("Configuration"), + project.GetPropertyValue("TargetFramework"), + project.GetPropertyValue("RuntimeIdentifier")), + project.GetPropertyValue("TargetFrameworks"), + project.GetPropertyValue("RuntimeIdentifiers"), ownership.Read(project)); + }).ToArray(); + DotNetGraphEdge[] edges = graph.ProjectNodes.SelectMany(node => + node.ProjectReferences.Select(dependency => + new DotNetGraphEdge(identities[node], identities[dependency]))).ToArray(); + DotNetGraphInput[] inputs = graph.ProjectNodes.SelectMany(node => + { + ProjectInstance project = node.ProjectInstance; + DotNetNodeIdentity consumer = identities[node]; + return new[] { new DotNetGraphInput(Absolute(project.FullPath), "Project", + "Evaluation", consumer) } + .Concat(project.ImportPaths.Select(path => new DotNetGraphInput(Absolute(path), + "Import", "Evaluation", consumer))) + .Concat(EvaluatedInputTypes.SelectMany(type => project.GetItems(type) + .Select(item => new DotNetGraphInput( + Absolute(item.GetMetadataValue("FullPath")), type, + "Evaluation", consumer)))); + }).ToArray(); + return new(request.Revision, root, entry, nodes, edges, inputs); + + DotNetNodeIdentity Identity(ProjectInstance project) + { + var globals = new Dictionary(StringComparer.Ordinal); + foreach (var property in project.GlobalProperties.OrderBy(property => + property.Key, StringComparer.Ordinal)) + { + if (property.Value.Replace('\\', '/').Contains(root.Replace('\\', '/'), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : + StringComparison.Ordinal)) + throw new InvalidDataException( + "Endpoint-dependent native semantic global: " + property.Key); + globals.Add(property.Key, property.Value); + } + return new(Absolute(project.FullPath), globals); + } + } + + private static string Absolute(string path) + { + if (!Path.IsPathFullyQualified(path)) + throw new InvalidDataException("Native graph coordinates must be absolute."); + return Path.GetFullPath(path); + } +} diff --git a/src/private/app/workflow-delivery/Repository/DotNetGraphTask.cs b/src/private/app/workflow-delivery/Repository/DotNetGraphTask.cs new file mode 100644 index 000000000..d7d3e86ad --- /dev/null +++ b/src/private/app/workflow-delivery/Repository/DotNetGraphTask.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using Microsoft.Build.Framework; + +namespace WorkflowDelivery.Repository; + +// The existing application supplies this entry; ordinary MSBuild loads its dependencies. +public sealed class DotNetGraphTask : ITask +{ + public IBuildEngine BuildEngine { get; set; } = null!; + public ITaskHost HostObject { get; set; } = null!; + + [Required] + public string RequestFile { get; set; } = ""; + + public bool Execute() + { + DotNetGraphRequest request = JsonSerializer.Deserialize(File.ReadAllText(RequestFile), + TransferJson.Default.DotNetGraphRequest) + ?? throw new InvalidDataException("Missing native graph request."); + DotNetGraphResponse response = DotNetGraph.Read(request); + using var output = new FileStream(request.Destination, FileMode.CreateNew, + FileAccess.Write, FileShare.None); + JsonSerializer.Serialize(output, response, TransferJson.Default.DotNetGraphResponse); + return true; + } +} diff --git a/src/private/app/workflow-delivery/Repository/DotNetOwnership.cs b/src/private/app/workflow-delivery/Repository/DotNetOwnership.cs new file mode 100644 index 000000000..2c79baf46 --- /dev/null +++ b/src/private/app/workflow-delivery/Repository/DotNetOwnership.cs @@ -0,0 +1,76 @@ +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Globbing; + +namespace WorkflowDelivery.Repository; + +// Git supplies committed entries; the selected MSBuild supplies region exclusions. +internal sealed class DotNetOwnership +{ + private static readonly StringComparison PathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private readonly string[] _regions; + private readonly string[] _paths; + + internal DotNetOwnership(string root, IEnumerable projects, + string[] committedPaths) + { + _regions = projects.Where(IsManagedProject).Select(project => + Path.GetDirectoryName(project.FullPath)!) + .Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : + StringComparer.Ordinal).OrderByDescending(path => path.Length).ToArray(); + _paths = committedPaths.Select(path => + { + if (!Path.IsPathFullyQualified(path)) + throw new InvalidDataException( + "Committed ownership coordinates must be absolute."); + string full = Path.GetFullPath(path); + if (!Inside(root, full)) + throw new InvalidDataException( + "Committed ownership entry is outside its endpoint."); + return full; + }).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + } + + internal string[] Read(ProjectInstance project) + { + if (!IsManagedProject(project)) return []; + string directory = Path.GetDirectoryName(project.FullPath)!; + MSBuildGlob[] excludes = Excludes("DefaultItemExcludes") + .Concat(Excludes("DefaultExcludesInProjectFolder")).ToArray(); + return _paths.Where(path => + string.Equals(_regions.FirstOrDefault(region => Inside(region, path)), directory, + PathComparison) && !excludes.Any(glob => glob.IsMatch(path))).ToArray(); + + IEnumerable Excludes(string name) + { + ProjectPropertyInstance? property = project.GetProperty(name); + if (property is null) yield break; + // Split while escaped so a literal semicolon cannot become a list separator. + foreach (string spec in ProjectInstance.GetPropertyValueEscaped(property) + .Split(';', StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries)) + { + if (spec.Contains("%2a", StringComparison.OrdinalIgnoreCase) || + spec.Contains("%3f", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException( + "Unsupported escaped native ownership wildcard."); + MSBuildGlob glob = MSBuildGlob.Parse(directory, ProjectCollection.Unescape(spec)); + if (!glob.IsLegal) + throw new InvalidDataException("Illegal native ownership filespec: " + name); + yield return glob; + } + } + } + + private static bool IsManagedProject(ProjectInstance project) + { + string extension = Path.GetExtension(project.FullPath); + return extension.Equals(".csproj", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".fsproj", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".vbproj", StringComparison.OrdinalIgnoreCase); + } + + private static bool Inside(string directory, string path) => path.StartsWith( + Path.TrimEndingDirectorySeparator(directory) + Path.DirectorySeparatorChar, PathComparison); +} diff --git a/tests/private/app/workflow-delivery/Repository/DotNetGraphTaskTests.cs b/tests/private/app/workflow-delivery/Repository/DotNetGraphTaskTests.cs new file mode 100644 index 000000000..910bd5ee0 --- /dev/null +++ b/tests/private/app/workflow-delivery/Repository/DotNetGraphTaskTests.cs @@ -0,0 +1,323 @@ +using System.Security; +using System.Text.Json; +using WorkflowDelivery.Platform; +using WorkflowDelivery.Repository; + +namespace WorkflowDelivery.Tests.Repository; + +[TestClass] +public sealed class DotNetGraphTaskTests(TestContext context) +{ + [TestMethod] + [Timeout(30000, CooperativeCancellation = true)] + public async Task LoadedNativeGraphPreservesInnerVariantsAndReferenceEdges() + { + using var fixture = new GraphFixture(); + fixture.Globals.Add("BusinessSentinel", "semantic"); + fixture.Write("dirs.proj", """ + + + + """); + fixture.Write("consumer.csproj", """ + + net10.0;net9.0 + + + + + + """); + fixture.Write("dependency.csproj", """ + + net10.0 + + + """); + fixture.Write("leaf.csproj", GraphFixture.Project); + fixture.Write("analyzer.csproj", GraphFixture.Project); + DotNetGraphResponse response = await fixture.ReadAsync(context.CancellationToken); + Assert.AreEqual(GraphFixture.Revision, response.Revision); + Assert.AreEqual(fixture.Root, response.Root); + DotNetGraphNode[] consumer = response.Nodes.Where(node => + node.Identity.Project == fixture.PathOf("consumer.csproj")).ToArray(); + Assert.HasCount(3, consumer); + Assert.HasCount(1, consumer.Where(node => node.OuterBuild)); + string[] frameworks = consumer.Where(node => !node.OuterBuild) + .Select(node => node.Dimension.TargetFramework).ToArray(); + Assert.HasCount(2, frameworks); + Assert.Contains("net10.0", frameworks); + Assert.Contains("net9.0", frameworks); + foreach (DotNetGraphNode node in response.Nodes) + { + Assert.AreEqual("Debug", node.Identity.Globals["Configuration"]); + Assert.AreEqual("true", node.Identity.Globals["ContinuousIntegrationBuild"]); + Assert.AreEqual("true", node.Identity.Globals["RestoreLockedMode"]); + Assert.AreEqual("semantic", node.Identity.Globals["BusinessSentinel"]); + Assert.IsFalse(node.Identity.Globals.ContainsKey("WrapperSentinel")); + Assert.IsFalse(node.Identity.Globals.ContainsKey("RequestFile")); + } + foreach (DotNetGraphNode node in consumer.Where(node => !node.OuterBuild)) + { + Assert.AreEqual(node.Dimension.TargetFramework, + node.Identity.Globals["TargetFramework"]); + foreach (string reference in new[] { "dependency.csproj", "analyzer.csproj" }) + Assert.HasCount(1, response.Edges.Where(edge => + edge.Consumer.Project == node.Identity.Project && + edge.Consumer.Globals.TryGetValue("TargetFramework", out string? framework) && + framework == node.Dimension.TargetFramework && + edge.Dependency.Project == fixture.PathOf(reference))); + } + Assert.IsTrue(response.Edges.Any(edge => + edge.Consumer.Project == fixture.PathOf("dependency.csproj") && + edge.Dependency.Project == fixture.PathOf("leaf.csproj"))); + } + + [TestMethod] + [DataRow("TargetFramework", "net10.0")] + [DataRow("targetframework", "net10.0")] + [DataRow("TargetFrameworks", "net10.0")] + [DataRow("TARGETFRAMEWORKS", "net10.0")] + [DataRow("RuntimeIdentifier", "linux-x64")] + [DataRow("runtimeidentifier", "linux-x64")] + [DataRow("RuntimeIdentifiers", "linux-x64")] + [DataRow("RUNTIMEIDENTIFIERS", "linux-x64")] + [DataRow("TargetFramework", "")] + [DataRow("TargetFrameworks", "")] + [DataRow("RuntimeIdentifier", "")] + [DataRow("RuntimeIdentifiers", "")] + [Timeout(30000, CooperativeCancellation = true)] + public async Task CallerVariantSelectorsFailWithoutAResponse(string name, string value) + { + using var fixture = new GraphFixture(); + fixture.Write("dirs.proj", """ + + """); + fixture.Write("multi.csproj", """ + + net10.0;net9.0 + + """); + fixture.Globals.Add(name, value); + await Assert.ThrowsExactlyAsync(() => + fixture.ReadAsync(context.CancellationToken)); + Assert.IsFalse(File.Exists(fixture.PathOf("response.json"))); + } + + [TestMethod] + [Timeout(30000, CooperativeCancellation = true)] + public async Task MissingNativeReferenceFailsWithoutAResponse() + { + using var fixture = new GraphFixture(); + fixture.Write("dirs.proj", """ + + """); + await Assert.ThrowsExactlyAsync(() => + fixture.ReadAsync(context.CancellationToken)); + Assert.IsFalse(File.Exists(fixture.PathOf("response.json"))); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + [Timeout(30000, CooperativeCancellation = true)] + public async Task EndpointDependentSemanticGlobalFailsWithoutAResponse(bool trailingSeparator) + { + using var fixture = new GraphFixture(); + fixture.Write("dirs.proj", GraphFixture.Project); + fixture.RequestRoot = trailingSeparator + ? fixture.Root + Path.DirectorySeparatorChar : fixture.Root; + fixture.Globals.Add("CustomSourceDirectory", fixture.Root); + await Assert.ThrowsExactlyAsync(() => + fixture.ReadAsync(context.CancellationToken)); + Assert.IsFalse(File.Exists(fixture.PathOf("response.json"))); + } + + [TestMethod] + [DataRow(".csproj")] + [DataRow(".CSPROJ")] + [Timeout(30000, CooperativeCancellation = true)] + public async Task NativeOwnershipPreservesNearestRegionsAndVariantExcludes(string extension) + { + using var fixture = new GraphFixture(); + fixture.Write("dirs.proj", $""" + + + + + + """); + fixture.Write("parent/main.csproj", """ + + + net10.0;net9.0 + + $(DefaultItemExcludes);fixtures/**;semi%3bcolon.txt + + + + $(DefaultItemExcludes);variant-only.txt + + + """); + fixture.Write($"parent/nested/one{extension}", GraphFixture.Project); + fixture.Write($"parent/nested/two{extension}", GraphFixture.Project); + fixture.CommittedPaths = [fixture.PathOf("parent/document.md"), + fixture.PathOf("parent/fixtures/version.json"), + fixture.PathOf("parent/.cache/hidden.txt"), + fixture.PathOf("parent/semi;colon.txt"), + fixture.PathOf("parent/variant-only.txt"), + fixture.PathOf("parent/nested/new.cs"), fixture.PathOf("unowned.md")]; + DotNetGraphResponse response = await fixture.ReadAsync(context.CancellationToken); + DotNetGraphNode[] parent = response.Nodes.Where(node => + node.Identity.Project == fixture.PathOf("parent/main.csproj") && !node.OuterBuild) + .ToArray(); + Assert.HasCount(2, parent); + foreach (DotNetGraphNode node in parent) + { + Assert.Contains(fixture.PathOf("parent/document.md"), node.OwnedPaths); + Assert.DoesNotContain(fixture.PathOf("parent/fixtures/version.json"), node.OwnedPaths); + Assert.DoesNotContain(fixture.PathOf("parent/.cache/hidden.txt"), node.OwnedPaths); + Assert.DoesNotContain(fixture.PathOf("parent/semi;colon.txt"), node.OwnedPaths); + Assert.DoesNotContain(fixture.PathOf("parent/nested/new.cs"), node.OwnedPaths); + Assert.DoesNotContain(fixture.PathOf("unowned.md"), node.OwnedPaths); + Assert.AreEqual(node.Dimension.TargetFramework == "net10.0", + node.OwnedPaths.Contains(fixture.PathOf("parent/variant-only.txt"))); + } + DotNetGraphNode[] nested = response.Nodes.Where(node => + node.Identity.Project == fixture.PathOf($"parent/nested/one{extension}") || + node.Identity.Project == fixture.PathOf($"parent/nested/two{extension}")).ToArray(); + Assert.HasCount(2, nested); + foreach (DotNetGraphNode node in nested) + { + Assert.HasCount(1, node.OwnedPaths); + Assert.Contains(fixture.PathOf("parent/nested/new.cs"), node.OwnedPaths); + } + } + + [TestMethod] + [DataRow("literal%2a.txt")] + [DataRow("bad/**suffix/file.txt")] + [Timeout(30000, CooperativeCancellation = true)] + public async Task UnsupportedNativeOwnershipExcludesFailWithoutAResponse(string exclude) + { + using var fixture = new GraphFixture(); + fixture.Write("dirs.proj", """ + + """); + fixture.Write("main.csproj", $""" + + net10.0 + $(DefaultItemExcludes);{exclude} + + """); + fixture.CommittedPaths = [fixture.PathOf("source.cs")]; + await Assert.ThrowsExactlyAsync(() => + fixture.ReadAsync(context.CancellationToken)); + Assert.IsFalse(File.Exists(fixture.PathOf("response.json"))); + } + + [TestMethod] + [Timeout(30000, CooperativeCancellation = true)] + public async Task ExcludedNativeResourceRetainsItsExplicitConsumerAndImport() + { + using var fixture = new GraphFixture(); + fixture.Write("dirs.proj", """ + + + + """); + fixture.Write("common.props", ""); + fixture.Write("shared.cs", ""); + fixture.Write("parent/fixtures/version.json", "{}"); + fixture.Write("parent/main.csproj", """ + + + + net10.0 + $(DefaultItemExcludes);fixtures/** + + + + + + + """); + fixture.CommittedPaths = [fixture.PathOf("parent/fixtures/version.json"), + fixture.PathOf("shared.cs"), fixture.PathOf("common.props")]; + DotNetGraphResponse response = await fixture.ReadAsync(context.CancellationToken); + DotNetGraphNode owner = Assert.ContainsSingle(response.Nodes.Where(node => + node.Identity.Project == fixture.PathOf("parent/main.csproj"))); + Assert.IsEmpty(owner.OwnedPaths); + foreach ((string path, string role) in new[] + { + ("parent/main.csproj", "Project"), ("common.props", "Import"), + ("shared.cs", "Compile"), ("parent/fixtures/version.json", "EmbeddedResource"), + }) + { + DotNetGraphInput input = Assert.ContainsSingle(response.Inputs.Where(input => + input.Consumer.Project == owner.Identity.Project && input.Role == role && + input.Path == fixture.PathOf(path))); + Assert.AreEqual("Evaluation", input.Stage); + CollectionAssert.AreEquivalent(owner.Identity.Globals.ToArray(), + input.Consumer.Globals.ToArray()); + } + } + + private sealed class GraphFixture : IDisposable + { + internal const string Revision = "1111111111111111111111111111111111111111"; + internal const string Project = """ + + net10.0 + + """; + internal string Root { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + "workflow-native-graph-" + Guid.NewGuid().ToString("N")); + internal Dictionary Globals { get; } = new(StringComparer.Ordinal) + { + ["Configuration"] = "Debug", + ["ContinuousIntegrationBuild"] = "true", + ["RestoreLockedMode"] = "true", + }; + internal string[] CommittedPaths { get; set; } = []; + internal string? RequestRoot { get; set; } + + internal GraphFixture() => Directory.CreateDirectory(Root); + internal string PathOf(string name) => System.IO.Path.GetFullPath( + System.IO.Path.Combine(Root, name)); + internal void Write(string name, string content) + { + string path = PathOf(name); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + internal async Task ReadAsync(CancellationToken token) + { + Write("request.json", JsonSerializer.Serialize(new DotNetGraphRequest(Revision, + RequestRoot ?? Root, PathOf("dirs.proj"), Globals, PathOf("response.json"), + CommittedPaths), TransferJson.Default.DotNetGraphRequest)); + string assembly = SecurityElement.Escape(typeof(DotNetGraphTask).Assembly.Location)!; + string request = SecurityElement.Escape(PathOf("request.json"))!; + Write("read.proj", $""" + + + + + + + """); + await NativeProcess.RunAsync("dotnet", Root, + ["msbuild", PathOf("read.proj"), "-nologo", "-noAutoResponse", "-target:Read", + "-property:WrapperSentinel=wrapper-only"], token); + return JsonSerializer.Deserialize(await File.ReadAllTextAsync( + PathOf("response.json"), token), TransferJson.Default.DotNetGraphResponse) + ?? throw new InvalidDataException("Missing graph response."); + } + + public void Dispose() => Directory.Delete(Root, true); + } +}