diff --git a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs new file mode 100644 index 000000000..cda806ffd --- /dev/null +++ b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Fallout.Common.Execution; +using Fallout.Common.Utilities; + +namespace Fallout.Build.Execution.Extensions; + +/// +/// Pure projection of the target graph into the build-graph.json shape consumed by editor +/// tooling (the VS Code extension): schema version, the running Fallout version, and for each target +/// its name, description, declaring type, the default/listed flags, and the four relation kinds. +/// +/// This is the machine-readable contract the extension gates on — the JSON shape must stay stable. +/// Any breaking change to it requires bumping . The projection is kept +/// separate from (which owns the build-lifecycle hook and +/// file I/O) so the contract can be snapshot-tested without driving a build. +/// +/// +internal static class BuildGraphUtility +{ + /// Schema version consumers gate on; bump only on a breaking shape change. + internal const int SchemaVersion = 1; + + private static readonly JsonSerializerOptions serializerOptions = + new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + /// Projects the targets into the serializable graph model. + /// The build's executable targets, in any order. + /// The running Fallout version, or null for a local/dev build. + internal static BuildGraphModel GetModel( + IReadOnlyCollection targets, + string falloutVersion) + => new( + SchemaVersion, + falloutVersion, + targets + .OrderBy(x => x.Name, StringComparer.Ordinal) + .Select(ToModel) + .ToList()); + + /// Serializes the graph model to the exact JSON written into build-graph.json. + internal static string GetJsonString( + IReadOnlyCollection targets, + string falloutVersion) + => GetModel(targets, falloutVersion).ToJson(serializerOptions); + + // Takes the informational version up to the build-metadata separator ('+'), so the pin aligns with + // the running tool. Returns the input unchanged when there is no separator, and null only when the + // input is null/empty (e.g. a local build with no version stamped). + internal static string NormalizeVersion(string informationalVersion) + { + if (string.IsNullOrEmpty(informationalVersion)) + { + return null; + } + + var plusIndex = informationalVersion.IndexOf('+'); + return plusIndex == -1 ? informationalVersion : informationalVersion[..plusIndex]; + } + + private static TargetModel ToModel(ExecutableTarget target) + => new( + target.Name, + target.Description, + target.Member?.DeclaringType?.Name, + target.IsDefault, + target.Listed, + SortedNames(target.ExecutionDependencies), + SortedNames(target.OrderDependencies), + SortedNames(target.TriggerDependencies), + SortedNames(target.Triggers)); + + // Sorted for deterministic output — the graph carries no execution order, so the display + // order is irrelevant to consumers and a stable ordering avoids spurious file churn. + private static IReadOnlyList SortedNames(IEnumerable targets) + => targets.Select(x => x.Name).OrderBy(x => x, StringComparer.Ordinal).ToList(); + + internal sealed record BuildGraphModel( + int Version, + string FalloutVersion, + IReadOnlyList Targets); + + internal sealed record TargetModel( + string Name, + string Description, + string DeclaredIn, + bool Default, + bool Listed, + IReadOnlyList DependsOn, + IReadOnlyList After, + IReadOnlyList TriggeredBy, + IReadOnlyList Triggers); +} diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs new file mode 100644 index 000000000..a621f3847 --- /dev/null +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Fallout.Common.Execution; +using Fallout.Common.IO; + +namespace Fallout.Build.Execution.Extensions; + +/// +/// Emits build-graph.json into the temporary directory on every build initialization, +/// giving editor tooling (the VS Code extension) a machine-readable projection of the target +/// graph. The projection itself lives in ; this attribute only owns +/// the build-lifecycle hook and the file write. Best-effort — a serialization failure never fails +/// the build. +/// +internal class SerializeBuildGraphAttribute : BuildExtensionAttributeBase, IOnBuildInitialized +{ + private const string GraphFileName = "build-graph.json"; + + private AbsolutePath GraphFile => Build.TemporaryDirectory / GraphFileName; + + public void OnBuildInitialized( + IReadOnlyCollection executableTargets, + IReadOnlyCollection executionPlan) + { + try + { + var json = BuildGraphUtility.GetJsonString(executableTargets, FindFalloutVersion()); + GraphFile.WriteAllText(json); + } + catch (Exception exception) + { + // Emission is a convenience for editor tooling — never let it break a build. + Serilog.Log.Verbose(exception, "Failed to emit {GraphFileName}", GraphFileName); + } + } + + // Mirrors Fallout.Migrate: the informational version of the running Fallout assembly, up to the + // build-metadata separator, so the pin aligns with the running tool. Null when unstamped. + private static string FindFalloutVersion() + => BuildGraphUtility.NormalizeVersion( + typeof(SerializeBuildGraphAttribute).Assembly + .GetCustomAttribute() + ?.InformationalVersion); +} diff --git a/src/Fallout.Build/FalloutBuild.cs b/src/Fallout.Build/FalloutBuild.cs index af8573875..a604cccc8 100644 --- a/src/Fallout.Build/FalloutBuild.cs +++ b/src/Fallout.Build/FalloutBuild.cs @@ -52,6 +52,7 @@ namespace Fallout.Common; // [SaveBuildProfile(Priority = 30)] // [LoadBuildProfiles(Priority = 25)] // After logo +[SerializeBuildGraph(Priority = 20)] [HandlePlanRequests(Priority = 10)] [HandleHelpRequests(Priority = 5)] [Telemetry] diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Empty_graph_matches_the_contract_snapshot.verified.json b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Empty_graph_matches_the_contract_snapshot.verified.json new file mode 100644 index 000000000..6dfd4a355 --- /dev/null +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Empty_graph_matches_the_contract_snapshot.verified.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "falloutVersion": null, + "targets": [] +} \ No newline at end of file diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json new file mode 100644 index 000000000..67baa6095 --- /dev/null +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "falloutVersion": "2026.1.0-preview.42", + "targets": [ + { + "name": "Compile", + "description": "Builds all projects", + "declaredIn": "SampleBuild", + "default": false, + "listed": true, + "dependsOn": [ + "Restore" + ], + "after": [], + "triggeredBy": [], + "triggers": [ + "Publish" + ] + }, + { + "name": "Publish", + "description": null, + "declaredIn": null, + "default": false, + "listed": false, + "dependsOn": [], + "after": [], + "triggeredBy": [ + "Test" + ], + "triggers": [] + }, + { + "name": "Restore", + "description": null, + "declaredIn": null, + "default": false, + "listed": true, + "dependsOn": [], + "after": [], + "triggeredBy": [], + "triggers": [] + }, + { + "name": "Test", + "description": null, + "declaredIn": null, + "default": true, + "listed": true, + "dependsOn": [ + "Compile" + ], + "after": [ + "Restore" + ], + "triggeredBy": [], + "triggers": [] + } + ] +} \ No newline at end of file diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs new file mode 100644 index 000000000..38776eb30 --- /dev/null +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; +using Fallout.Build.Execution.Extensions; +using Fallout.Common.Execution; +using FluentAssertions; +using VerifyXunit; +using Xunit; + +namespace Fallout.Common.Specs.Execution; + +/// +/// Contract tests for build-graph.json. This JSON is consumed by the VS Code extension, so the +/// shape must not drift silently — the verified snapshots below are the contract. A change that fails +/// them is a schema change and demands bumping plus a +/// matching update on the extension side. +/// +public class BuildGraphUtilitySpecs +{ + private const string SampleVersion = "2026.1.0-preview.42"; + + // A representative graph exercising every emitted field and relation kind. + private static IReadOnlyCollection SampleGraph() + { + var restore = new ExecutableTarget { Name = "Restore", Listed = true }; + var compile = new ExecutableTarget + { + Name = "Compile", + Description = "Builds all projects", + Listed = true, + Member = MemberOf(nameof(SampleBuild.Compile)), + }; + var test = new ExecutableTarget { Name = "Test", Listed = true, IsDefault = true }; + var publish = new ExecutableTarget { Name = "Publish", Listed = false }; + + compile.ExecutionDependencies.Add(restore); + test.ExecutionDependencies.Add(compile); + test.OrderDependencies.Add(restore); + publish.TriggerDependencies.Add(test); + compile.Triggers.Add(publish); + + // Deliberately unsorted so the ordinal ordering guarantee is exercised. + return new[] { test, publish, compile, restore }; + } + + [Fact] + public Task Sample_graph_matches_the_contract_snapshot() + => Verifier.Verify(BuildGraphUtility.GetJsonString(SampleGraph(), SampleVersion), "json"); + + [Fact] + public Task Empty_graph_matches_the_contract_snapshot() + => Verifier.Verify(BuildGraphUtility.GetJsonString(new ExecutableTarget[0], falloutVersion: null), "json"); + + [Fact] + public void Schema_version_is_1() + { + // A change here is a breaking contract change — update the VS Code extension's + // SUPPORTED_SCHEMA_VERSION and this guard together, deliberately. + BuildGraphUtility.SchemaVersion.Should().Be(1); + } + + [Fact] + public void Targets_are_ordered_by_name_ordinally() + { + var model = BuildGraphUtility.GetModel(SampleGraph(), SampleVersion); + + model.Targets.Select(x => x.Name).Should().Equal("Compile", "Publish", "Restore", "Test"); + } + + [Fact] + public void Relation_kinds_map_to_their_own_fields() + { + var compile = ModelFor("Compile"); + var test = ModelFor("Test"); + var publish = ModelFor("Publish"); + + compile.DependsOn.Should().Equal("Restore"); + compile.Triggers.Should().Equal("Publish"); + test.DependsOn.Should().Equal("Compile"); + test.After.Should().Equal("Restore"); + publish.TriggeredBy.Should().Equal("Test"); + } + + [Fact] + public void Dependency_lists_are_ordered_ordinally() + { + var target = new ExecutableTarget { Name = "Root" }; + target.ExecutionDependencies.Add(new ExecutableTarget { Name = "Zebra" }); + target.ExecutionDependencies.Add(new ExecutableTarget { Name = "Alpha" }); + target.ExecutionDependencies.Add(new ExecutableTarget { Name = "Mango" }); + + var model = BuildGraphUtility.GetModel(new[] { target }, SampleVersion).Targets.Single(); + + model.DependsOn.Should().Equal("Alpha", "Mango", "Zebra"); + } + + [Fact] + public void Default_and_listed_flags_are_projected() + { + var test = ModelFor("Test"); + var publish = ModelFor("Publish"); + + test.Default.Should().BeTrue(); + test.Listed.Should().BeTrue(); + publish.Default.Should().BeFalse(); + publish.Listed.Should().BeFalse(); + } + + [Fact] + public void DeclaredIn_is_the_declaring_type_simple_name_or_null() + { + ModelFor("Compile").DeclaredIn.Should().Be(nameof(SampleBuild)); + // Restore has no backing member, so there is nothing to disambiguate go-to-definition with. + ModelFor("Restore").DeclaredIn.Should().BeNull(); + } + + [Fact] + public void Optional_string_fields_are_emitted_as_null_rather_than_omitted() + { + // The extension's Target interface marks description/declaredIn optional; we keep the keys + // present (as null) for a stable shape, so consumers can rely on the property existing. + using var doc = JsonDocument.Parse(BuildGraphUtility.GetJsonString(SampleGraph(), falloutVersion: null)); + + doc.RootElement.GetProperty("falloutVersion").ValueKind.Should().Be(JsonValueKind.Null); + + var restore = doc.RootElement.GetProperty("targets").EnumerateArray() + .Single(x => x.GetProperty("name").GetString() == "Restore"); + restore.GetProperty("description").ValueKind.Should().Be(JsonValueKind.Null); + restore.GetProperty("declaredIn").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public void Root_and_target_property_names_are_camelCase() + { + using var doc = JsonDocument.Parse(BuildGraphUtility.GetJsonString(SampleGraph(), SampleVersion)); + + doc.RootElement.EnumerateObject().Select(x => x.Name) + .Should().Equal("version", "falloutVersion", "targets"); + + var firstTarget = doc.RootElement.GetProperty("targets").EnumerateArray().First(); + firstTarget.EnumerateObject().Select(x => x.Name) + .Should().Equal( + "name", "description", "declaredIn", "default", "listed", + "dependsOn", "after", "triggeredBy", "triggers"); + } + + [Theory] + [InlineData("2026.1.0-preview.42+abc123", "2026.1.0-preview.42")] + [InlineData("2026.1.0", "2026.1.0")] + [InlineData("10.0.0-rc.1", "10.0.0-rc.1")] + [InlineData("", null)] + [InlineData(null, null)] + public void NormalizeVersion_strips_build_metadata(string input, string expected) + { + BuildGraphUtility.NormalizeVersion(input).Should().Be(expected); + } + + private static BuildGraphUtility.TargetModel ModelFor(string name) + => BuildGraphUtility.GetModel(SampleGraph(), SampleVersion).Targets.Single(x => x.Name == name); + + private static MemberInfo MemberOf(string name) + => typeof(SampleBuild).GetProperty(name, BindingFlags.Instance | BindingFlags.Public); + + // Backing type whose name flows into `declaredIn`. + private class SampleBuild + { + public object Compile => null; + } +}