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"