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