From 8de27757c4e28c38f2a92d32bd36bd18c78431c2 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 12:12:49 +0200 Subject: [PATCH 01/10] Scaffold draft PR for exposing the build model as JSON From 219c90f105cd3c2d9e1bf2f741ccda21734ebad2 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 12:15:33 +0200 Subject: [PATCH 02/10] Project tool requirements onto the build-graph target model --- .../Execution/Extensions/BuildGraphUtility.cs | 35 +++++++++++++++++-- ...atches_the_contract_snapshot.verified.json | 21 +++++++++-- .../BuildGraphUtilitySpecs.cs | 21 ++++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs index cda806ffd..815642634 100644 --- a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs +++ b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text.Json; using Fallout.Common.Execution; +using Fallout.Common.Tooling; using Fallout.Common.Utilities; namespace Fallout.Build.Execution.Extensions; @@ -74,7 +75,29 @@ private static TargetModel ToModel(ExecutableTarget target) SortedNames(target.ExecutionDependencies), SortedNames(target.OrderDependencies), SortedNames(target.TriggerDependencies), - SortedNames(target.Triggers)); + SortedNames(target.Triggers), + ToolRequirements(target)); + + // Sorted for the same reason as SortedNames: the declaration order carries no meaning to a + // consumer, and a stable ordering keeps build-graph.json free of spurious churn. + private static IReadOnlyList ToolRequirements(ExecutableTarget target) + => target.ToolRequirements + .Select(ToModel) + .OrderBy(x => x.Kind, StringComparer.Ordinal) + .ThenBy(x => x.PackageId, StringComparer.Ordinal) + .ToList(); + + // A path requirement names an executable rather than a package, and neither it nor an apt-get + // requirement carries a version — both report null rather than inventing one. + private static ToolRequirementModel ToModel(ToolRequirement requirement) + => requirement switch + { + NuGetPackageRequirement x => new ToolRequirementModel("nuget", x.PackageId, x.Version), + NpmPackageRequirement x => new ToolRequirementModel("npm", x.PackageId, x.Version), + AptGetPackageRequirement x => new ToolRequirementModel("aptget", x.PackageId, Version: null), + PathToolRequirement x => new ToolRequirementModel("path", x.PathExecutable, Version: null), + _ => new ToolRequirementModel("unknown", requirement.GetType().Name, Version: null) + }; // 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. @@ -95,5 +118,13 @@ internal sealed record TargetModel( IReadOnlyList DependsOn, IReadOnlyList After, IReadOnlyList TriggeredBy, - IReadOnlyList Triggers); + IReadOnlyList Triggers, + IReadOnlyList ToolRequirements); + + /// + /// One declared tool dependency. is nuget, npm, + /// aptget or path; carries the executable name for + /// a path requirement. is null for the kinds that have none. + /// + internal sealed record ToolRequirementModel(string Kind, string PackageId, string Version); } 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 index 67baa6095..0c26ff0f3 100644 --- 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 @@ -15,6 +15,18 @@ "triggeredBy": [], "triggers": [ "Publish" + ], + "toolRequirements": [ + { + "kind": "nuget", + "packageId": "GitVersion.Tool", + "version": "5.12.0" + }, + { + "kind": "path", + "packageId": "git", + "version": null + } ] }, { @@ -28,7 +40,8 @@ "triggeredBy": [ "Test" ], - "triggers": [] + "triggers": [], + "toolRequirements": [] }, { "name": "Restore", @@ -39,7 +52,8 @@ "dependsOn": [], "after": [], "triggeredBy": [], - "triggers": [] + "triggers": [], + "toolRequirements": [] }, { "name": "Test", @@ -54,7 +68,8 @@ "Restore" ], "triggeredBy": [], - "triggers": [] + "triggers": [], + "toolRequirements": [] } ] } \ No newline at end of file diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs index 38776eb30..0d0bd1915 100644 --- a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Fallout.Build.Execution.Extensions; using Fallout.Common.Execution; +using Fallout.Common.Tooling; using FluentAssertions; using VerifyXunit; using Xunit; @@ -41,6 +42,10 @@ private static IReadOnlyCollection SampleGraph() publish.TriggerDependencies.Add(test); compile.Triggers.Add(publish); + // Two kinds, one carrying a version and one not, so the projection covers both shapes. + compile.ToolRequirements.Add(new NuGetPackageRequirement("GitVersion.Tool", "5.12.0")); + compile.ToolRequirements.Add(new PathToolRequirement("git")); + // Deliberately unsorted so the ordinal ordering guarantee is exercised. return new[] { test, publish, compile, restore }; } @@ -143,7 +148,7 @@ public void Root_and_target_property_names_are_camelCase() firstTarget.EnumerateObject().Select(x => x.Name) .Should().Equal( "name", "description", "declaredIn", "default", "listed", - "dependsOn", "after", "triggeredBy", "triggers"); + "dependsOn", "after", "triggeredBy", "triggers", "toolRequirements"); } [Theory] @@ -157,6 +162,20 @@ public void NormalizeVersion_strips_build_metadata(string input, string expected BuildGraphUtility.NormalizeVersion(input).Should().Be(expected); } + [Fact] + public void Tool_requirements_are_projected_with_their_kind() + { + ModelFor("Compile").ToolRequirements.Should().Equal( + new BuildGraphUtility.ToolRequirementModel("nuget", "GitVersion.Tool", "5.12.0"), + new BuildGraphUtility.ToolRequirementModel("path", "git", Version: null)); + } + + [Fact] + public void Targets_without_tool_requirements_emit_an_empty_list() + { + ModelFor("Restore").ToolRequirements.Should().BeEmpty(); + } + private static BuildGraphUtility.TargetModel ModelFor(string name) => BuildGraphUtility.GetModel(SampleGraph(), SampleVersion).Targets.Single(x => x.Name == name); From 94e52783cf8edd133247904fc8d92a448d434ca4 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 12:23:22 +0200 Subject: [PATCH 03/10] Project declared parameters onto the build-graph model --- .../Execution/Extensions/BuildGraphUtility.cs | 70 ++++++++++++++++++- .../SerializeBuildGraphAttribute.cs | 6 +- ...atches_the_contract_snapshot.verified.json | 3 +- ...atches_the_contract_snapshot.verified.json | 3 +- .../BuildGraphUtilitySpecs.cs | 66 ++++++++++++++++- 5 files changed, 143 insertions(+), 5 deletions(-) diff --git a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs index 815642634..18ece249c 100644 --- a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs +++ b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Text.Json; +using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.Tooling; using Fallout.Common.Utilities; +using Fallout.Common.ValueInjection; namespace Fallout.Build.Execution.Extensions; @@ -37,12 +40,29 @@ internal static class BuildGraphUtility internal static BuildGraphModel GetModel( IReadOnlyCollection targets, string falloutVersion) + => GetModel(targets, falloutVersion, new MemberInfo[0]); + + /// Projects the targets and the build's declared parameters into the serializable model. + /// The build's executable targets, in any order. + /// The running Fallout version, or null for a local/dev build. + /// + /// The declared parameter members, from — + /// the same set --help lists, inherited component parameters included. + /// + internal static BuildGraphModel GetModel( + IReadOnlyCollection targets, + string falloutVersion, + IReadOnlyCollection parameterMembers) => new( SchemaVersion, falloutVersion, targets .OrderBy(x => x.Name, StringComparer.Ordinal) .Select(ToModel) + .ToList(), + parameterMembers + .Select(ToModel) + .OrderBy(x => x.Name, StringComparer.Ordinal) .ToList()); /// Serializes the graph model to the exact JSON written into build-graph.json. @@ -51,6 +71,13 @@ internal static string GetJsonString( string falloutVersion) => GetModel(targets, falloutVersion).ToJson(serializerOptions); + /// Serializes the graph model, parameters included. + internal static string GetJsonString( + IReadOnlyCollection targets, + string falloutVersion, + IReadOnlyCollection parameterMembers) + => GetModel(targets, falloutVersion, parameterMembers).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). @@ -104,10 +131,51 @@ private static ToolRequirementModel ToModel(ToolRequirement requirement) private static IReadOnlyList SortedNames(IEnumerable targets) => targets.Select(x => x.Name).OrderBy(x => x, StringComparer.Ordinal).ToList(); + // Projects one declared parameter. The value is deliberately absent: a [Secret] member's + // injected value must never reach the emitted model, and emitting non-secret defaults only + // would make the field's meaning depend on the flag next to it. + private static ParameterModel ToModel(MemberInfo member) + { + var attribute = member.GetCustomAttribute(); + return new ParameterModel( + ParameterService.GetParameterDashedName(member), + UnderlyingTypeName(member.GetMemberType()), + member.DeclaringType?.Name, + ParameterService.GetParameterDescription(member), + member.GetCustomAttribute() != null, + member.GetCustomAttribute() != null, + attribute?.List ?? true, + Default: null, + AllowedValues: null); + } + + // `int?` and `int` describe the same thing to someone typing --retries 3, so the wrapper is + // unwrapped. Note this is NOT ReflectionUtility.GetNullableType, which goes the other way + // (it *wraps* a value type) and throws outright on an interface-typed member. + private static string UnderlyingTypeName(Type type) + => (Nullable.GetUnderlyingType(type) ?? type).FullName; + internal sealed record BuildGraphModel( int Version, string FalloutVersion, - IReadOnlyList Targets); + IReadOnlyList Targets, + IReadOnlyList Parameters); + + /// + /// One declared [Parameter]. is the dashed spelling a consumer + /// types; the CLR type with unwrapped. + /// is always null — see ToModel for why. + /// + internal sealed record ParameterModel( + string Name, + string Type, + string DeclaredIn, + string Description, + bool Required, + bool Secret, + bool List, + string Default, + IReadOnlyList AllowedValues); internal sealed record TargetModel( string Name, diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs index a621f3847..18484bc29 100644 --- a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -3,6 +3,7 @@ using System.Reflection; using Fallout.Common.Execution; using Fallout.Common.IO; +using Fallout.Common.ValueInjection; namespace Fallout.Build.Execution.Extensions; @@ -25,7 +26,10 @@ public void OnBuildInitialized( { try { - var json = BuildGraphUtility.GetJsonString(executableTargets, FindFalloutVersion()); + var json = BuildGraphUtility.GetJsonString( + executableTargets, + FindFalloutVersion(), + ValueInjectionUtility.GetParameterMembers(Build.GetType(), includeUnlisted: false)); GraphFile.WriteAllText(json); } catch (Exception exception) 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 index 6dfd4a355..18d89d3df 100644 --- 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 @@ -1,5 +1,6 @@ { "version": 1, "falloutVersion": null, - "targets": [] + "targets": [], + "parameters": [] } \ 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 index 0c26ff0f3..8e6d74d77 100644 --- 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 @@ -71,5 +71,6 @@ "triggers": [], "toolRequirements": [] } - ] + ], + "parameters": [] } \ No newline at end of file diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs index 0d0bd1915..287ea56de 100644 --- a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs @@ -1,11 +1,14 @@ +using System; 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; using Fallout.Common.Execution; using Fallout.Common.Tooling; +using Fallout.Common.ValueInjection; using FluentAssertions; using VerifyXunit; using Xunit; @@ -142,7 +145,7 @@ 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"); + .Should().Equal("version", "falloutVersion", "targets", "parameters"); var firstTarget = doc.RootElement.GetProperty("targets").EnumerateArray().First(); firstTarget.EnumerateObject().Select(x => x.Name) @@ -176,6 +179,57 @@ public void Targets_without_tool_requirements_emit_an_empty_list() ModelFor("Restore").ToolRequirements.Should().BeEmpty(); } + [Fact] + public void Parameters_are_projected_without_leaking_secret_values() + { + var apiKey = ParameterModelFor("nuget-api-key"); + + apiKey.Required.Should().BeTrue(); + apiKey.Secret.Should().BeTrue(); + apiKey.Type.Should().Be("System.String"); + apiKey.DeclaredIn.Should().Be(nameof(SampleParameterBuild)); + // ParameterService trims the trailing period; --help re-appends it when rendering. + apiKey.Description.Should().Be("API key for nuget.org"); + apiKey.Default.Should().BeNull("a secret's value must never reach the emitted model"); + } + + [Fact] + public void Nullable_value_type_parameters_report_their_underlying_clr_type() + { + ParameterModelFor("retries").Type.Should().Be("System.Int32"); + } + + [Fact] + public void Inherited_built_in_parameters_are_projected_alongside_the_build_s_own() + { + var names = SampleParameters().Select(x => x.Name).ToList(); + + names.Should().Contain("nuget-api-key").And.Contain("no-logo"); + } + + [Fact] + public void Parameters_are_ordered_by_name_ordinally() + { + var names = SampleParameters().Select(x => x.Name).ToList(); + + names.Should().BeInAscendingOrder(StringComparer.Ordinal); + } + + [Fact] + public void A_graph_projected_without_parameter_members_emits_an_empty_list() + { + BuildGraphUtility.GetModel(SampleGraph(), SampleVersion).Parameters.Should().BeEmpty(); + } + + private static BuildGraphUtility.ParameterModel ParameterModelFor(string name) + => SampleParameters().Single(x => x.Name == name); + + private static IReadOnlyList SampleParameters() + => BuildGraphUtility.GetModel( + SampleGraph(), + SampleVersion, + ValueInjectionUtility.GetParameterMembers(typeof(SampleParameterBuild), includeUnlisted: false)).Parameters; + private static BuildGraphUtility.TargetModel ModelFor(string name) => BuildGraphUtility.GetModel(SampleGraph(), SampleVersion).Targets.Single(x => x.Name == name); @@ -187,4 +241,14 @@ private class SampleBuild { public object Compile => null; } + + // Private readonly fields are the idiomatic parameter declaration — see DuplicateParameterSpecs. + private class SampleParameterBuild : FalloutBuild + { + [Parameter("API key for nuget.org.")] [Required] [Secret] + private readonly string NuGetApiKey; + + [Parameter("How often to retry.")] + private readonly int? Retries; + } } From 21350c90a96834b0cd76039ec8a0cef2849fba0a Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 12:33:25 +0200 Subject: [PATCH 04/10] Add --describe emitting the build model as JSON before tool requirements run --- .fallout/build.schema.json | 8 ++ .../Execution/BuildIntrospectionService.cs | 50 +++++++++++ src/Fallout.Build/Execution/BuildManager.cs | 16 +++- .../HandleShellCompletionAttribute.cs | 5 ++ .../Execution/ValueInjectionUtility.cs | 2 + src/Fallout.Build/FalloutBuild.cs | 14 ++- .../BuildIntrospectionServiceSpecs.cs | 85 +++++++++++++++++++ ...CompletionItemsParameterBuild.verified.txt | 2 + ...GetCompletionItemsTargetBuild.verified.txt | 2 + ...TestCustomParameterAttribute.verified.json | 10 ++- ...aUtilitySpecs.TestEmptyBuild.verified.json | 8 ++ ...litySpecs.TestParameterBuild.verified.json | 10 ++- ...UtilitySpecs.TestTargetBuild.verified.json | 8 ++ 13 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 src/Fallout.Build/Execution/BuildIntrospectionService.cs create mode 100644 tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json index 74c6b5567..13468ffad 100644 --- a/.fallout/build.schema.json +++ b/.fallout/build.schema.json @@ -62,6 +62,10 @@ "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, + "Describe": { + "type": "boolean", + "description": "Prints the build model as JSON and exits without executing any target" + }, "Help": { "type": "boolean", "description": "Shows the help text for this build assembly" @@ -70,6 +74,10 @@ "description": "Host for execution. Default is 'automatic'", "$ref": "#/definitions/Host" }, + "Json": { + "type": "boolean", + "description": "Emits machine-readable JSON on standard output for read-only requests (--plan)" + }, "NoLogo": { "type": "boolean", "description": "Disables displaying the Fallout logo" diff --git a/src/Fallout.Build/Execution/BuildIntrospectionService.cs b/src/Fallout.Build/Execution/BuildIntrospectionService.cs new file mode 100644 index 000000000..9074a21c7 --- /dev/null +++ b/src/Fallout.Build/Execution/BuildIntrospectionService.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Fallout.Build.Execution.Extensions; +using Fallout.Common.ValueInjection; + +namespace Fallout.Common.Execution; + +/// +/// Owns the read-only introspection requests — --describe and --plan --json — which +/// print the build model on standard output and execute nothing. +/// +/// calls this immediately after the execution plan is resolved and +/// before . That ordering is the +/// feature, not an implementation detail: EnsureToolRequirements writes into the temporary +/// directory and shells out to dotnet restore, so an +/// extension — where --help and --plan live — could not honour "runs no external tool". +/// +/// +internal static class BuildIntrospectionService +{ + /// Whether this invocation is a read-only introspection request rather than a build. + // --plan alone keeps its existing meaning (the HTML graph); only --json redirects it here. + internal static bool IsRequested(FalloutBuild build) + => build.Describe || (build.Plan && build.Json); + + /// The whole build model: targets, dependency edges, tool requirements, parameters. + internal static string GetDescribeJson( + FalloutBuild build, + IReadOnlyCollection targets) + => GetDescribeJson(build, targets, FindFalloutVersion()); + + /// Overload taking an explicit version, so the document can be asserted deterministically. + internal static string GetDescribeJson( + FalloutBuild build, + IReadOnlyCollection targets, + string falloutVersion) + => BuildGraphUtility.GetJsonString( + targets, + falloutVersion, + ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false)); + + // Mirrors SerializeBuildGraphAttribute: the informational version of the running Fallout + // assembly, up to the build-metadata separator. Null when unstamped (a local/dev build). + private static string FindFalloutVersion() + => BuildGraphUtility.NormalizeVersion( + typeof(BuildIntrospectionService).Assembly + .GetCustomAttribute() + ?.InformationalVersion); +} diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs index 33fe49dc3..0fb6df20d 100644 --- a/src/Fallout.Build/Execution/BuildManager.cs +++ b/src/Fallout.Build/Execution/BuildManager.cs @@ -56,7 +56,8 @@ public static int Execute(Expression>[] defaultTargetExpressi NuGetToolPathResolver.NuGetAssetsConfigFile = build.NuGetAssetsConfigFile; NpmToolPathResolver.NpmPackageJsonFile = build.NpmPackageJsonFile; - if (!build.NoLogo) + // An introspection request owns standard output: the document must be the only thing on it. + if (!build.NoLogo && !BuildIntrospectionService.IsRequested(build)) build.WriteLogo(); // TODO: move InvokedTargets to ExecutableTargetFactory @@ -64,6 +65,15 @@ public static int Execute(Expression>[] defaultTargetExpressi build.ExecutableTargets, ParameterService.GetParameter(() => build.InvokedTargets)); + // Read-only introspection short-circuits ABOVE EnsureToolRequirements deliberately: + // everything below this line writes files or shells out to a tool, which --describe + // must not do. Returning here also means the executor is never reached. + if (build.Describe) + { + Console.Out.Write(BuildIntrospectionService.GetDescribeJson(build, build.ExecutableTargets)); + return build.ExitCode ??= 0; + } + ToolRequirementService.EnsureToolRequirements(build, build.ExecutionPlan); build.ExecuteExtension(x => x.OnBuildInitialized(build.ExecutableTargets, build.ExecutionPlan)); @@ -95,7 +105,9 @@ public static int Execute(Expression>[] defaultTargetExpressi void Finish() { - if (build.ExecutionPlan == null) + // The plan is resolved before the introspection short-circuit returns, so guarding on + // it alone would print the outcome tables over the emitted document. + if (build.ExecutionPlan == null || BuildIntrospectionService.IsRequested(build)) return; foreach (var target in build.ExecutionPlan) diff --git a/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs b/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs index d3b5a1d57..82ce9fb89 100644 --- a/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs @@ -28,6 +28,11 @@ public void OnBuildCreated(IReadOnlyCollection executableTarge } else if (Build.BuildProjectFile != null) { + // A read-only introspection request must leave the working tree untouched, and this + // hook fires long before the request can short-circuit — so it has to opt out here. + if (Build is FalloutBuild build && BuildIntrospectionService.IsRequested(build)) + return; + var buildSchema = SchemaUtility.GetJsonString(Build); var buildSchemaFile = GetBuildSchemaFile(Build.RootDirectory); buildSchemaFile.WriteAllText(buildSchema); diff --git a/src/Fallout.Build/Execution/ValueInjectionUtility.cs b/src/Fallout.Build/Execution/ValueInjectionUtility.cs index d1257f86c..5638196ce 100644 --- a/src/Fallout.Build/Execution/ValueInjectionUtility.cs +++ b/src/Fallout.Build/Execution/ValueInjectionUtility.cs @@ -62,6 +62,8 @@ private static void InjectValuesInternal( { nameof(FalloutBuild.Plan), nameof(FalloutBuild.Help), + nameof(FalloutBuild.Describe), + nameof(FalloutBuild.Json), nameof(FalloutBuild.Continue), nameof(FalloutBuild.NoLogo), nameof(FalloutBuild.Verbosity), diff --git a/src/Fallout.Build/FalloutBuild.cs b/src/Fallout.Build/FalloutBuild.cs index 824905fb8..1d4bf85ef 100644 --- a/src/Fallout.Build/FalloutBuild.cs +++ b/src/Fallout.Build/FalloutBuild.cs @@ -126,7 +126,7 @@ protected static int Execute(params Expression>[] defaultTarg /// Gets a value whether to show the execution plan (HTML). /// [Parameter("Shows the execution plan (HTML).")] - public bool Plan { get; } + public bool Plan { get; internal set; } /// /// Gets a value whether to show the help text for this build assembly. @@ -134,6 +134,18 @@ protected static int Execute(params Expression>[] defaultTarg [Parameter("Shows the help text for this build assembly.")] public bool Help { get; } + /// + /// Gets a value whether to print the build model as JSON and exit without executing anything. + /// + [Parameter("Prints the build model as JSON and exits without executing any target.")] + public bool Describe { get; internal set; } + + /// + /// Gets a value whether read-only requests emit machine-readable JSON on standard output. + /// + [Parameter("Emits machine-readable JSON on standard output for read-only requests (--plan).")] + public bool Json { get; internal set; } + /// /// Gets a value whether to display the Fallout logo. /// diff --git a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs new file mode 100644 index 000000000..3e125d5c1 --- /dev/null +++ b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Fallout.Common; +using Fallout.Common.Execution; +using FluentAssertions; +using Xunit; + +namespace Fallout.Common.Specs.Execution; + +/// +/// Covers the read-only introspection requests — --describe and --plan --json — that +/// short-circuit above . The +/// documents are a machine-facing contract, so they are asserted as parsed JSON rather than text. +/// +public class BuildIntrospectionServiceSpecs +{ + private const string SampleVersion = "2026.1.0-preview.42"; + + [Fact] + public void Describe_is_requested_by_the_describe_flag_alone() + { + BuildIntrospectionService.IsRequested(new SampleBuild { Describe = true }).Should().BeTrue(); + } + + [Fact] + public void Plan_json_is_requested_only_when_both_flags_are_set() + { + // --plan on its own keeps its existing behaviour: the HTML graph, opened in a browser. + BuildIntrospectionService.IsRequested(new SampleBuild { Plan = true }).Should().BeFalse(); + BuildIntrospectionService.IsRequested(new SampleBuild { Json = true }).Should().BeFalse(); + BuildIntrospectionService.IsRequested(new SampleBuild { Plan = true, Json = true }).Should().BeTrue(); + } + + [Fact] + public void An_ordinary_run_requests_no_introspection() + { + BuildIntrospectionService.IsRequested(new SampleBuild()).Should().BeFalse(); + } + + [Fact] + public void Describe_document_carries_targets_and_parameters_and_parses_as_json() + { + var json = BuildIntrospectionService.GetDescribeJson( + new SampleBuild(), SampleGraph(), SampleVersion); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + + root.GetProperty("version").GetInt32().Should().Be(1); + root.GetProperty("falloutVersion").GetString().Should().Be(SampleVersion); + root.GetProperty("targets").EnumerateArray().Select(x => x.GetProperty("name").GetString()) + .Should().Equal("Compile", "Restore"); + root.GetProperty("parameters").GetArrayLength().Should().BeGreaterThan(0); + } + + [Fact] + public void Describe_document_projects_the_build_s_own_parameters() + { + var json = BuildIntrospectionService.GetDescribeJson( + new SampleBuild(), SampleGraph(), SampleVersion); + + using var document = JsonDocument.Parse(json); + var names = document.RootElement.GetProperty("parameters").EnumerateArray() + .Select(x => x.GetProperty("name").GetString()).ToList(); + + names.Should().Contain("api-key"); + } + + private static IReadOnlyCollection SampleGraph() + { + var restore = new ExecutableTarget { Name = "Restore", Listed = true }; + var compile = new ExecutableTarget { Name = "Compile", Listed = true }; + compile.ExecutionDependencies.Add(restore); + return new[] { restore, compile }; + } + + private class SampleBuild : FalloutBuild + { + [Parameter("An API key.")] + private readonly string ApiKey; + } +} diff --git a/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsParameterBuild.verified.txt b/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsParameterBuild.verified.txt index 00eddc288..20bedd176 100644 --- a/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsParameterBuild.verified.txt +++ b/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsParameterBuild.verified.txt @@ -11,6 +11,7 @@ Debug, Release ], + Describe: [], Help: [], Host: [ Rider, @@ -19,6 +20,7 @@ VSCode ], IntegerArrayParam: [], + Json: [], NoLogo: [], NullableBooleanParam: [], Partition: [], diff --git a/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsTargetBuild.verified.txt b/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsTargetBuild.verified.txt index eace258d2..bbc7d733a 100644 --- a/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsTargetBuild.verified.txt +++ b/tests/Fallout.Build.Specs/CompletionUtilitySpecs.TestGetCompletionItemsTargetBuild.verified.txt @@ -1,6 +1,7 @@ { BuildProjectFile: [], Continue: [], + Describe: [], Help: [], Host: [ Rider, @@ -8,6 +9,7 @@ VisualStudio, VSCode ], + Json: [], NoLogo: [], Partition: [], Plan: [], diff --git a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json index dd7b413a8..14b0d74b8 100644 --- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json +++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json-schema.org/draft-04/schema#", "definitions": { "Host": { @@ -30,6 +30,10 @@ "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, + "Describe": { + "type": "boolean", + "description": "Prints the build model as JSON and exits without executing any target" + }, "Help": { "type": "boolean", "description": "Shows the help text for this build assembly" @@ -38,6 +42,10 @@ "description": "Host for execution. Default is 'automatic'", "$ref": "#/definitions/Host" }, + "Json": { + "type": "boolean", + "description": "Emits machine-readable JSON on standard output for read-only requests (--plan)" + }, "NoLogo": { "type": "boolean", "description": "Disables displaying the Fallout logo" diff --git a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestEmptyBuild.verified.json b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestEmptyBuild.verified.json index 8ca8fc77e..471603851 100644 --- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestEmptyBuild.verified.json +++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestEmptyBuild.verified.json @@ -30,6 +30,10 @@ "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, + "Describe": { + "type": "boolean", + "description": "Prints the build model as JSON and exits without executing any target" + }, "Help": { "type": "boolean", "description": "Shows the help text for this build assembly" @@ -38,6 +42,10 @@ "description": "Host for execution. Default is 'automatic'", "$ref": "#/definitions/Host" }, + "Json": { + "type": "boolean", + "description": "Emits machine-readable JSON on standard output for read-only requests (--plan)" + }, "NoLogo": { "type": "boolean", "description": "Disables displaying the Fallout logo" diff --git a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json index 9e5fa8751..8c9e0a3a2 100644 --- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json +++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json-schema.org/draft-04/schema#", "definitions": { "ComplexType": { @@ -75,6 +75,10 @@ "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, + "Describe": { + "type": "boolean", + "description": "Prints the build model as JSON and exits without executing any target" + }, "Help": { "type": "boolean", "description": "Shows the help text for this build assembly" @@ -83,6 +87,10 @@ "description": "Host for execution. Default is 'automatic'", "$ref": "#/definitions/Host" }, + "Json": { + "type": "boolean", + "description": "Emits machine-readable JSON on standard output for read-only requests (--plan)" + }, "NoLogo": { "type": "boolean", "description": "Disables displaying the Fallout logo" diff --git a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestTargetBuild.verified.json b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestTargetBuild.verified.json index d918549a1..adb5a0c60 100644 --- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestTargetBuild.verified.json +++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestTargetBuild.verified.json @@ -36,6 +36,10 @@ "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, + "Describe": { + "type": "boolean", + "description": "Prints the build model as JSON and exits without executing any target" + }, "Help": { "type": "boolean", "description": "Shows the help text for this build assembly" @@ -44,6 +48,10 @@ "description": "Host for execution. Default is 'automatic'", "$ref": "#/definitions/Host" }, + "Json": { + "type": "boolean", + "description": "Emits machine-readable JSON on standard output for read-only requests (--plan)" + }, "NoLogo": { "type": "boolean", "description": "Disables displaying the Fallout logo" From 56e860b53f6073647d30b5b66bb05ceaa89c0006 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 12:37:01 +0200 Subject: [PATCH 05/10] Add --plan --json emitting the resolved execution plan --- .../Execution/BuildIntrospectionService.cs | 79 +++++++++++++++++++ src/Fallout.Build/Execution/BuildManager.cs | 5 +- .../BuildIntrospectionServiceSpecs.cs | 64 +++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/Fallout.Build/Execution/BuildIntrospectionService.cs b/src/Fallout.Build/Execution/BuildIntrospectionService.cs index 9074a21c7..73788051b 100644 --- a/src/Fallout.Build/Execution/BuildIntrospectionService.cs +++ b/src/Fallout.Build/Execution/BuildIntrospectionService.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; +using System.Text.Json; using Fallout.Build.Execution.Extensions; +using Fallout.Common.Utilities; using Fallout.Common.ValueInjection; namespace Fallout.Common.Execution; @@ -19,11 +22,69 @@ namespace Fallout.Common.Execution; /// internal static class BuildIntrospectionService { + // The exact reason string BuildExecutor records, so the predicted plan and the executed one + // describe a --skip the same way. + private const string SkippedViaParameter = "via parameter"; + + private static readonly JsonSerializerOptions serializerOptions = + new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + /// Whether this invocation is a read-only introspection request rather than a build. // --plan alone keeps its existing meaning (the HTML graph); only --json redirects it here. internal static bool IsRequested(FalloutBuild build) => build.Describe || (build.Plan && build.Json); + /// The document for whichever request matched. + internal static string GetDocument( + FalloutBuild build, + IReadOnlyCollection targets, + IReadOnlyCollection plan) + => build.Describe + ? GetDescribeJson(build, targets) + : GetPlanJson( + ParameterService.GetParameter(() => build.InvokedTargets) ?? new string[0], + plan, + ParameterService.GetParameter(() => build.SkippedTargets)); + + /// + /// The resolved execution plan: what would run, in order, and what gates each entry. + /// Conditions are reported as their declared text and never evaluated — they are user delegates, + /// and running them would contradict "invokes no target". + /// + internal static string GetPlanJson( + IReadOnlyCollection invokedTargets, + IReadOnlyCollection plan, + IReadOnlyCollection skippedTargets) + { + // Mirrors BuildExecutor: dashes are stripped before matching, and an empty --skip list + // means "skip everything". + var skipped = (skippedTargets ?? new string[0]) + .Select(x => x.Replace("-", string.Empty)).ToList(); + + var entries = plan + .Select((target, index) => new PlanEntryModel( + target.Name, + index, + target.Invoked, + skippedTargets != null && + (skipped.Count == 0 || skipped.Contains(target.Name, StringComparer.OrdinalIgnoreCase)) + ? SkippedViaParameter + : null, + target.StaticConditions.Select(x => x.Text).ToList(), + target.DynamicConditions.Select(x => x.Text).ToList())) + .ToList(); + + return new PlanModel( + BuildGraphUtility.SchemaVersion, + invokedTargets.ToList(), + entries) + .ToJson(serializerOptions); + } + /// The whole build model: targets, dependency edges, tool requirements, parameters. internal static string GetDescribeJson( FalloutBuild build, @@ -40,6 +101,24 @@ internal static string GetDescribeJson( falloutVersion, ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false)); + internal sealed record PlanModel( + int Version, + IReadOnlyList InvokedTargets, + IReadOnlyList Plan); + + /// + /// One entry of the resolved plan. is its position in the run, + /// distinguishes an explicitly requested target from one pulled in + /// as a dependency, and is null unless --skip names it. + /// + internal sealed record PlanEntryModel( + string Name, + int Order, + bool Invoked, + string Skip, + IReadOnlyList StaticConditions, + IReadOnlyList DynamicConditions); + // Mirrors SerializeBuildGraphAttribute: the informational version of the running Fallout // assembly, up to the build-metadata separator. Null when unstamped (a local/dev build). private static string FindFalloutVersion() diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs index 0fb6df20d..af0e59522 100644 --- a/src/Fallout.Build/Execution/BuildManager.cs +++ b/src/Fallout.Build/Execution/BuildManager.cs @@ -68,9 +68,10 @@ public static int Execute(Expression>[] defaultTargetExpressi // Read-only introspection short-circuits ABOVE EnsureToolRequirements deliberately: // everything below this line writes files or shells out to a tool, which --describe // must not do. Returning here also means the executor is never reached. - if (build.Describe) + if (BuildIntrospectionService.IsRequested(build)) { - Console.Out.Write(BuildIntrospectionService.GetDescribeJson(build, build.ExecutableTargets)); + Console.Out.Write( + BuildIntrospectionService.GetDocument(build, build.ExecutableTargets, build.ExecutionPlan)); return build.ExitCode ??= 0; } diff --git a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs index 3e125d5c1..64b0a6115 100644 --- a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs +++ b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs @@ -69,6 +69,70 @@ public void Describe_document_projects_the_build_s_own_parameters() names.Should().Contain("api-key"); } + [Fact] + public void Plan_document_preserves_order_and_never_evaluates_conditions() + { + var evaluated = false; + var restore = new ExecutableTarget { Name = "Restore" }; + var compile = new ExecutableTarget { Name = "Compile", Invoked = true }; + compile.StaticConditions.Add(("IsServerBuild", () => { evaluated = true; return true; })); + + var json = BuildIntrospectionService.GetPlanJson( + new[] { "Compile" }, new[] { restore, compile }, skippedTargets: null); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + root.GetProperty("version").GetInt32().Should().Be(1); + root.GetProperty("invokedTargets").EnumerateArray().Select(x => x.GetString()) + .Should().Equal("Compile"); + + var entries = root.GetProperty("plan"); + entries[0].GetProperty("name").GetString().Should().Be("Restore"); + entries[0].GetProperty("order").GetInt32().Should().Be(0); + entries[0].GetProperty("invoked").GetBoolean().Should().BeFalse(); + entries[0].GetProperty("skip").ValueKind.Should().Be(JsonValueKind.Null); + + entries[1].GetProperty("name").GetString().Should().Be("Compile"); + entries[1].GetProperty("order").GetInt32().Should().Be(1); + entries[1].GetProperty("invoked").GetBoolean().Should().BeTrue(); + entries[1].GetProperty("staticConditions")[0].GetString().Should().Be("IsServerBuild"); + + evaluated.Should().BeFalse("the plan reports what gates a target, never the gate's value"); + } + + [Fact] + public void A_named_skipped_target_carries_the_executor_s_own_reason() + { + var restore = new ExecutableTarget { Name = "Restore" }; + var compile = new ExecutableTarget { Name = "Compile" }; + + var json = BuildIntrospectionService.GetPlanJson( + new[] { "Compile" }, new[] { restore, compile }, new[] { "re-store" }); + + using var document = JsonDocument.Parse(json); + var entries = document.RootElement.GetProperty("plan"); + + // BuildExecutor strips dashes before matching, so --skip re-store hits Restore. + entries[0].GetProperty("skip").GetString().Should().Be("via parameter"); + entries[1].GetProperty("skip").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public void An_empty_skip_list_skips_every_target_as_the_executor_does() + { + var json = BuildIntrospectionService.GetPlanJson( + new[] { "Compile" }, + new[] { new ExecutableTarget { Name = "Restore" }, new ExecutableTarget { Name = "Compile" } }, + new string[0]); + + using var document = JsonDocument.Parse(json); + + document.RootElement.GetProperty("plan").EnumerateArray() + .Select(x => x.GetProperty("skip").GetString()) + .Should().AllBe("via parameter"); + } + private static IReadOnlyCollection SampleGraph() { var restore = new ExecutableTarget { Name = "Restore", Listed = true }; From 317203912bb7808fd060846744ae38944912be6b Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 12:39:55 +0200 Subject: [PATCH 06/10] Emit a machine-readable error envelope for introspection failures --- .../Execution/BuildIntrospectionService.cs | 14 ++++++++++++++ src/Fallout.Build/Execution/BuildManager.cs | 10 ++++++++++ .../BuildIntrospectionServiceSpecs.cs | 14 ++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/src/Fallout.Build/Execution/BuildIntrospectionService.cs b/src/Fallout.Build/Execution/BuildIntrospectionService.cs index 73788051b..aa0eefbac 100644 --- a/src/Fallout.Build/Execution/BuildIntrospectionService.cs +++ b/src/Fallout.Build/Execution/BuildIntrospectionService.cs @@ -101,6 +101,20 @@ internal static string GetDescribeJson( falloutVersion, ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false)); + /// + /// The failure form of both documents, so a consumer parsing standard output gets JSON whether + /// the request succeeded or the build threw on its way to being described. + /// + internal static string GetErrorJson(Exception exception) + => new ErrorModel( + BuildGraphUtility.SchemaVersion, + new ErrorDetailModel(exception.GetType().Name, exception.Message)) + .ToJson(serializerOptions); + + internal sealed record ErrorModel(int Version, ErrorDetailModel Error); + + internal sealed record ErrorDetailModel(string Kind, string Message); + internal sealed record PlanModel( int Version, IReadOnlyList InvokedTargets, diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs index af0e59522..212725ac3 100644 --- a/src/Fallout.Build/Execution/BuildManager.cs +++ b/src/Fallout.Build/Execution/BuildManager.cs @@ -88,6 +88,16 @@ public static int Execute(Expression>[] defaultTargetExpressi catch (Exception exception) { exception = exception.Unwrap(); + + // An introspection request promised JSON on standard output; a failure on the way to + // emitting the document has to keep that promise rather than switch to human prose. + if (BuildIntrospectionService.IsRequested(build)) + { + Log.Verbose(exception, "Introspection request failed"); + Console.Out.Write(BuildIntrospectionService.GetErrorJson(exception)); + return build.ExitCode ??= ErrorExitCode; + } + if (exception is not TargetExecutionException) { Log.Verbose(exception, "Target-unrelated exception was thrown"); diff --git a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs index 64b0a6115..0d5ea5243 100644 --- a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs +++ b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs @@ -133,6 +133,20 @@ public void An_empty_skip_list_skips_every_target_as_the_executor_does() .Should().AllBe("via parameter"); } + [Fact] + public void Error_envelope_names_the_exception_kind_and_message() + { + var json = BuildIntrospectionService.GetErrorJson(new InvalidOperationException("boom")); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + root.GetProperty("version").GetInt32().Should().Be(1); + var error = root.GetProperty("error"); + error.GetProperty("kind").GetString().Should().Be(nameof(InvalidOperationException)); + error.GetProperty("message").GetString().Should().Be("boom"); + } + private static IReadOnlyCollection SampleGraph() { var restore = new ExecutableTarget { Name = "Restore", Listed = true }; From fa83070e056f74494d302a40dd51dc2ee377a9ad Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 12:48:08 +0200 Subject: [PATCH 07/10] Render --help from the shared build model so the human and machine views cannot drift --- .../Extensions/HandleHelpRequestsAttribute.cs | 35 +++++--- .../HandleHelpRequestsSpecs.cs | 79 +++++++++++++++++++ 2 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 tests/Fallout.Build.Specs/HandleHelpRequestsSpecs.cs diff --git a/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs index d7a6c50db..a370c783d 100644 --- a/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using System.Text; +using Fallout.Build.Execution.Extensions; using Fallout.Common.Utilities; using Fallout.Common.ValueInjection; @@ -24,18 +25,25 @@ public void OnBuildInitialized( public string GetTargetsText() { + // Every displayed field comes from the same projection --describe emits, so the human and + // machine views cannot drift (#642). Only the iteration order is ours: the model sorts + // ordinally, while the help listing keeps declaration order, which usually mirrors the + // pipeline (Restore, Compile, Test, Pack) and reads better than alphabetical. + var model = BuildGraphUtility.GetModel(Build.ExecutableTargets, falloutVersion: null) + .Targets.ToDictionary(x => x.Name, StringComparer.Ordinal); + var builder = new StringBuilder(); var longestTargetName = Build.ExecutableTargets.Select(x => x.Name.Length).OrderByDescending(x => x).First(); var padRightTargets = Math.Max(longestTargetName, val2: 20); builder.AppendLine("Targets (with their direct dependencies):"); builder.AppendLine(); - foreach (var target in Build.ExecutableTargets.Where(x => x.Listed)) + foreach (var target in Build.ExecutableTargets.Select(x => model[x.Name]).Where(x => x.Listed)) { - var dependencies = target.ExecutionDependencies.Count > 0 - ? $" -> {target.ExecutionDependencies.Select(x => x.Name).JoinCommaSpace()}" + var dependencies = target.DependsOn.Count > 0 + ? $" -> {target.DependsOn.JoinCommaSpace()}" : string.Empty; - var targetEntry = target.Name + (target.IsDefault ? " (default)" : string.Empty); + var targetEntry = target.Name + (target.Default ? " (default)" : string.Empty); builder.AppendLine($" {targetEntry.PadRight(padRightTargets)}{dependencies}"); if (!string.IsNullOrWhiteSpace(target.Description)) builder.AppendLine($" {target.Description}"); @@ -49,8 +57,12 @@ public string GetParametersText() var defaultTargets = Build.ExecutableTargets.Where(x => x.IsDefault).Select(x => x.Name).ToList(); var builder = new StringBuilder(); - var parameters = ValueInjectionUtility.GetParameterMembers(Build.GetType(), includeUnlisted: false); - var padRightParameter = Math.Max(parameters.Max(x => ParameterService.GetParameterDashedName(x).Length), val2: 16); + // Same projection as --describe (#642): name, description and declaring type all come from + // the model rather than being re-derived from reflection here. + var members = ValueInjectionUtility.GetParameterMembers(Build.GetType(), includeUnlisted: false); + var parameters = BuildGraphUtility + .GetModel(Build.ExecutableTargets, falloutVersion: null, members).Parameters; + var padRightParameter = Math.Max(parameters.Max(x => x.Name.Length), val2: 16); List SplitLines(string text) { @@ -68,30 +80,29 @@ List SplitLines(string text) return lines; } - void PrintParameter(MemberInfo parameter) + void PrintParameter(BuildGraphUtility.ParameterModel parameter) { var description = SplitLines( // TODO: remove - ParameterService.GetParameterDescription(parameter) + parameter.Description ?.Replace("{default_target}", defaultTargets.Count > 0 ? defaultTargets.JoinCommaSpace() : "") .TrimEnd(".").Append(".") ?? ""); - var parameterName = ParameterService.GetParameterDashedName(parameter); - builder.AppendLine($" --{parameterName.PadRight(padRightParameter)} {description.First()}"); + builder.AppendLine($" --{parameter.Name.PadRight(padRightParameter)} {description.First()}"); foreach (var line in description.Skip(count: 1)) builder.AppendLine($"{' '.Repeat(padRightParameter + 6)}{line}"); } builder.AppendLine("Parameters:"); - var customParameters = parameters.Where(x => x.DeclaringType != typeof(FalloutBuild)).ToList(); + var customParameters = parameters.Where(x => x.DeclaredIn != nameof(FalloutBuild)).ToList(); if (customParameters.Count > 0) builder.AppendLine(); customParameters.ForEach(PrintParameter); builder.AppendLine(); - var inheritedParameters = parameters.Where(x => x.DeclaringType == typeof(FalloutBuild)).ToList(); + var inheritedParameters = parameters.Where(x => x.DeclaredIn == nameof(FalloutBuild)).ToList(); inheritedParameters.ForEach(PrintParameter); return builder.ToString(); diff --git a/tests/Fallout.Build.Specs/HandleHelpRequestsSpecs.cs b/tests/Fallout.Build.Specs/HandleHelpRequestsSpecs.cs new file mode 100644 index 000000000..d55f5164a --- /dev/null +++ b/tests/Fallout.Build.Specs/HandleHelpRequestsSpecs.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using Fallout.Build.Execution.Extensions; +using Fallout.Common.Execution; +using FluentAssertions; +using Xunit; + +namespace Fallout.Common.Specs.Execution; + +/// +/// Characterization tests for the --help target listing. #642 re-points this text at the same +/// projection the machine-readable documents use, so the human and +/// machine views cannot drift; these assertions pin the rendered output across that refactor. +/// +public class HandleHelpRequestsSpecs +{ + [Fact] + public void Help_lists_every_listed_target_and_hides_the_unlisted_ones() + { + var text = TargetsTextFor(SampleGraph()); + + text.Should().Contain("Restore").And.Contain("Compile").And.Contain("Test"); + text.Should().NotContain("Publish", "unlisted targets are not part of the help listing"); + } + + [Fact] + public void Help_marks_the_default_target_and_renders_direct_dependencies() + { + var text = TargetsTextFor(SampleGraph()); + + text.Should().Contain("Test (default)"); + text.Should().Contain("-> Restore"); + } + + [Fact] + public void Help_renders_a_target_description_underneath_its_entry() + { + TargetsTextFor(SampleGraph()).Should().Contain("Builds all projects"); + } + + [Fact] + public void Help_renders_the_same_listed_set_the_model_reports() + { + var graph = SampleGraph(); + var text = TargetsTextFor(graph); + + var listed = BuildGraphUtility.GetModel(graph, falloutVersion: null) + .Targets.Where(x => x.Listed).Select(x => x.Name); + + foreach (var name in listed) + text.Should().Contain(name); + } + + private static string TargetsTextFor(IReadOnlyCollection graph) + => new HandleHelpRequestsAttribute { Build = new SampleBuild { ExecutableTargets = graph } } + .GetTargetsText(); + + private static IReadOnlyCollection SampleGraph() + { + var restore = new ExecutableTarget { Name = "Restore", Listed = true }; + var compile = new ExecutableTarget + { + Name = "Compile", + Description = "Builds all projects", + Listed = true, + }; + 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(restore); + + return new[] { restore, compile, test, publish }; + } + + private class SampleBuild : FalloutBuild + { + } +} From 6b7982435c2334a5cb029b3bf1b64374b9e9357d Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 13:15:59 +0200 Subject: [PATCH 08/10] Address code-review findings on the build-model documents --- src/Fallout.Build/Execution/BuildExecutor.cs | 9 +- .../Execution/BuildIntrospectionService.cs | 64 ++++++----- src/Fallout.Build/Execution/BuildManager.cs | 34 +++--- .../Execution/Extensions/BuildGraphUtility.cs | 103 ++++++++++++++---- .../Extensions/HandleHelpRequestsAttribute.cs | 52 +++++---- .../HandleShellCompletionAttribute.cs | 5 - .../SerializeBuildGraphAttribute.cs | 12 +- ...atches_the_contract_snapshot.verified.json | 1 + ...atches_the_contract_snapshot.verified.json | 1 + .../BuildGraphUtilitySpecs.cs | 31 +++++- .../BuildIntrospectionServiceSpecs.cs | 30 ++++- ...TestCustomParameterAttribute.verified.json | 2 +- ...litySpecs.TestParameterBuild.verified.json | 2 +- 13 files changed, 240 insertions(+), 106 deletions(-) diff --git a/src/Fallout.Build/Execution/BuildExecutor.cs b/src/Fallout.Build/Execution/BuildExecutor.cs index 5f8869dae..40998d49d 100644 --- a/src/Fallout.Build/Execution/BuildExecutor.cs +++ b/src/Fallout.Build/Execution/BuildExecutor.cs @@ -15,6 +15,13 @@ namespace Fallout.Common.Execution; /// internal static class BuildExecutor { + /// + /// Reason recorded when --skip names a target. Shared with + /// so the predicted plan and the executed one describe + /// a skip identically. + /// + internal const string SkippedViaParameterReason = "via parameter"; + // NOTE: no IFalloutBuild because of BuildAttemptFile + WriteTarget private static AbsolutePath BuildAttemptFile => Constants.GetBuildAttemptFile(FalloutBuild.RootDirectory); @@ -25,7 +32,7 @@ public static void Execute(FalloutBuild build, IReadOnlyCollection skipp skippedTargets = skippedTargets.Select(x => x.Replace("-", string.Empty)).ToArray(); build.ExecutionPlan .Where(x => skippedTargets.Count == 0 || skippedTargets.Contains(x.Name, StringComparer.OrdinalIgnoreCase)) - .ForEach(x => MarkTargetSkipped(build, x, reason: "via parameter")); + .ForEach(x => MarkTargetSkipped(build, x, reason: SkippedViaParameterReason)); } build.ExecutionPlan.ForEach(x => CheckConditions(build, x, x.StaticConditions)); diff --git a/src/Fallout.Build/Execution/BuildIntrospectionService.cs b/src/Fallout.Build/Execution/BuildIntrospectionService.cs index aa0eefbac..e690eea64 100644 --- a/src/Fallout.Build/Execution/BuildIntrospectionService.cs +++ b/src/Fallout.Build/Execution/BuildIntrospectionService.cs @@ -22,28 +22,38 @@ namespace Fallout.Common.Execution; /// internal static class BuildIntrospectionService { - // The exact reason string BuildExecutor records, so the predicted plan and the executed one - // describe a --skip the same way. - private const string SkippedViaParameter = "via parameter"; + /// + /// Version of the --plan --json document. Deliberately its own constant rather than + /// : the plan and build-graph.json are + /// different shapes, and one number could not tell a consumer which contract it received — + /// nor could it be bumped for one without falsely signalling a break in the other. + /// + internal const int PlanSchemaVersion = 1; - private static readonly JsonSerializerOptions serializerOptions = - new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - }; + /// Version of the error envelope, separate for the same reason. + internal const int ErrorSchemaVersion = 1; /// Whether this invocation is a read-only introspection request rather than a build. + /// + /// Each flag is read from the injected property OR straight from the arguments, because this is + /// asked before value injection has run: InjectParameterValuesAttribute is itself an + /// IOnBuildCreated extension, and the gate has to fire before any extension does. Reading only + /// the property here would make every request look like an ordinary build. + /// // --plan alone keeps its existing meaning (the HTML graph); only --json redirects it here. internal static bool IsRequested(FalloutBuild build) - => build.Describe || (build.Plan && build.Json); + => Flag(build.Describe, nameof(FalloutBuild.Describe)) || + (Flag(build.Plan, nameof(FalloutBuild.Plan)) && Flag(build.Json, nameof(FalloutBuild.Json))); + + private static bool Flag(bool injected, string parameterName) + => injected || ParameterService.GetParameter(parameterName); /// The document for whichever request matched. internal static string GetDocument( FalloutBuild build, IReadOnlyCollection targets, IReadOnlyCollection plan) - => build.Describe + => Flag(build.Describe, nameof(FalloutBuild.Describe)) ? GetDescribeJson(build, targets) : GetPlanJson( ParameterService.GetParameter(() => build.InvokedTargets) ?? new string[0], @@ -70,36 +80,40 @@ internal static string GetPlanJson( target.Name, index, target.Invoked, + // MarkTargetSkipped only skips when !Invoked, so an explicitly invoked target runs + // even when --skip names it. The prediction has to agree with the executor. + !target.Invoked && skippedTargets != null && (skipped.Count == 0 || skipped.Contains(target.Name, StringComparer.OrdinalIgnoreCase)) - ? SkippedViaParameter + ? BuildExecutor.SkippedViaParameterReason : null, target.StaticConditions.Select(x => x.Text).ToList(), target.DynamicConditions.Select(x => x.Text).ToList())) .ToList(); return new PlanModel( - BuildGraphUtility.SchemaVersion, + PlanSchemaVersion, invokedTargets.ToList(), entries) - .ToJson(serializerOptions); + .ToJson(BuildGraphUtility.SerializerOptions); } /// The whole build model: targets, dependency edges, tool requirements, parameters. internal static string GetDescribeJson( FalloutBuild build, IReadOnlyCollection targets) - => GetDescribeJson(build, targets, FindFalloutVersion()); + => BuildGraphUtility.GetJsonString(build, targets); /// Overload taking an explicit version, so the document can be asserted deterministically. internal static string GetDescribeJson( FalloutBuild build, IReadOnlyCollection targets, string falloutVersion) - => BuildGraphUtility.GetJsonString( - targets, - falloutVersion, - ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false)); + => BuildGraphUtility.GetModel( + targets, + falloutVersion, + ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false)) + .ToJson(BuildGraphUtility.SerializerOptions); /// /// The failure form of both documents, so a consumer parsing standard output gets JSON whether @@ -107,9 +121,9 @@ internal static string GetDescribeJson( /// internal static string GetErrorJson(Exception exception) => new ErrorModel( - BuildGraphUtility.SchemaVersion, + ErrorSchemaVersion, new ErrorDetailModel(exception.GetType().Name, exception.Message)) - .ToJson(serializerOptions); + .ToJson(BuildGraphUtility.SerializerOptions); internal sealed record ErrorModel(int Version, ErrorDetailModel Error); @@ -132,12 +146,4 @@ internal sealed record PlanEntryModel( string Skip, IReadOnlyList StaticConditions, IReadOnlyList DynamicConditions); - - // Mirrors SerializeBuildGraphAttribute: the informational version of the running Fallout - // assembly, up to the build-metadata separator. Null when unstamped (a local/dev build). - private static string FindFalloutVersion() - => BuildGraphUtility.NormalizeVersion( - typeof(BuildIntrospectionService).Assembly - .GetCustomAttribute() - ?.InformationalVersion); } diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs index 212725ac3..561bdb400 100644 --- a/src/Fallout.Build/Execution/BuildManager.cs +++ b/src/Fallout.Build/Execution/BuildManager.cs @@ -49,6 +49,23 @@ public static int Execute(Expression>[] defaultTargetExpressi Logging.Configure(build); build.ExecutableTargets = ExecutableTargetFactory.CreateAll(build, defaultTargetExpressions); + + // A read-only introspection request short-circuits before ANY extension runs and long + // before EnsureToolRequirements. That single gate is what makes "changes nothing, runs + // nothing" structural: IOnBuildCreated alone rewrites .fallout/build.schema.json + // (HandleShellCompletionAttribute) and can block on Console.ReadKey + // (UpdateNotificationAttribute), which would deadlock a piped consumer. + if (BuildIntrospectionService.IsRequested(build)) + { + build.ExecutionPlan = ExecutionPlanner.GetExecutionPlan( + build.ExecutableTargets, + ParameterService.GetParameter(() => build.InvokedTargets)); + + Console.Out.Write( + BuildIntrospectionService.GetDocument(build, build.ExecutableTargets, build.ExecutionPlan)); + return build.ExitCode ??= 0; + } + build.ExecuteExtension(x => x.OnBuildCreated(build.ExecutableTargets)); NuGetToolPathResolver.EmbeddedPackagesDirectory = build.EmbeddedPackagesDirectory; @@ -56,8 +73,7 @@ public static int Execute(Expression>[] defaultTargetExpressi NuGetToolPathResolver.NuGetAssetsConfigFile = build.NuGetAssetsConfigFile; NpmToolPathResolver.NpmPackageJsonFile = build.NpmPackageJsonFile; - // An introspection request owns standard output: the document must be the only thing on it. - if (!build.NoLogo && !BuildIntrospectionService.IsRequested(build)) + if (!build.NoLogo) build.WriteLogo(); // TODO: move InvokedTargets to ExecutableTargetFactory @@ -65,16 +81,6 @@ public static int Execute(Expression>[] defaultTargetExpressi build.ExecutableTargets, ParameterService.GetParameter(() => build.InvokedTargets)); - // Read-only introspection short-circuits ABOVE EnsureToolRequirements deliberately: - // everything below this line writes files or shells out to a tool, which --describe - // must not do. Returning here also means the executor is never reached. - if (BuildIntrospectionService.IsRequested(build)) - { - Console.Out.Write( - BuildIntrospectionService.GetDocument(build, build.ExecutableTargets, build.ExecutionPlan)); - return build.ExitCode ??= 0; - } - ToolRequirementService.EnsureToolRequirements(build, build.ExecutionPlan); build.ExecuteExtension(x => x.OnBuildInitialized(build.ExecutableTargets, build.ExecutionPlan)); @@ -93,7 +99,9 @@ public static int Execute(Expression>[] defaultTargetExpressi // emitting the document has to keep that promise rather than switch to human prose. if (BuildIntrospectionService.IsRequested(build)) { - Log.Verbose(exception, "Introspection request failed"); + // Nothing is logged here on purpose: Serilog's console sink writes to standard + // output, so a log line would land inside the document a consumer is parsing. The + // envelope carries the kind and message instead. Console.Out.Write(BuildIntrospectionService.GetErrorJson(exception)); return build.ExitCode ??= ErrorExitCode; } diff --git a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs index 18ece249c..37dd9e846 100644 --- a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs +++ b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs @@ -27,13 +27,27 @@ 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 = + /// + /// Serialization settings for every machine-readable document Fallout emits, so the plan and + /// error envelopes cannot drift from build-graph.json in casing or indentation. + /// + internal static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, }; + /// + /// The running Fallout version, up to the build-metadata separator, so the pin aligns with the + /// running tool. Null when unstamped (a local/dev build). + /// + internal static string GetFalloutVersion() + => NormalizeVersion( + typeof(BuildGraphUtility).Assembly + .GetCustomAttribute() + ?.InformationalVersion); + /// 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. @@ -53,30 +67,67 @@ internal static BuildGraphModel GetModel( IReadOnlyCollection targets, string falloutVersion, IReadOnlyCollection parameterMembers) + => GetModel(targets, falloutVersion, parameterMembers, new ToolRequirement[0]); + + /// Projects targets, parameters, and the build's class-level tool requirements. + /// + /// Requirements declared with [Requires<T>] on the build class or a component + /// interface. That attribute targets Class/Interface only, so these can never appear on a + /// target and would be missing from the document entirely if not passed separately. + /// + internal static BuildGraphModel GetModel( + IReadOnlyCollection targets, + string falloutVersion, + IReadOnlyCollection parameterMembers, + IReadOnlyCollection buildRequirements) => new( SchemaVersion, falloutVersion, + SortedRequirements(buildRequirements), targets .OrderBy(x => x.Name, StringComparer.Ordinal) .Select(ToModel) .ToList(), - parameterMembers - .Select(ToModel) - .OrderBy(x => x.Name, StringComparer.Ordinal) - .ToList()); + GetParameterModels(parameterMembers)); + + /// + /// Projects just the declared parameters, for callers that need them without paying for the + /// whole target graph (--help's parameter section). + /// + internal static IReadOnlyList GetParameterModels( + IReadOnlyCollection parameterMembers) + => parameterMembers + .Select(ToModel) + .OrderBy(x => x.Name, StringComparer.Ordinal) + .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); + => GetModel(targets, falloutVersion).ToJson(SerializerOptions); /// Serializes the graph model, parameters included. internal static string GetJsonString( IReadOnlyCollection targets, string falloutVersion, IReadOnlyCollection parameterMembers) - => GetModel(targets, falloutVersion, parameterMembers).ToJson(serializerOptions); + => GetModel(targets, falloutVersion, parameterMembers).ToJson(SerializerOptions); + + /// Serializes the whole model for a build: targets, parameters, build-level requirements. + internal static string GetJsonString(IFalloutBuild build, IReadOnlyCollection targets) + => GetModel( + targets, + GetFalloutVersion(), + ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false), + BuildRequirements(build)) + .ToJson(SerializerOptions); + + // Same source ToolRequirementService reads for class-level requirements. + private static IReadOnlyCollection BuildRequirements(IFalloutBuild build) + => build.GetType().GetCustomAttributes() + .Select(x => x.GetRequirement()) + .ToList(); // 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 @@ -103,12 +154,13 @@ private static TargetModel ToModel(ExecutableTarget target) SortedNames(target.OrderDependencies), SortedNames(target.TriggerDependencies), SortedNames(target.Triggers), - ToolRequirements(target)); + SortedRequirements(target.ToolRequirements)); // Sorted for the same reason as SortedNames: the declaration order carries no meaning to a // consumer, and a stable ordering keeps build-graph.json free of spurious churn. - private static IReadOnlyList ToolRequirements(ExecutableTarget target) - => target.ToolRequirements + private static IReadOnlyList SortedRequirements( + IEnumerable requirements) + => requirements .Select(ToModel) .OrderBy(x => x.Kind, StringComparer.Ordinal) .ThenBy(x => x.PackageId, StringComparer.Ordinal) @@ -135,29 +187,43 @@ private static IReadOnlyList SortedNames(IEnumerable t // injected value must never reach the emitted model, and emitting non-secret defaults only // would make the field's meaning depend on the flag next to it. private static ParameterModel ToModel(MemberInfo member) - { - var attribute = member.GetCustomAttribute(); - return new ParameterModel( + => new( ParameterService.GetParameterDashedName(member), - UnderlyingTypeName(member.GetMemberType()), + TypeName(member.GetMemberType()), member.DeclaringType?.Name, ParameterService.GetParameterDescription(member), member.GetCustomAttribute() != null, member.GetCustomAttribute() != null, - attribute?.List ?? true, Default: null, AllowedValues: null); - } // `int?` and `int` describe the same thing to someone typing --retries 3, so the wrapper is // unwrapped. Note this is NOT ReflectionUtility.GetNullableType, which goes the other way // (it *wraps* a value type) and throws outright on an interface-typed member. - private static string UnderlyingTypeName(Type type) - => (Nullable.GetUnderlyingType(type) ?? type).FullName; + // + // Generic arguments are rendered recursively rather than via Type.FullName, whose constructed + // form embeds the assembly name and runtime version — `List`1[[System.String, ..., + // Version=10.0.0.0, ...]]` — which would put the running runtime into the emitted contract and + // churn it on every SDK bump. + private static string TypeName(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + + if (type.IsArray) + return $"{TypeName(type.GetElementType())}[]"; + + if (!type.IsGenericType) + return type.FullName ?? type.Name; + + var definition = type.GetGenericTypeDefinition().FullName ?? type.GetGenericTypeDefinition().Name; + var name = definition[..definition.IndexOf('`')]; + return $"{name}<{type.GetGenericArguments().Select(TypeName).JoinComma()}>"; + } internal sealed record BuildGraphModel( int Version, string FalloutVersion, + IReadOnlyList ToolRequirements, IReadOnlyList Targets, IReadOnlyList Parameters); @@ -173,7 +239,6 @@ internal sealed record ParameterModel( string Description, bool Required, bool Secret, - bool List, string Default, IReadOnlyList AllowedValues); diff --git a/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs index a370c783d..63c1e7f15 100644 --- a/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs @@ -25,10 +25,12 @@ public void OnBuildInitialized( public string GetTargetsText() { - // Every displayed field comes from the same projection --describe emits, so the human and - // machine views cannot drift (#642). Only the iteration order is ours: the model sorts - // ordinally, while the help listing keeps declaration order, which usually mirrors the - // pipeline (Restore, Compile, Test, Pack) and reads better than alphabetical. + // The displayed FIELDS come from the same projection --describe emits, so what the two + // views say about a target cannot drift (#642). ORDER stays with the declarations, for + // both the target list and each dependency line: the model sorts ordinally for + // determinism, whereas declaration order carries the pipeline reading a human wants + // (Restore, Compile, Test, Pack). Rendering the sorted lists here would silently + // alphabetize --help, which this deliberately does not do. var model = BuildGraphUtility.GetModel(Build.ExecutableTargets, falloutVersion: null) .Targets.ToDictionary(x => x.Name, StringComparer.Ordinal); @@ -38,15 +40,19 @@ public string GetTargetsText() var padRightTargets = Math.Max(longestTargetName, val2: 20); builder.AppendLine("Targets (with their direct dependencies):"); builder.AppendLine(); - foreach (var target in Build.ExecutableTargets.Select(x => model[x.Name]).Where(x => x.Listed)) + foreach (var target in Build.ExecutableTargets) { - var dependencies = target.DependsOn.Count > 0 - ? $" -> {target.DependsOn.JoinCommaSpace()}" + var projected = model[target.Name]; + if (!projected.Listed) + continue; + + var dependencies = target.ExecutionDependencies.Count > 0 + ? $" -> {target.ExecutionDependencies.Select(x => x.Name).JoinCommaSpace()}" : string.Empty; - var targetEntry = target.Name + (target.Default ? " (default)" : string.Empty); + var targetEntry = projected.Name + (projected.Default ? " (default)" : string.Empty); builder.AppendLine($" {targetEntry.PadRight(padRightTargets)}{dependencies}"); - if (!string.IsNullOrWhiteSpace(target.Description)) - builder.AppendLine($" {target.Description}"); + if (!string.IsNullOrWhiteSpace(projected.Description)) + builder.AppendLine($" {projected.Description}"); } return builder.ToString(); @@ -57,12 +63,16 @@ public string GetParametersText() var defaultTargets = Build.ExecutableTargets.Where(x => x.IsDefault).Select(x => x.Name).ToList(); var builder = new StringBuilder(); - // Same projection as --describe (#642): name, description and declaring type all come from - // the model rather than being re-derived from reflection here. + // Same projection as --describe (#642) for the displayed name and description. As above, + // ORDER stays with GetParameterMembers (culture-ordered by member name), not the model's + // ordinal-by-dashed-name, so --help's listing is unchanged. var members = ValueInjectionUtility.GetParameterMembers(Build.GetType(), includeUnlisted: false); - var parameters = BuildGraphUtility - .GetModel(Build.ExecutableTargets, falloutVersion: null, members).Parameters; - var padRightParameter = Math.Max(parameters.Max(x => x.Name.Length), val2: 16); + var model = BuildGraphUtility.GetParameterModels(members) + .ToDictionary(x => x.Name, StringComparer.Ordinal); + var parameters = members + .Select(x => (Member: x, Model: model[ParameterService.GetParameterDashedName(x)])) + .ToList(); + var padRightParameter = Math.Max(parameters.Max(x => x.Model.Name.Length), val2: 16); List SplitLines(string text) { @@ -80,29 +90,31 @@ List SplitLines(string text) return lines; } - void PrintParameter(BuildGraphUtility.ParameterModel parameter) + void PrintParameter((MemberInfo Member, BuildGraphUtility.ParameterModel Model) parameter) { var description = SplitLines( // TODO: remove - parameter.Description + parameter.Model.Description ?.Replace("{default_target}", defaultTargets.Count > 0 ? defaultTargets.JoinCommaSpace() : "") .TrimEnd(".").Append(".") ?? ""); - builder.AppendLine($" --{parameter.Name.PadRight(padRightParameter)} {description.First()}"); + builder.AppendLine($" --{parameter.Model.Name.PadRight(padRightParameter)} {description.First()}"); foreach (var line in description.Skip(count: 1)) builder.AppendLine($"{' '.Repeat(padRightParameter + 6)}{line}"); } builder.AppendLine("Parameters:"); - var customParameters = parameters.Where(x => x.DeclaredIn != nameof(FalloutBuild)).ToList(); + // Type identity, not the model's DeclaredIn name: a user type merely *called* FalloutBuild + // in another namespace must not have its parameters filed under the built-in block. + var customParameters = parameters.Where(x => x.Member.DeclaringType != typeof(FalloutBuild)).ToList(); if (customParameters.Count > 0) builder.AppendLine(); customParameters.ForEach(PrintParameter); builder.AppendLine(); - var inheritedParameters = parameters.Where(x => x.DeclaredIn == nameof(FalloutBuild)).ToList(); + var inheritedParameters = parameters.Where(x => x.Member.DeclaringType == typeof(FalloutBuild)).ToList(); inheritedParameters.ForEach(PrintParameter); return builder.ToString(); diff --git a/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs b/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs index 82ce9fb89..d3b5a1d57 100644 --- a/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/HandleShellCompletionAttribute.cs @@ -28,11 +28,6 @@ public void OnBuildCreated(IReadOnlyCollection executableTarge } else if (Build.BuildProjectFile != null) { - // A read-only introspection request must leave the working tree untouched, and this - // hook fires long before the request can short-circuit — so it has to opt out here. - if (Build is FalloutBuild build && BuildIntrospectionService.IsRequested(build)) - return; - var buildSchema = SchemaUtility.GetJsonString(Build); var buildSchemaFile = GetBuildSchemaFile(Build.RootDirectory); buildSchemaFile.WriteAllText(buildSchema); diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs index 18484bc29..222465110 100644 --- a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -26,10 +26,7 @@ public void OnBuildInitialized( { try { - var json = BuildGraphUtility.GetJsonString( - executableTargets, - FindFalloutVersion(), - ValueInjectionUtility.GetParameterMembers(Build.GetType(), includeUnlisted: false)); + var json = BuildGraphUtility.GetJsonString(Build, executableTargets); GraphFile.WriteAllText(json); } catch (Exception exception) @@ -39,11 +36,4 @@ public void OnBuildInitialized( } } - // 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 index 18d89d3df..34a41b26f 100644 --- 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 @@ -1,6 +1,7 @@ { "version": 1, "falloutVersion": null, + "toolRequirements": [], "targets": [], "parameters": [] } \ 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 index 8e6d74d77..67548bb77 100644 --- 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 @@ -1,6 +1,7 @@ { "version": 1, "falloutVersion": "2026.1.0-preview.42", + "toolRequirements": [], "targets": [ { "name": "Compile", diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs index 287ea56de..f835e72c0 100644 --- a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs @@ -145,7 +145,7 @@ 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", "parameters"); + .Should().Equal("version", "falloutVersion", "toolRequirements", "targets", "parameters"); var firstTarget = doc.RootElement.GetProperty("targets").EnumerateArray().First(); firstTarget.EnumerateObject().Select(x => x.Name) @@ -179,6 +179,32 @@ public void Targets_without_tool_requirements_emit_an_empty_list() ModelFor("Restore").ToolRequirements.Should().BeEmpty(); } + [Fact] + public void Build_level_requirements_are_projected_at_the_document_root() + { + // [Requires] targets Class/Interface only, so a build-level requirement can never appear + // on a target and would be missing from the document entirely if not projected separately. + var model = BuildGraphUtility.GetModel( + SampleGraph(), + SampleVersion, + new MemberInfo[0], + new ToolRequirement[] { new NuGetPackageRequirement("GitVersion.Tool", "5.12.0") }); + + model.ToolRequirements.Should().Equal( + new BuildGraphUtility.ToolRequirementModel("nuget", "GitVersion.Tool", "5.12.0")); + } + + [Fact] + public void A_generic_parameter_type_is_named_without_assembly_or_runtime_version() + { + // Type.FullName would emit List`1[[System.String, System.Private.CoreLib, Version=...]], + // putting the running runtime into the contract and churning it on every SDK bump. + var type = ParameterModelFor("tags").Type; + + type.Should().Be("System.Collections.Generic.List"); + type.Should().NotContain("Version=").And.NotContain("PublicKeyToken"); + } + [Fact] public void Parameters_are_projected_without_leaking_secret_values() { @@ -250,5 +276,8 @@ private class SampleParameterBuild : FalloutBuild [Parameter("How often to retry.")] private readonly int? Retries; + + [Parameter("Tags to apply.")] + private readonly List Tags; } } diff --git a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs index 0d5ea5243..2d39ca8bb 100644 --- a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs +++ b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs @@ -119,18 +119,38 @@ public void A_named_skipped_target_carries_the_executor_s_own_reason() } [Fact] - public void An_empty_skip_list_skips_every_target_as_the_executor_does() + public void An_empty_skip_list_skips_every_target_except_the_invoked_ones() { var json = BuildIntrospectionService.GetPlanJson( new[] { "Compile" }, - new[] { new ExecutableTarget { Name = "Restore" }, new ExecutableTarget { Name = "Compile" } }, + new[] + { + new ExecutableTarget { Name = "Restore" }, + new ExecutableTarget { Name = "Compile", Invoked = true }, + }, new string[0]); + using var document = JsonDocument.Parse(json); + var entries = document.RootElement.GetProperty("plan"); + + entries[0].GetProperty("skip").GetString().Should().Be("via parameter"); + entries[1].GetProperty("skip").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public void An_explicitly_invoked_target_is_never_reported_as_skipped() + { + // BuildExecutor.MarkTargetSkipped only skips when !target.Invoked, so naming an invoked + // target in --skip does not stop it running. The predicted plan has to say the same. + var json = BuildIntrospectionService.GetPlanJson( + new[] { "Compile" }, + new[] { new ExecutableTarget { Name = "Compile", Invoked = true } }, + new[] { "Compile" }); + using var document = JsonDocument.Parse(json); - document.RootElement.GetProperty("plan").EnumerateArray() - .Select(x => x.GetProperty("skip").GetString()) - .Should().AllBe("via parameter"); + document.RootElement.GetProperty("plan")[0] + .GetProperty("skip").ValueKind.Should().Be(JsonValueKind.Null); } [Fact] diff --git a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json index 14b0d74b8..764158962 100644 --- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json +++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json-schema.org/draft-04/schema#", "definitions": { "Host": { diff --git a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json index 8c9e0a3a2..1d949380e 100644 --- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json +++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json-schema.org/draft-04/schema#", "definitions": { "ComplexType": { From 46c4456ef35abb0003931a3cb141228e5da2378e Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 27 Aug 2026 13:46:14 +0200 Subject: [PATCH 09/10] Keep standard output parseable for the introspection requests --- build.ps1 | 9 +++-- build.sh | 6 ++- .../Execution/BuildIntrospectionService.cs | 16 ++++++++ src/Fallout.Build/Execution/BuildManager.cs | 6 ++- .../Extensions/HandleHelpRequestsAttribute.cs | 20 +++++++++- src/Fallout.Cli/Commands/RunCommand.cs | 39 +++++++++++++++++-- src/Fallout.Cli/templates/build.ps1 | 9 +++-- src/Fallout.Cli/templates/build.sh | 6 ++- .../BuildIntrospectionServiceSpecs.cs | 18 +++++++++ 9 files changed, 112 insertions(+), 17 deletions(-) diff --git a/build.ps1 b/build.ps1 index 3ea0b34c1..50eb931af 100644 --- a/build.ps1 +++ b/build.ps1 @@ -4,7 +4,9 @@ Param( [string[]]$BuildArguments ) -Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)" +# Provisioning chatter goes to standard error, so standard output carries only what the build +# itself writes. That is what lets `./build.ps1 --describe | ConvertFrom-Json` work. +[Console]::Error.WriteLine("PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)") Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 } $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent @@ -60,7 +62,8 @@ else { $env:PATH = "$DotNetDirectory;$env:PATH" } -Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" +[Console]::Error.WriteLine("Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)") -ExecSafe { & $env:DOTNET_EXE tool restore } +# PowerShell has no `1>&2`, so the restore's output is pumped to standard error explicitly. +ExecSafe { & $env:DOTNET_EXE tool restore 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } } ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments } diff --git a/build.sh b/build.sh index bd0eb8400..a96d5a648 100755 --- a/build.sh +++ b/build.sh @@ -53,7 +53,9 @@ else export PATH="$DOTNET_DIRECTORY:$PATH" fi -echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" +# Provisioning chatter goes to standard error, so standard output carries only what the build +# itself writes. That is what lets `./build.sh --describe | jq` work. +echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" >&2 -"$DOTNET_EXE" tool restore +"$DOTNET_EXE" tool restore >&2 exec "$DOTNET_EXE" fallout "$@" diff --git a/src/Fallout.Build/Execution/BuildIntrospectionService.cs b/src/Fallout.Build/Execution/BuildIntrospectionService.cs index e690eea64..690e99dd3 100644 --- a/src/Fallout.Build/Execution/BuildIntrospectionService.cs +++ b/src/Fallout.Build/Execution/BuildIntrospectionService.cs @@ -48,6 +48,22 @@ internal static bool IsRequested(FalloutBuild build) private static bool Flag(bool injected, string parameterName) => injected || ParameterService.GetParameter(parameterName); + /// + /// Whether raw command-line arguments request introspection, for callers that must decide + /// before a build process exists — the CLI, which has to know where to send the build step's + /// own output. Shares this type with the property-based overload so the two entry points + /// cannot disagree about what counts as a read-only request. + /// + internal static bool IsRequested(IReadOnlyCollection arguments) + => HasFlag(arguments, nameof(FalloutBuild.Describe)) || + (HasFlag(arguments, nameof(FalloutBuild.Plan)) && HasFlag(arguments, nameof(FalloutBuild.Json))); + + // Accepts every spelling the parameter parser does: --describe, -describe, --DESCRIBE. + private static bool HasFlag(IReadOnlyCollection arguments, string parameterName) + => arguments.Any(x => + x.StartsWith("-", StringComparison.Ordinal) && + x.TrimStart('-').Replace("-", string.Empty).EqualsOrdinalIgnoreCase(parameterName)); + /// The document for whichever request matched. internal static string GetDocument( FalloutBuild build, diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs index 561bdb400..20c0d1ccd 100644 --- a/src/Fallout.Build/Execution/BuildManager.cs +++ b/src/Fallout.Build/Execution/BuildManager.cs @@ -61,7 +61,9 @@ public static int Execute(Expression>[] defaultTargetExpressi build.ExecutableTargets, ParameterService.GetParameter(() => build.InvokedTargets)); - Console.Out.Write( + // Newline-terminated, matching SchemaUtility's emitted JSON and the usual + // expectation that a document written to a pipe ends with one. + Console.Out.WriteLine( BuildIntrospectionService.GetDocument(build, build.ExecutableTargets, build.ExecutionPlan)); return build.ExitCode ??= 0; } @@ -102,7 +104,7 @@ public static int Execute(Expression>[] defaultTargetExpressi // Nothing is logged here on purpose: Serilog's console sink writes to standard // output, so a log line would land inside the document a consumer is parsing. The // envelope carries the kind and message instead. - Console.Out.Write(BuildIntrospectionService.GetErrorJson(exception)); + Console.Out.WriteLine(BuildIntrospectionService.GetErrorJson(exception)); return build.ExitCode ??= ErrorExitCode; } diff --git a/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs index 63c1e7f15..cbeaa0e04 100644 --- a/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using System.Text; @@ -58,6 +59,22 @@ public string GetTargetsText() return builder.ToString(); } + // Console.BufferWidth throws IOException ("The handle is invalid") when standard output has no + // console behind it — a redirected pipe, a file, or a CI agent without a console — which used to + // abort --help outright, printing the targets and then dying before the parameters (#616). The + // wrap width is cosmetic, so an unavailable console falls back to the 90-column cap below. + private static int GetBufferWidth() + { + try + { + return Console.BufferWidth; + } + catch (IOException) + { + return int.MaxValue; + } + } + public string GetParametersText() { var defaultTargets = Build.ExecutableTargets.Where(x => x.IsDefault).Select(x => x.Name).ToList(); @@ -73,6 +90,7 @@ public string GetParametersText() .Select(x => (Member: x, Model: model[ParameterService.GetParameterDashedName(x)])) .ToList(); var padRightParameter = Math.Max(parameters.Max(x => x.Model.Name.Length), val2: 16); + var bufferWidth = GetBufferWidth(); List SplitLines(string text) { @@ -81,7 +99,7 @@ List SplitLines(string text) foreach (var word in words) { var nextLength = padRightParameter + 6 + lines.Last().Length + word.Length; - if (nextLength >= Console.BufferWidth || nextLength > 90) + if (nextLength >= bufferWidth || nextLength > 90) lines.Add(string.Empty); lines[lines.Count - 1] = $"{lines.Last()} {word}"; diff --git a/src/Fallout.Cli/Commands/RunCommand.cs b/src/Fallout.Cli/Commands/RunCommand.cs index 374bbf7ac..25699032a 100644 --- a/src/Fallout.Cli/Commands/RunCommand.cs +++ b/src/Fallout.Cli/Commands/RunCommand.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Fallout.Common; +using Fallout.Common.Execution; using Fallout.Common.IO; using Fallout.Common.Utilities; using static Fallout.Common.Constants; @@ -23,7 +24,12 @@ public async Task ExecuteAsync(string[] forwardedArgs, AbsolutePath rootDir { var dotnet = ResolveDotnet(rootDirectory); - var buildExitCode = await StartDotnetAsync(dotnet, GetBuildArguments(buildProjectFile)); + // A read-only introspection request (--describe, --plan --json) promises a JSON document on + // standard output. Compiling the build project writes there too, so its output would land + // inside the document a consumer is parsing — send it to standard error for those runs. + var quietBuildStep = BuildIntrospectionService.IsRequested(forwardedArgs); + + var buildExitCode = await StartDotnetAsync(dotnet, GetBuildArguments(buildProjectFile), quietBuildStep); if (buildExitCode != 0) { return buildExitCode; @@ -59,12 +65,16 @@ private static string TryGetDotnetFromPath() .FirstOrDefault(File.Exists); } - private static async Task StartDotnetAsync(string dotnet, IEnumerable arguments) + private static async Task StartDotnetAsync( + string dotnet, + IEnumerable arguments, + bool redirectStandardOutputToError = false) { var startInfo = new ProcessStartInfo { FileName = dotnet, - UseShellExecute = false + UseShellExecute = false, + RedirectStandardOutput = redirectStandardOutputToError }; foreach (var argument in arguments) @@ -79,10 +89,31 @@ private static async Task StartDotnetAsync(string dotnet, IEnumerable GetBuildArguments(AbsolutePath buildProjectFile) { // Mirrors the dotnet build invocation in build.sh / build.ps1. diff --git a/src/Fallout.Cli/templates/build.ps1 b/src/Fallout.Cli/templates/build.ps1 index f66198b3e..3205fdd21 100644 --- a/src/Fallout.Cli/templates/build.ps1 +++ b/src/Fallout.Cli/templates/build.ps1 @@ -4,7 +4,9 @@ Param( [string[]]$BuildArguments ) -Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)" +# Provisioning chatter goes to standard error, so standard output carries only what the build +# itself writes. That is what lets `./build.ps1 --describe | ConvertFrom-Json` work. +[Console]::Error.WriteLine("PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)") Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 } $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent @@ -59,7 +61,8 @@ else { $env:PATH = "$DotNetDirectory;$env:PATH" } -Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" +[Console]::Error.WriteLine("Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)") -ExecSafe { & $env:DOTNET_EXE tool restore } +# PowerShell has no `1>&2`, so the restore's output is pumped to standard error explicitly. +ExecSafe { & $env:DOTNET_EXE tool restore 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } } ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments } diff --git a/src/Fallout.Cli/templates/build.sh b/src/Fallout.Cli/templates/build.sh index 554a328c7..052849116 100644 --- a/src/Fallout.Cli/templates/build.sh +++ b/src/Fallout.Cli/templates/build.sh @@ -52,7 +52,9 @@ else export PATH="$DOTNET_DIRECTORY:$PATH" fi -echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" +# Provisioning chatter goes to standard error, so standard output carries only what the build +# itself writes. That is what lets `./build.sh --describe | jq` work. +echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" >&2 -"$DOTNET_EXE" tool restore +"$DOTNET_EXE" tool restore >&2 exec "$DOTNET_EXE" fallout "$@" diff --git a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs index 2d39ca8bb..aaf4609ba 100644 --- a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs +++ b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs @@ -39,6 +39,24 @@ public void An_ordinary_run_requests_no_introspection() BuildIntrospectionService.IsRequested(new SampleBuild()).Should().BeFalse(); } + [Theory] + [InlineData(true, "--describe")] + [InlineData(true, "-describe")] + [InlineData(true, "--DESCRIBE")] + [InlineData(true, "Compile", "--describe")] + [InlineData(true, "--plan", "--json")] + [InlineData(false, "--plan")] + [InlineData(false, "--json")] + [InlineData(false, "Compile")] + // A target that merely reads like the flag must not be mistaken for one. + [InlineData(false, "describe")] + public void Raw_arguments_are_recognised_the_same_way_the_injected_flags_are( + bool expected, + params string[] arguments) + { + BuildIntrospectionService.IsRequested(arguments).Should().Be(expected); + } + [Fact] public void Describe_document_carries_targets_and_parameters_and_parses_as_json() { From e6c358d5469fba90419fae8fa18180de6d41e7ad Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 3 Sep 2026 13:54:11 +0200 Subject: [PATCH 10/10] Make the introspection service an instance resolved once per run Addresses the review: the introspection code was a static class over ambient state, which the surrounding statics made easy to reach for but which cost correctness and coverage. BuildIntrospectionService is now a sealed class holding the resolved request. BuildManager builds one with `For(build)` and reuses it at the gate, on the failure path and before the outcome tables. Those three sites each used to re-read the flags out of ParameterService, so a single run answered "is this introspection?" three separate times from process-global state that nothing kept in agreement. The invoked targets are read once now too, instead of once for the planner and again inside GetDocument. That also removes a test-only overload, and with it a real gap. Production described a build through GetJsonString(build, targets), which projects the class-level [Requires] requirements; the specs called an overload taking an explicit version, and that one passed no requirements at all. The specs were asserting a document the product never emits, and the projection was uncovered. BuildGraphUtility now takes the version on the same path production uses, and a new spec pins a build-level requirement reaching the document. The argument-based IsRequested stays static: the CLI asks it before a build exists, and it is a pure function of its arguments. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VyD69qYha8hBMuja7opyj2 --- .../Execution/BuildIntrospectionService.cs | 80 +++++++++++-------- src/Fallout.Build/Execution/BuildManager.cs | 27 +++++-- .../Execution/Extensions/BuildGraphUtility.cs | 13 ++- .../BuildIntrospectionServiceSpecs.cs | 64 +++++++++++---- 4 files changed, 129 insertions(+), 55 deletions(-) diff --git a/src/Fallout.Build/Execution/BuildIntrospectionService.cs b/src/Fallout.Build/Execution/BuildIntrospectionService.cs index 690e99dd3..b9cacae0f 100644 --- a/src/Fallout.Build/Execution/BuildIntrospectionService.cs +++ b/src/Fallout.Build/Execution/BuildIntrospectionService.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; -using System.Text.Json; using Fallout.Build.Execution.Extensions; using Fallout.Common.Utilities; using Fallout.Common.ValueInjection; @@ -20,7 +18,15 @@ namespace Fallout.Common.Execution; /// extension — where --help and --plan live — could not honour "runs no external tool". /// /// -internal static class BuildIntrospectionService +/// +/// One instance per run, resolved once by , rather than a static class. The +/// request is asked three times during a run — at the gate, on the failure path, and before the +/// outcome tables — and answering it from ambient state on each +/// call made those three independent reads of process-global state that nothing kept in agreement. +/// Holding the version on the instance also lets a caller supply it, so a spec asserts the document +/// production emits rather than a parallel one assembled for the test. +/// +internal sealed class BuildIntrospectionService { /// /// Version of the --plan --json document. Deliberately its own constant rather than @@ -33,7 +39,24 @@ internal static class BuildIntrospectionService /// Version of the error envelope, separate for the same reason. internal const int ErrorSchemaVersion = 1; - /// Whether this invocation is a read-only introspection request rather than a build. + private readonly bool describe; + private readonly bool planAsJson; + private readonly string falloutVersion; + + /// Whether --describe was requested. + /// Whether --plan and --json were both requested. + /// + /// The version stamped into the describe document. Passed in rather than read inside, so a spec + /// can assert an exact document without the running assembly's version leaking into it. + /// + internal BuildIntrospectionService(bool describe, bool planAsJson, string falloutVersion) + { + this.describe = describe; + this.planAsJson = planAsJson; + this.falloutVersion = falloutVersion; + } + + /// Resolves the request for a run, once. /// /// Each flag is read from the injected property OR straight from the arguments, because this is /// asked before value injection has run: InjectParameterValuesAttribute is itself an @@ -41,9 +64,11 @@ internal static class BuildIntrospectionService /// the property here would make every request look like an ordinary build. /// // --plan alone keeps its existing meaning (the HTML graph); only --json redirects it here. - internal static bool IsRequested(FalloutBuild build) - => Flag(build.Describe, nameof(FalloutBuild.Describe)) || - (Flag(build.Plan, nameof(FalloutBuild.Plan)) && Flag(build.Json, nameof(FalloutBuild.Json))); + internal static BuildIntrospectionService For(FalloutBuild build) + => new( + Flag(build.Describe, nameof(FalloutBuild.Describe)), + Flag(build.Plan, nameof(FalloutBuild.Plan)) && Flag(build.Json, nameof(FalloutBuild.Json)), + BuildGraphUtility.GetFalloutVersion()); private static bool Flag(bool injected, string parameterName) => injected || ParameterService.GetParameter(parameterName); @@ -51,8 +76,8 @@ private static bool Flag(bool injected, string parameterName) /// /// Whether raw command-line arguments request introspection, for callers that must decide /// before a build process exists — the CLI, which has to know where to send the build step's - /// own output. Shares this type with the property-based overload so the two entry points - /// cannot disagree about what counts as a read-only request. + /// own output. Static because there is no run yet to hang an instance off, and it lives on this + /// type so the two entry points cannot disagree about what counts as a read-only request. /// internal static bool IsRequested(IReadOnlyCollection arguments) => HasFlag(arguments, nameof(FalloutBuild.Describe)) || @@ -64,24 +89,26 @@ private static bool HasFlag(IReadOnlyCollection arguments, string parame x.StartsWith("-", StringComparison.Ordinal) && x.TrimStart('-').Replace("-", string.Empty).EqualsOrdinalIgnoreCase(parameterName)); - /// The document for whichever request matched. - internal static string GetDocument( + /// Whether this invocation is a read-only introspection request rather than a build. + internal bool IsRequestedForRun => describe || planAsJson; + + /// The document for whichever request matched. + internal string GetDocument( FalloutBuild build, IReadOnlyCollection targets, - IReadOnlyCollection plan) - => Flag(build.Describe, nameof(FalloutBuild.Describe)) + IReadOnlyCollection plan, + IReadOnlyCollection invokedTargets, + IReadOnlyCollection skippedTargets) + => describe ? GetDescribeJson(build, targets) - : GetPlanJson( - ParameterService.GetParameter(() => build.InvokedTargets) ?? new string[0], - plan, - ParameterService.GetParameter(() => build.SkippedTargets)); + : GetPlanJson(invokedTargets ?? new string[0], plan, skippedTargets); /// /// The resolved execution plan: what would run, in order, and what gates each entry. /// Conditions are reported as their declared text and never evaluated — they are user delegates, /// and running them would contradict "invokes no target". /// - internal static string GetPlanJson( + internal string GetPlanJson( IReadOnlyCollection invokedTargets, IReadOnlyCollection plan, IReadOnlyCollection skippedTargets) @@ -115,27 +142,16 @@ internal static string GetPlanJson( } /// The whole build model: targets, dependency edges, tool requirements, parameters. - internal static string GetDescribeJson( + internal string GetDescribeJson( FalloutBuild build, IReadOnlyCollection targets) - => BuildGraphUtility.GetJsonString(build, targets); - - /// Overload taking an explicit version, so the document can be asserted deterministically. - internal static string GetDescribeJson( - FalloutBuild build, - IReadOnlyCollection targets, - string falloutVersion) - => BuildGraphUtility.GetModel( - targets, - falloutVersion, - ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false)) - .ToJson(BuildGraphUtility.SerializerOptions); + => BuildGraphUtility.GetJsonString(build, targets, falloutVersion); /// /// The failure form of both documents, so a consumer parsing standard output gets JSON whether /// the request succeeded or the build threw on its way to being described. /// - internal static string GetErrorJson(Exception exception) + internal string GetErrorJson(Exception exception) => new ErrorModel( ErrorSchemaVersion, new ErrorDetailModel(exception.GetType().Name, exception.Message)) diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs index 20c0d1ccd..f41c45d7a 100644 --- a/src/Fallout.Build/Execution/BuildManager.cs +++ b/src/Fallout.Build/Execution/BuildManager.cs @@ -44,8 +44,15 @@ public static int Execute(Expression>[] defaultTargetExpressi using var context = BuildContext.Activate(); var build = new T(); + // Resolved once, then reused by the gate, the failure path and Finish(). Declared out here + // so those last two still have it; null means the run failed before the request could even + // be determined, which is an ordinary failure and reported as one. + BuildIntrospectionService introspection = null; + try { + introspection = BuildIntrospectionService.For(build); + Logging.Configure(build); build.ExecutableTargets = ExecutableTargetFactory.CreateAll(build, defaultTargetExpressions); @@ -55,16 +62,20 @@ public static int Execute(Expression>[] defaultTargetExpressi // nothing" structural: IOnBuildCreated alone rewrites .fallout/build.schema.json // (HandleShellCompletionAttribute) and can block on Console.ReadKey // (UpdateNotificationAttribute), which would deadlock a piped consumer. - if (BuildIntrospectionService.IsRequested(build)) + if (introspection.IsRequestedForRun) { - build.ExecutionPlan = ExecutionPlanner.GetExecutionPlan( - build.ExecutableTargets, - ParameterService.GetParameter(() => build.InvokedTargets)); + var invokedTargets = ParameterService.GetParameter(() => build.InvokedTargets); + build.ExecutionPlan = ExecutionPlanner.GetExecutionPlan(build.ExecutableTargets, invokedTargets); // Newline-terminated, matching SchemaUtility's emitted JSON and the usual // expectation that a document written to a pipe ends with one. Console.Out.WriteLine( - BuildIntrospectionService.GetDocument(build, build.ExecutableTargets, build.ExecutionPlan)); + introspection.GetDocument( + build, + build.ExecutableTargets, + build.ExecutionPlan, + invokedTargets, + ParameterService.GetParameter(() => build.SkippedTargets))); return build.ExitCode ??= 0; } @@ -99,12 +110,12 @@ public static int Execute(Expression>[] defaultTargetExpressi // An introspection request promised JSON on standard output; a failure on the way to // emitting the document has to keep that promise rather than switch to human prose. - if (BuildIntrospectionService.IsRequested(build)) + if (introspection is { IsRequestedForRun: true }) { // Nothing is logged here on purpose: Serilog's console sink writes to standard // output, so a log line would land inside the document a consumer is parsing. The // envelope carries the kind and message instead. - Console.Out.WriteLine(BuildIntrospectionService.GetErrorJson(exception)); + Console.Out.WriteLine(introspection.GetErrorJson(exception)); return build.ExitCode ??= ErrorExitCode; } @@ -128,7 +139,7 @@ void Finish() { // The plan is resolved before the introspection short-circuit returns, so guarding on // it alone would print the outcome tables over the emitted document. - if (build.ExecutionPlan == null || BuildIntrospectionService.IsRequested(build)) + if (build.ExecutionPlan == null || introspection is { IsRequestedForRun: true }) return; foreach (var target in build.ExecutionPlan) diff --git a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs index 37dd9e846..96498c1d4 100644 --- a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs +++ b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs @@ -116,9 +116,20 @@ internal static string GetJsonString( /// Serializes the whole model for a build: targets, parameters, build-level requirements. internal static string GetJsonString(IFalloutBuild build, IReadOnlyCollection targets) + => GetJsonString(build, targets, GetFalloutVersion()); + + /// + /// As above, with the version supplied instead of read off the running assembly, so a caller + /// that needs a deterministic document gets one down the SAME path production uses — the + /// parameters and the build-level requirements included. + /// + internal static string GetJsonString( + IFalloutBuild build, + IReadOnlyCollection targets, + string falloutVersion) => GetModel( targets, - GetFalloutVersion(), + falloutVersion, ValueInjectionUtility.GetParameterMembers(build.GetType(), includeUnlisted: false), BuildRequirements(build)) .ToJson(SerializerOptions); diff --git a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs index aaf4609ba..8b09b8c65 100644 --- a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs +++ b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Fallout.Common; using Fallout.Common.Execution; +using Fallout.Common.Tooling; using FluentAssertions; using Xunit; @@ -18,25 +19,36 @@ public class BuildIntrospectionServiceSpecs { private const string SampleVersion = "2026.1.0-preview.42"; + // The flags a run resolves once, restated here so each spec builds the service the way + // BuildManager does. Version is pinned so the asserted document never moves with the assembly. + private static BuildIntrospectionService Describing() + => new(describe: true, planAsJson: false, SampleVersion); + + private static BuildIntrospectionService Planning() + => new(describe: false, planAsJson: true, SampleVersion); + + private static bool IsRequestedFor(FalloutBuild build) + => BuildIntrospectionService.For(build).IsRequestedForRun; + [Fact] public void Describe_is_requested_by_the_describe_flag_alone() { - BuildIntrospectionService.IsRequested(new SampleBuild { Describe = true }).Should().BeTrue(); + IsRequestedFor(new SampleBuild { Describe = true }).Should().BeTrue(); } [Fact] public void Plan_json_is_requested_only_when_both_flags_are_set() { // --plan on its own keeps its existing behaviour: the HTML graph, opened in a browser. - BuildIntrospectionService.IsRequested(new SampleBuild { Plan = true }).Should().BeFalse(); - BuildIntrospectionService.IsRequested(new SampleBuild { Json = true }).Should().BeFalse(); - BuildIntrospectionService.IsRequested(new SampleBuild { Plan = true, Json = true }).Should().BeTrue(); + IsRequestedFor(new SampleBuild { Plan = true }).Should().BeFalse(); + IsRequestedFor(new SampleBuild { Json = true }).Should().BeFalse(); + IsRequestedFor(new SampleBuild { Plan = true, Json = true }).Should().BeTrue(); } [Fact] public void An_ordinary_run_requests_no_introspection() { - BuildIntrospectionService.IsRequested(new SampleBuild()).Should().BeFalse(); + IsRequestedFor(new SampleBuild()).Should().BeFalse(); } [Theory] @@ -60,8 +72,7 @@ public void Raw_arguments_are_recognised_the_same_way_the_injected_flags_are( [Fact] public void Describe_document_carries_targets_and_parameters_and_parses_as_json() { - var json = BuildIntrospectionService.GetDescribeJson( - new SampleBuild(), SampleGraph(), SampleVersion); + var json = Describing().GetDescribeJson(new SampleBuild(), SampleGraph()); using var document = JsonDocument.Parse(json); var root = document.RootElement; @@ -77,8 +88,7 @@ public void Describe_document_carries_targets_and_parameters_and_parses_as_json( [Fact] public void Describe_document_projects_the_build_s_own_parameters() { - var json = BuildIntrospectionService.GetDescribeJson( - new SampleBuild(), SampleGraph(), SampleVersion); + var json = Describing().GetDescribeJson(new SampleBuild(), SampleGraph()); using var document = JsonDocument.Parse(json); var names = document.RootElement.GetProperty("parameters").EnumerateArray() @@ -95,7 +105,7 @@ public void Plan_document_preserves_order_and_never_evaluates_conditions() var compile = new ExecutableTarget { Name = "Compile", Invoked = true }; compile.StaticConditions.Add(("IsServerBuild", () => { evaluated = true; return true; })); - var json = BuildIntrospectionService.GetPlanJson( + var json = Planning().GetPlanJson( new[] { "Compile" }, new[] { restore, compile }, skippedTargets: null); using var document = JsonDocument.Parse(json); @@ -125,7 +135,7 @@ public void A_named_skipped_target_carries_the_executor_s_own_reason() var restore = new ExecutableTarget { Name = "Restore" }; var compile = new ExecutableTarget { Name = "Compile" }; - var json = BuildIntrospectionService.GetPlanJson( + var json = Planning().GetPlanJson( new[] { "Compile" }, new[] { restore, compile }, new[] { "re-store" }); using var document = JsonDocument.Parse(json); @@ -139,7 +149,7 @@ public void A_named_skipped_target_carries_the_executor_s_own_reason() [Fact] public void An_empty_skip_list_skips_every_target_except_the_invoked_ones() { - var json = BuildIntrospectionService.GetPlanJson( + var json = Planning().GetPlanJson( new[] { "Compile" }, new[] { @@ -160,7 +170,7 @@ public void An_explicitly_invoked_target_is_never_reported_as_skipped() { // BuildExecutor.MarkTargetSkipped only skips when !target.Invoked, so naming an invoked // target in --skip does not stop it running. The predicted plan has to say the same. - var json = BuildIntrospectionService.GetPlanJson( + var json = Planning().GetPlanJson( new[] { "Compile" }, new[] { new ExecutableTarget { Name = "Compile", Invoked = true } }, new[] { "Compile" }); @@ -174,7 +184,7 @@ public void An_explicitly_invoked_target_is_never_reported_as_skipped() [Fact] public void Error_envelope_names_the_exception_kind_and_message() { - var json = BuildIntrospectionService.GetErrorJson(new InvalidOperationException("boom")); + var json = Planning().GetErrorJson(new InvalidOperationException("boom")); using var document = JsonDocument.Parse(json); var root = document.RootElement; @@ -185,6 +195,22 @@ public void Error_envelope_names_the_exception_kind_and_message() error.GetProperty("message").GetString().Should().Be("boom"); } + [Fact] + public void Describe_document_carries_the_build_s_class_level_tool_requirements() + { + // Regression guard for a gap the old shape hid. The specs used to call a version-taking + // overload that projected targets and parameters but NOT BuildRequirements, so the document + // asserted here was not the one production emitted and this projection went uncovered. + // Both now go down one path, and a class-level [Requires] reaches the document. + var json = Describing().GetDescribeJson(new RequiringBuild(), SampleGraph()); + + using var document = JsonDocument.Parse(json); + var requirements = document.RootElement.GetProperty("toolRequirements"); + + requirements.EnumerateArray().Select(x => x.GetProperty("packageId").GetString()) + .Should().Equal("GitVersion.Tool"); + } + private static IReadOnlyCollection SampleGraph() { var restore = new ExecutableTarget { Name = "Restore", Listed = true }; @@ -198,4 +224,14 @@ private class SampleBuild : FalloutBuild [Parameter("An API key.")] private readonly string ApiKey; } + + // A stand-in tool: the specs project references Fallout.Build, not the generated wrappers in + // Fallout.Common, and [Requires] only needs T to carry a ToolAttribute. + [NuGetTool(Id = "GitVersion.Tool")] + private class FakeTool : IRequireNuGetPackage; + + // [Requires] targets Class/Interface only, so this is the sole way a build-level tool + // requirement can reach the describe document. + [Requires(Version = "5.12.0")] + private class RequiringBuild : FalloutBuild; }