From d031915938932a646d99b50ed9991f29621d1d37 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 13 Jul 2026 15:10:48 +1200 Subject: [PATCH 1/5] Emit build-graph.json on build initialization for editor tooling Add SerializeBuildGraphAttribute, which writes a machine-readable projection of the target graph to /build-graph.json on every build init: target names, descriptions, declaring type, default/listed flags, and the four relation kinds (dependsOn/after/triggeredBy/triggers). Reuses the same graph data as the --plan renderer. Runs at priority 20 so --plan and --help runs also refresh it. Best-effort: wrapped so a serialization failure can never fail a build. Consumed by the VS Code extension's targets view and dependency graph. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SerializeBuildGraphAttribute.cs | 104 ++++++++++++++++++ src/Fallout.Build/FalloutBuild.cs | 1 + 2 files changed, 105 insertions(+) create mode 100644 src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs new file mode 100644 index 000000000..6a64fc90a --- /dev/null +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Fallout.Common.Execution; +using Fallout.Common.IO; +using Fallout.Common.Utilities; + +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: names, descriptions, the default/listed flags, the declaring type, and the four +/// relation kinds. Best-effort — a serialization failure never fails the build. +/// +internal class SerializeBuildGraphAttribute : BuildExtensionAttributeBase, IOnBuildInitialized +{ + private const string GraphFileName = "build-graph.json"; + + /// Schema version consumers gate on; bump only on a breaking shape change. + private const int SchemaVersion = 1; + + private static readonly JsonSerializerOptions s_options = + new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + private AbsolutePath GraphFile => Build.TemporaryDirectory / GraphFileName; + + public void OnBuildInitialized( + IReadOnlyCollection executableTargets, + IReadOnlyCollection executionPlan) + { + try + { + var model = new BuildGraphModel( + SchemaVersion, + ResolveFalloutVersion(), + executableTargets + .OrderBy(x => x.Name, StringComparer.Ordinal) + .Select(ToModel) + .ToList()); + + GraphFile.WriteJson(model, s_options); + } + 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); + } + } + + 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(); + + // Mirrors Fallout.Migrate: the informational version up to the build-metadata separator, + // so the pin aligns with the running tool. Null for local/dev builds without a `+` suffix. + private static string ResolveFalloutVersion() + { + var informational = typeof(SerializeBuildGraphAttribute).Assembly + .GetCustomAttribute() + ?.InformationalVersion; + if (string.IsNullOrEmpty(informational)) + return null; + + var plusIndex = informational.IndexOf('+'); + return plusIndex == -1 ? informational : informational[..plusIndex]; + } + + private sealed record BuildGraphModel( + int Version, + string FalloutVersion, + IReadOnlyList Targets); + + private 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/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] From 1958faec00611c810a669789cb9627f878a0f077 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 13 Jul 2026 17:49:08 +1200 Subject: [PATCH 2/5] Update SerializeBuildGraphAttribute.cs Co-authored-by: ITaluone <44049228+ITaluone@users.noreply.github.com> --- .../Execution/Extensions/SerializeBuildGraphAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs index 6a64fc90a..b188cd9bd 100644 --- a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -23,7 +23,7 @@ internal class SerializeBuildGraphAttribute : BuildExtensionAttributeBase, IOnBu /// Schema version consumers gate on; bump only on a breaking shape change. private const int SchemaVersion = 1; - private static readonly JsonSerializerOptions s_options = + private static readonly JsonSerializerOptions serializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, From 4c99afd4092c84ee446a7ffe87d7fc9a663a4740 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 13 Jul 2026 17:49:14 +1200 Subject: [PATCH 3/5] Update SerializeBuildGraphAttribute.cs Co-authored-by: ITaluone <44049228+ITaluone@users.noreply.github.com> --- .../Execution/Extensions/SerializeBuildGraphAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs index b188cd9bd..bb46fe1af 100644 --- a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -46,7 +46,7 @@ public void OnBuildInitialized( .Select(ToModel) .ToList()); - GraphFile.WriteJson(model, s_options); + GraphFile.WriteJson(model, serializerOptions ); } catch (Exception exception) { From e8c6f35bbd4315f7f1328f729a4c66db9d2c33d9 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 13 Jul 2026 19:54:04 +1200 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Execution/Extensions/SerializeBuildGraphAttribute.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs index bb46fe1af..ef9297315 100644 --- a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Reflection; using System.Text.Json; -using System.Text.Json.Serialization; using Fallout.Common.Execution; using Fallout.Common.IO; using Fallout.Common.Utilities; From cb69ec32e6bbafdc16b5dbf7b911e2b137800649 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 13 Jul 2026 20:01:06 +1200 Subject: [PATCH 5/5] Extract build-graph projection into testable BuildGraphUtility Move the target-graph projection and JSON serialization out of SerializeBuildGraphAttribute into a pure BuildGraphUtility, so the build-graph.json contract the VS Code extension consumes can be snapshot-tested without driving a full build. The attribute keeps only the build-lifecycle hook and the file write. Add BuildGraphUtilitySpecs: Verify snapshots that lock the emitted JSON shape, plus unit coverage for target/dependency ordering, relation-kind mapping, declaredIn, null handling, and version normalization. Address review feedback: rename ResolveFalloutVersion -> FindFalloutVersion (it can return null) and brace the null guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Execution/Extensions/BuildGraphUtility.cs | 99 ++++++++++ .../SerializeBuildGraphAttribute.cs | 82 ++------- ...atches_the_contract_snapshot.verified.json | 5 + ...atches_the_contract_snapshot.verified.json | 60 ++++++ .../BuildGraphUtilitySpecs.cs | 171 ++++++++++++++++++ 5 files changed, 347 insertions(+), 70 deletions(-) create mode 100644 src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs create mode 100644 tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Empty_graph_matches_the_contract_snapshot.verified.json create mode 100644 tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json create mode 100644 tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs 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 index ef9297315..a621f3847 100644 --- a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -1,34 +1,22 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; -using System.Text.Json; using Fallout.Common.Execution; using Fallout.Common.IO; -using Fallout.Common.Utilities; 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: names, descriptions, the default/listed flags, the declaring type, and the four -/// relation kinds. Best-effort — a serialization failure never fails the build. +/// 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"; - /// Schema version consumers gate on; bump only on a breaking shape change. - private const int SchemaVersion = 1; - - private static readonly JsonSerializerOptions serializerOptions = - new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - }; - private AbsolutePath GraphFile => Build.TemporaryDirectory / GraphFileName; public void OnBuildInitialized( @@ -37,15 +25,8 @@ public void OnBuildInitialized( { try { - var model = new BuildGraphModel( - SchemaVersion, - ResolveFalloutVersion(), - executableTargets - .OrderBy(x => x.Name, StringComparer.Ordinal) - .Select(ToModel) - .ToList()); - - GraphFile.WriteJson(model, serializerOptions ); + var json = BuildGraphUtility.GetJsonString(executableTargets, FindFalloutVersion()); + GraphFile.WriteAllText(json); } catch (Exception exception) { @@ -54,50 +35,11 @@ public void OnBuildInitialized( } } - 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(); - - // Mirrors Fallout.Migrate: the informational version up to the build-metadata separator, - // so the pin aligns with the running tool. Null for local/dev builds without a `+` suffix. - private static string ResolveFalloutVersion() - { - var informational = typeof(SerializeBuildGraphAttribute).Assembly - .GetCustomAttribute() - ?.InformationalVersion; - if (string.IsNullOrEmpty(informational)) - return null; - - var plusIndex = informational.IndexOf('+'); - return plusIndex == -1 ? informational : informational[..plusIndex]; - } - - private sealed record BuildGraphModel( - int Version, - string FalloutVersion, - IReadOnlyList Targets); - - private sealed record TargetModel( - string Name, - string Description, - string DeclaredIn, - bool Default, - bool Listed, - IReadOnlyList DependsOn, - IReadOnlyList After, - IReadOnlyList TriggeredBy, - IReadOnlyList Triggers); + // 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/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; + } +}