Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Pure projection of the target graph into the <c>build-graph.json</c> 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.
/// <para>
/// This is the machine-readable contract the extension gates on — the JSON shape must stay stable.
/// Any breaking change to it requires bumping <see cref="SchemaVersion"/>. The projection is kept
/// separate from <see cref="SerializeBuildGraphAttribute"/> (which owns the build-lifecycle hook and
/// file I/O) so the contract can be snapshot-tested without driving a build.
/// </para>
/// </summary>
internal static class BuildGraphUtility
{
/// <summary>Schema version consumers gate on; bump only on a breaking shape change.</summary>
internal const int SchemaVersion = 1;

private static readonly JsonSerializerOptions serializerOptions =
new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};

/// <summary>Projects the targets into the serializable graph model.</summary>
/// <param name="targets">The build's executable targets, in any order.</param>
/// <param name="falloutVersion">The running Fallout version, or <c>null</c> for a local/dev build.</param>
internal static BuildGraphModel GetModel(
IReadOnlyCollection<ExecutableTarget> targets,
string falloutVersion)
=> new(
SchemaVersion,
falloutVersion,
targets
.OrderBy(x => x.Name, StringComparer.Ordinal)
.Select(ToModel)
.ToList());

/// <summary>Serializes the graph model to the exact JSON written into <c>build-graph.json</c>.</summary>
internal static string GetJsonString(
IReadOnlyCollection<ExecutableTarget> 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<string> SortedNames(IEnumerable<ExecutableTarget> targets)
=> targets.Select(x => x.Name).OrderBy(x => x, StringComparer.Ordinal).ToList();

internal sealed record BuildGraphModel(
int Version,
string FalloutVersion,
IReadOnlyList<TargetModel> Targets);

internal sealed record TargetModel(
string Name,
string Description,
string DeclaredIn,
bool Default,
bool Listed,
IReadOnlyList<string> DependsOn,
IReadOnlyList<string> After,
IReadOnlyList<string> TriggeredBy,
IReadOnlyList<string> Triggers);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Emits <c>build-graph.json</c> 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 <see cref="BuildGraphUtility"/>; this attribute only owns
/// the build-lifecycle hook and the file write. Best-effort — a serialization failure never fails
/// the build.
/// </summary>
internal class SerializeBuildGraphAttribute : BuildExtensionAttributeBase, IOnBuildInitialized
{
private const string GraphFileName = "build-graph.json";

private AbsolutePath GraphFile => Build.TemporaryDirectory / GraphFileName;

public void OnBuildInitialized(
IReadOnlyCollection<ExecutableTarget> executableTargets,
IReadOnlyCollection<ExecutableTarget> 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<AssemblyInformationalVersionAttribute>()
?.InformationalVersion);
}
1 change: 1 addition & 0 deletions src/Fallout.Build/FalloutBuild.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": 1,
"falloutVersion": null,
"targets": []
}
Original file line number Diff line number Diff line change
@@ -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": []
}
]
}
171 changes: 171 additions & 0 deletions tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Contract tests for <c>build-graph.json</c>. 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 <see cref="BuildGraphUtility.SchemaVersion"/> plus a
/// matching update on the extension side.
/// </summary>
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<ExecutableTarget> 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;
}
}
Loading