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/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/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
new file mode 100644
index 000000000..b9cacae0f
--- /dev/null
+++ b/src/Fallout.Build/Execution/BuildIntrospectionService.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Fallout.Build.Execution.Extensions;
+using Fallout.Common.Utilities;
+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".
+///
+///
+///
+/// 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
+ /// : 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;
+
+ /// Version of the error envelope, separate for the same reason.
+ internal const int ErrorSchemaVersion = 1;
+
+ 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
+ /// 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 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);
+
+ ///
+ /// 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. 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)) ||
+ (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));
+
+ /// 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,
+ IReadOnlyCollection invokedTargets,
+ IReadOnlyCollection skippedTargets)
+ => describe
+ ? GetDescribeJson(build, targets)
+ : 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 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,
+ // 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))
+ ? BuildExecutor.SkippedViaParameterReason
+ : null,
+ target.StaticConditions.Select(x => x.Text).ToList(),
+ target.DynamicConditions.Select(x => x.Text).ToList()))
+ .ToList();
+
+ return new PlanModel(
+ PlanSchemaVersion,
+ invokedTargets.ToList(),
+ entries)
+ .ToJson(BuildGraphUtility.SerializerOptions);
+ }
+
+ /// The whole build model: targets, dependency edges, tool requirements, parameters.
+ internal string GetDescribeJson(
+ FalloutBuild build,
+ IReadOnlyCollection targets)
+ => 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 string GetErrorJson(Exception exception)
+ => new ErrorModel(
+ ErrorSchemaVersion,
+ new ErrorDetailModel(exception.GetType().Name, exception.Message))
+ .ToJson(BuildGraphUtility.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,
+ 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);
+}
diff --git a/src/Fallout.Build/Execution/BuildManager.cs b/src/Fallout.Build/Execution/BuildManager.cs
index 33fe49dc3..f41c45d7a 100644
--- a/src/Fallout.Build/Execution/BuildManager.cs
+++ b/src/Fallout.Build/Execution/BuildManager.cs
@@ -44,11 +44,41 @@ 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);
+
+ // 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 (introspection.IsRequestedForRun)
+ {
+ 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(
+ introspection.GetDocument(
+ build,
+ build.ExecutableTargets,
+ build.ExecutionPlan,
+ invokedTargets,
+ ParameterService.GetParameter(() => build.SkippedTargets)));
+ return build.ExitCode ??= 0;
+ }
+
build.ExecuteExtension(x => x.OnBuildCreated(build.ExecutableTargets));
NuGetToolPathResolver.EmbeddedPackagesDirectory = build.EmbeddedPackagesDirectory;
@@ -77,6 +107,18 @@ 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 (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(introspection.GetErrorJson(exception));
+ return build.ExitCode ??= ErrorExitCode;
+ }
+
if (exception is not TargetExecutionException)
{
Log.Verbose(exception, "Target-unrelated exception was thrown");
@@ -95,7 +137,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 || 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 cda806ffd..96498c1d4 100644
--- a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs
+++ b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs
@@ -1,9 +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;
@@ -23,32 +27,118 @@ 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.
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)
+ => 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());
+ .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);
+
+ /// 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,
+ falloutVersion,
+ 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
@@ -74,17 +164,94 @@ private static TargetModel ToModel(ExecutableTarget target)
SortedNames(target.ExecutionDependencies),
SortedNames(target.OrderDependencies),
SortedNames(target.TriggerDependencies),
- SortedNames(target.Triggers));
+ SortedNames(target.Triggers),
+ 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 SortedRequirements(
+ IEnumerable requirements)
+ => requirements
+ .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.
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)
+ => new(
+ ParameterService.GetParameterDashedName(member),
+ TypeName(member.GetMemberType()),
+ member.DeclaringType?.Name,
+ ParameterService.GetParameterDescription(member),
+ member.GetCustomAttribute() != null,
+ member.GetCustomAttribute() != null,
+ 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.
+ //
+ // 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 Targets);
+ IReadOnlyList ToolRequirements,
+ 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,
+ string Default,
+ IReadOnlyList AllowedValues);
internal sealed record TargetModel(
string Name,
@@ -95,5 +262,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/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs
index d7a6c50db..cbeaa0e04 100644
--- a/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs
+++ b/src/Fallout.Build/Execution/Extensions/HandleHelpRequestsAttribute.cs
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
+using Fallout.Build.Execution.Extensions;
using Fallout.Common.Utilities;
using Fallout.Common.ValueInjection;
@@ -24,33 +26,71 @@ public void OnBuildInitialized(
public string GetTargetsText()
{
+ // 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);
+
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)
{
+ 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.IsDefault ? " (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();
}
+ // 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();
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) 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 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);
+ var bufferWidth = GetBufferWidth();
List SplitLines(string text)
{
@@ -59,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}";
@@ -68,30 +108,31 @@ List SplitLines(string text)
return lines;
}
- void PrintParameter(MemberInfo parameter)
+ void PrintParameter((MemberInfo Member, BuildGraphUtility.ParameterModel Model) parameter)
{
var description = SplitLines(
// TODO: remove
- ParameterService.GetParameterDescription(parameter)
+ parameter.Model.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.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.DeclaringType != typeof(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.DeclaringType == typeof(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/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs
index a621f3847..222465110 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,7 @@ public void OnBuildInitialized(
{
try
{
- var json = BuildGraphUtility.GetJsonString(executableTargets, FindFalloutVersion());
+ var json = BuildGraphUtility.GetJsonString(Build, executableTargets);
GraphFile.WriteAllText(json);
}
catch (Exception exception)
@@ -35,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/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/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/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..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,5 +1,7 @@
{
"version": 1,
"falloutVersion": null,
- "targets": []
+ "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 67baa6095..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",
@@ -15,6 +16,18 @@
"triggeredBy": [],
"triggers": [
"Publish"
+ ],
+ "toolRequirements": [
+ {
+ "kind": "nuget",
+ "packageId": "GitVersion.Tool",
+ "version": "5.12.0"
+ },
+ {
+ "kind": "path",
+ "packageId": "git",
+ "version": null
+ }
]
},
{
@@ -28,7 +41,8 @@
"triggeredBy": [
"Test"
],
- "triggers": []
+ "triggers": [],
+ "toolRequirements": []
},
{
"name": "Restore",
@@ -39,7 +53,8 @@
"dependsOn": [],
"after": [],
"triggeredBy": [],
- "triggers": []
+ "triggers": [],
+ "toolRequirements": []
},
{
"name": "Test",
@@ -54,7 +69,9 @@
"Restore"
],
"triggeredBy": [],
- "triggers": []
+ "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 38776eb30..f835e72c0 100644
--- a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs
+++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs
@@ -1,10 +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;
@@ -41,6 +45,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 };
}
@@ -137,13 +145,13 @@ 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", "toolRequirements", "targets", "parameters");
var firstTarget = doc.RootElement.GetProperty("targets").EnumerateArray().First();
firstTarget.EnumerateObject().Select(x => x.Name)
.Should().Equal(
"name", "description", "declaredIn", "default", "listed",
- "dependsOn", "after", "triggeredBy", "triggers");
+ "dependsOn", "after", "triggeredBy", "triggers", "toolRequirements");
}
[Theory]
@@ -157,6 +165,97 @@ 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();
+ }
+
+ [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()
+ {
+ 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);
@@ -168,4 +267,17 @@ 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;
+
+ [Parameter("Tags to apply.")]
+ private readonly List Tags;
+ }
}
diff --git a/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs
new file mode 100644
index 000000000..8b09b8c65
--- /dev/null
+++ b/tests/Fallout.Build.Specs/BuildIntrospectionServiceSpecs.cs
@@ -0,0 +1,237 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using Fallout.Common;
+using Fallout.Common.Execution;
+using Fallout.Common.Tooling;
+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";
+
+ // 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()
+ {
+ 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.
+ 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()
+ {
+ IsRequestedFor(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()
+ {
+ var json = Describing().GetDescribeJson(new SampleBuild(), SampleGraph());
+
+ 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 = Describing().GetDescribeJson(new SampleBuild(), SampleGraph());
+
+ 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");
+ }
+
+ [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 = Planning().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 = Planning().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_except_the_invoked_ones()
+ {
+ var json = Planning().GetPlanJson(
+ new[] { "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 = Planning().GetPlanJson(
+ new[] { "Compile" },
+ new[] { new ExecutableTarget { Name = "Compile", Invoked = true } },
+ new[] { "Compile" });
+
+ using var document = JsonDocument.Parse(json);
+
+ document.RootElement.GetProperty("plan")[0]
+ .GetProperty("skip").ValueKind.Should().Be(JsonValueKind.Null);
+ }
+
+ [Fact]
+ public void Error_envelope_names_the_exception_kind_and_message()
+ {
+ var json = Planning().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");
+ }
+
+ [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 };
+ 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;
+ }
+
+ // 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;
+}
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/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
+ {
+ }
+}
diff --git a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json
index dd7b413a8..764158962 100644
--- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.verified.json
+++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestCustomParameterAttribute.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.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..1d949380e 100644
--- a/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json
+++ b/tests/Fallout.Build.Specs/SchemaUtilitySpecs.TestParameterBuild.verified.json
@@ -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"