From 0ff6cbc17a9d343ffccf5958fba7c9f8d0f38e33 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 20:17:42 +1200 Subject: [PATCH 01/10] Record ADR-0009: lift the netstandard2.0 floor via an out-of-process MSBuild bridge Decision record for freeing Fallout.Tooling + Fallout.Tooling.Generator from the ns2.0 floor that Fallout.MSBuildTasks's net472 target imposes. Extract task logic into a net10 engine; serve dotnet/SDK builds with an in-proc net10 task and VS2019/2022 full-framework MSBuild with a thin net472 ToolTask bridge that shells out to a standalone net10 worker. Unblocks lifting AbsolutePath / Host / IFalloutBuild into Fallout.Core. Implements Lever 2 of #441 (Lever 1 / SourceGenerators cone is #442, independent). Bridge is time-boxed and deletable once VS2026's .NET MSBuild lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...0009-lift-ns20-floor-via-msbuild-bridge.md | 62 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 63 insertions(+) create mode 100644 docs/adr/0009-lift-ns20-floor-via-msbuild-bridge.md diff --git a/docs/adr/0009-lift-ns20-floor-via-msbuild-bridge.md b/docs/adr/0009-lift-ns20-floor-via-msbuild-bridge.md new file mode 100644 index 00000000..11670c3a --- /dev/null +++ b/docs/adr/0009-lift-ns20-floor-via-msbuild-bridge.md @@ -0,0 +1,62 @@ +# ADR-0009: Lift the `netstandard2.0` floor off the tooling stack via an out-of-process MSBuild bridge + +## Status + +Proposed (2026-06-30). Implements the **Lever 2** half of [#441](https://github.com/Fallout-build/Fallout/issues/441) ("move off the ns2.0/net472 floor"); the **Lever 1** half (freeing the `Fallout.SourceGenerators` Roslyn cone) is tracked separately in [#442](https://github.com/Fallout-build/Fallout/pull/442) and is an independent cone — the two do not interact. Unblocks the deferred onion lifts (`AbsolutePath`, `Host`, and lifting `IFalloutBuild` into `Fallout.Core`). + +## Context + +Two project graphs are pinned to `netstandard2.0`, for unrelated reasons: + +- **Cone A — `Fallout.SourceGenerators`** is a Roslyn source generator and is ns2.0 by compiler requirement. Its references (`Build.Shared`, `Solution`, `Persistence.Solution`) do **not** touch the tooling stack. Addressed by Lever 1 (#442); out of scope here. +- **Cone B — `Fallout.MSBuildTasks`** targets `net10.0;net472`. Its `net472` flavour exists because the MSBuild tasks it ships are loaded **into the consumer's MSBuild host**, and the in-IDE build of VS 2019/2022 (and `msbuild.exe`) is **full-framework** MSBuild, which can only load a `net472` task assembly in-process. That `net472` reference is the **sole** force dragging `Fallout.Tooling` and `Fallout.Tooling.Generator` down to a `net10.0;netstandard2.0` multi-target: + + ``` + Fallout.MSBuildTasks (net10.0;net472) ──► Fallout.Tooling (net10.0;netstandard2.0) + └─► Fallout.Tooling.Generator (netstandard2.0) + ``` + +Because `Fallout.Tooling` is ns2.0 and `Fallout.Core` is ns2.1, anything `Tooling` must consume cannot live in Core. That is the direct cause of: + +- the tool-execution contract living in the dedicated ns2.0 leaves `Fallout.Application.Tooling.Execution` / `.Requirements` instead of Core; +- `Configure` being un-liftable to Core (Phase 2a deferral); +- `AbsolutePath` (2b) and `Host` (2c) being un-liftable, which in turn blocks lifting `IFalloutBuild` into Core (2e). + +The three concrete tasks (`CodeGenerationTask`, `EmbedPackagesForSelfContainedTask`, `PackPackageToolsTask`, all extending `ContextAwareTask : Microsoft.Build.Utilities.Task`) are build-time **file producers** — tool-wrapper codegen, package embedding, tool packing. None needs a chatty in-process MSBuild API; each is expressible as *inputs → work → outputs + diagnostics*. That makes them safe to run out-of-process. + +`ContextAwareTask` carries a `CustomAssemblyLoader : AssemblyLoadContext` whose only job is to isolate Fallout's dependency closure from MSBuild's own assemblies inside the host — a known-fragile piece that exists purely because the work runs in-process. + +## Decision + +Extract the task **logic** into a `net10` engine and front it with two thin adapters, selected by MSBuild runtime: + +``` +Fallout.MSBuildTasks.Engine (net10) ← the real work; references net10 Tooling + Generator + ├─ Fallout.MSBuildTasks (net10) in-proc Task → calls the engine directly + └─ Fallout.MSBuildTasks.Bridge (net472) ToolTask → shells out to a standalone net10 worker +``` + +1. **`Fallout.Tooling` and `Fallout.Tooling.Generator` become `net10`-only.** With the `net472` consumer gone, the floor is lifted. `Fallout.Core` (or a single set of net-modern contracts) can then absorb the tool contracts, `Configure`, and `AbsolutePath` — unblocking 2b/2c/2e. +2. **Core MSBuild hosts (`dotnet` / SDK build) load the in-proc `net10` task** — the hot path, no process spawn. It keeps the `ContextAwareTask`/`AssemblyLoadContext` isolation (loading net10 Tooling + Serilog/STJ into SDK-MSBuild can still clash). +3. **Full-framework MSBuild hosts (VS 2019/2022, `msbuild.exe`) load the `net472` bridge** — a `Microsoft.Build.Utilities.ToolTask` that shells out via `System.Diagnostics.Process` to a **standalone net10 worker console** (`Fallout.MSBuildTasks.Worker`). `ToolTask` is purpose-built for this: process launch, output capture, cancellation, and `LogEventsFromTextOutput` to surface worker diagnostics in the VS error list. + - The bridge references **only** `Microsoft.Build.*` + the BCL — **zero Fallout dependencies**, no `AssemblyLoadContext` dance. + - The worker is **standalone**, not a verb on the `fallout` CLI — this is a time-boxed workaround and must add no surface to the long-lived CLI. +4. **`.targets` selects the adapter on `$(MSBuildRuntimeType)`** (`Core` → in-proc task; `Full` → bridge). The legacy `Nuke*`→`Fallout*` property bridging and `FalloutTasksEnabled` flag are untouched. +5. **`dotnet` host discovery** uses `$(DOTNET_HOST_PATH)` (set by the SDK), falling back to PATH, passed to the bridge so it can launch `dotnet Fallout.MSBuildTasks.Worker.dll`. +6. **I/O marshalling stays boring** — `ITaskItem[]`/properties (e.g. `FalloutSpecificationFiles`) serialize to a temp JSON arg file; the worker writes its output files plus a small results JSON the bridge reads back for output item groups. + +The net10 runtime being present at the consumer's build is **assumed, not mitigated** — Fallout is a net10 build system; if net10 were absent, nothing would run. + +## Consequences + +- **Unblocks the de-static "lift to Core" track** (2b `AbsolutePath`, 2c `Host`, 2e `IFalloutBuild`) and lets the ns2.0 tooling leaves (`Application.Tooling.Execution`/`.Requirements`) eventually fold back into Core. +- **Deletes the fragile in-host ALC isolation for VS** — the bridge has no Fallout deps to isolate. +- **Adds a per-invocation process spawn for full-framework builds only.** These are build-time, run-once-per-build operations; the cost is negligible and the hot `dotnet`/CLI path keeps its in-proc speed. +- **The bridge is a fenced, deletable legacy adapter** for VS 2019/2022. VS 2026's move to a 64-bit/.NET MSBuild is expected to load net10 tasks in-process directly, at which point `Full` hosts also get a modern runtime and the bridge is dead weight. **Retirement = delete the `.Bridge` project + the `Full` branch in `.targets`.** Keep it past then only if it is costing nothing. +- **Behaviour parity is the bar:** VS-driven consumer builds (codegen, package embed, tool pack) must produce identical output and identical error-list diagnostics through the bridge as through the in-proc task. + +## Alternatives considered + +- **Drop `net472` outright** (the original Lever 2 framing). Simplest, but breaks the Fallout MSBuild tasks for in-VS / `msbuild.exe` builds on VS 2019/2022. Rejected: the bridge preserves those at low, time-boxed cost. +- **Unify everyone onto the out-of-process worker** (no in-proc task). One code path, but pays the process-spawn cost on every `dotnet`/CLI build and discards the existing ALC isolation that already works. Rejected in favour of in-proc-for-Core, bridge-for-Full. +- **MSBuild task host (`Runtime="NET"`)** to run a net-core task from full-framework MSBuild. Not a reliable cross-runtime path from net472; the explicit shell-out is more robust and fully decoupled. diff --git a/docs/adr/README.md b/docs/adr/README.md index a932561f..0ed0bd7f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,3 +40,4 @@ If you change a decision, do NOT silently rewrite the old ADR — add a new one | [0004](0004-calendar-versioning-and-dual-pace-channels.md) | Calendar versioning + dual-pace channels (edge/stable) + experimental APIs | Accepted (§3 amended by 0007; channel ladder §2 superseded by 0008) | | [0007](0007-cut-release-branch-on-demand.md) | Cut `release/YYYY` on demand, not preemptively | Accepted | | [0008](0008-collapse-experimental-into-main.md) | Collapse `experimental` into `main`; `main` is the sole prerelease lane | Accepted | +| [0009](0009-lift-ns20-floor-via-msbuild-bridge.md) | Lift the `netstandard2.0` floor off the tooling stack via an out-of-process MSBuild bridge | Proposed | From a6c32fd5a1705aff55497d3cad6ed4fc81125027 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 20:22:30 +1200 Subject: [PATCH 02/10] Extract the MSBuild task logic into a net10 Fallout.MSBuildTasks.Engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of ADR-0009. Lifts the bodies of CodeGenerationTask / EmbedPackagesForSelfContained / PackPackageTools into an MSBuild-free, net10-only engine (CodeGenerationEngine + PackageToolingEngine, plain data in/out via PackagedToolFile). This is the shared home both the in-process net10 task and the out-of-process worker will call, so the net472 host can later become a thin bridge and Fallout.Tooling can drop the ns2.0 floor. Additive — nothing consumes the engine yet; the existing tasks are untouched. Solution + full build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- fallout.slnx | 1 + .../CodeGenerationEngine.cs | 43 ++++++++++++++ .../Fallout.MSBuildTasks.Engine.csproj | 19 +++++++ .../PackageToolingEngine.cs | 57 +++++++++++++++++++ .../PackagedToolFile.cs | 4 ++ 5 files changed, 124 insertions(+) create mode 100644 src/Fallout.MSBuildTasks.Engine/CodeGenerationEngine.cs create mode 100644 src/Fallout.MSBuildTasks.Engine/Fallout.MSBuildTasks.Engine.csproj create mode 100644 src/Fallout.MSBuildTasks.Engine/PackageToolingEngine.cs create mode 100644 src/Fallout.MSBuildTasks.Engine/PackagedToolFile.cs diff --git a/fallout.slnx b/fallout.slnx index 808eb0ea..15ed7b40 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -23,6 +23,7 @@ + diff --git a/src/Fallout.MSBuildTasks.Engine/CodeGenerationEngine.cs b/src/Fallout.MSBuildTasks.Engine/CodeGenerationEngine.cs new file mode 100644 index 00000000..2e64f07d --- /dev/null +++ b/src/Fallout.MSBuildTasks.Engine/CodeGenerationEngine.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Fallout.CodeGeneration; +using Fallout.CodeGeneration.Model; +using Fallout.Common.IO; +using Fallout.Common.Utilities.Collections; + +namespace Fallout.MSBuildTasks.Engine; + +/// +/// MSBuild-free tool-wrapper code generation. Lifted out of CodeGenerationTask so the same +/// logic serves both the in-process net10 task and the out-of-process worker. +/// +public static class CodeGenerationEngine +{ + public static void Generate( + IReadOnlyList specificationFiles, + string baseDirectory, + bool useNestedNamespaces, + string baseNamespace, + bool updateReferences, + Action log) + { + string GetFilePath(Tool tool) + => (AbsolutePath)baseDirectory + / (useNestedNamespaces ? tool.Name : ".") + / tool.DefaultOutputFileName; + + string GetNamespace(Tool tool) + => !useNestedNamespaces + ? baseNamespace + : string.IsNullOrEmpty(baseNamespace) + ? tool.Name + : $"{baseNamespace}.{tool.Name}"; + + specificationFiles + .ForEachLazy(x => log($"Handling {x} ...")) + .ForEach(x => CodeGenerator.GenerateCode(x, GetFilePath, GetNamespace)); + + if (updateReferences) + ReferenceUpdater.UpdateReferences(specificationFiles); + } +} diff --git a/src/Fallout.MSBuildTasks.Engine/Fallout.MSBuildTasks.Engine.csproj b/src/Fallout.MSBuildTasks.Engine/Fallout.MSBuildTasks.Engine.csproj new file mode 100644 index 00000000..483eef45 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Engine/Fallout.MSBuildTasks.Engine.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + false + + + + + + + + + diff --git a/src/Fallout.MSBuildTasks.Engine/PackageToolingEngine.cs b/src/Fallout.MSBuildTasks.Engine/PackageToolingEngine.cs new file mode 100644 index 00000000..1e3495d5 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Engine/PackageToolingEngine.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Fallout.Common.Tooling; +using Fallout.Common.Utilities; +using static Fallout.Common.IO.PathConstruction; + +namespace Fallout.MSBuildTasks.Engine; + +/// +/// MSBuild-free NuGet package discovery for self-contained embedding and tool packing. Lifted out of +/// EmbedPackagesForSelfContainedTask / PackPackageToolsTask. +/// +public static class PackageToolingEngine +{ + /// Package files carrying a tools folder, excluding the runtime pack. + public static IReadOnlyList GetEmbeddablePackageFiles(string projectAssetsFile) + { + return NuGetPackageResolver.GetLocalInstalledPackages(projectAssetsFile) + .Where(x => !x.Id.StartsWithOrdinalIgnoreCase("microsoft.netcore.app.runtime")) + .Where(x => Directory.GetDirectories(x.Directory, "tools").Any()) + .Select(x => (string)x.File) + .ToList(); + } + + /// Every file under each installed package's tools folder, with its pack metadata. + public static IReadOnlyList GetPackageToolFiles( + string projectAssetsFile, + string nuGetPackageRoot, + string targetFramework) + { + return NuGetPackageResolver.GetLocalInstalledPackages(projectAssetsFile) + .SelectMany(x => GetFiles(nuGetPackageRoot, targetFramework, x.Id, x.Version.ToString())) + .ToList(); + } + + private static IEnumerable GetFiles( + string nuGetPackageRoot, + string targetFramework, + string packageId, + string packageVersion) + { + var packageToolsDirectory = Path.Combine( + nuGetPackageRoot, packageId.ToLowerInvariant(), packageVersion.ToLowerInvariant(), "tools"); + if (!Directory.Exists(packageToolsDirectory)) + yield break; + + foreach (var file in Directory.GetFiles(packageToolsDirectory, "*", SearchOption.AllDirectories)) + { + yield return new PackagedToolFile( + File: file, + BuildAction: "None", + PackagePath: Path.Combine( + "tools", targetFramework, "any", packageId, GetRelativePath(packageToolsDirectory, file))); + } + } +} diff --git a/src/Fallout.MSBuildTasks.Engine/PackagedToolFile.cs b/src/Fallout.MSBuildTasks.Engine/PackagedToolFile.cs new file mode 100644 index 00000000..13fb6ce5 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Engine/PackagedToolFile.cs @@ -0,0 +1,4 @@ +namespace Fallout.MSBuildTasks.Engine; + +/// A tool file discovered inside a NuGet package, plus the MSBuild item metadata it needs. +public sealed record PackagedToolFile(string File, string BuildAction, string PackagePath); From a90e4922c48b43114d74021b8788313f6eb1eec2 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 20:28:12 +1200 Subject: [PATCH 03/10] Add the net10 worker + shared JSON protocol for the MSBuild bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of ADR-0009. Fallout.MSBuildTasks.Protocol is a dependency-free contract (netstandard2.0;net10) of request/response POCOs + System.Text.Json helpers, referenced by both the net472 bridge and the net10 worker. Fallout.MSBuildTasks.Worker is the standalone net10 console the bridge shells out to: , dispatching codegen / embed-packages / pack-tools to the engine and writing a WorkerResponse (exit 0/1/2). Additive — nothing invokes the worker yet. Smoke-tested out-of-process: bad-invocation → exit 2; embed-packages against a real project.assets.json → exit 0 with a valid package list. Co-Authored-By: Claude Opus 4.8 (1M context) --- fallout.slnx | 2 + .../Fallout.MSBuildTasks.Protocol.csproj | 17 +++++ .../WorkerProtocol.cs | 64 +++++++++++++++++++ .../Fallout.MSBuildTasks.Worker.csproj | 20 ++++++ src/Fallout.MSBuildTasks.Worker/Program.cs | 60 +++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj create mode 100644 src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs create mode 100644 src/Fallout.MSBuildTasks.Worker/Fallout.MSBuildTasks.Worker.csproj create mode 100644 src/Fallout.MSBuildTasks.Worker/Program.cs diff --git a/fallout.slnx b/fallout.slnx index 15ed7b40..6cf768c8 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -24,6 +24,8 @@ + + diff --git a/src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj b/src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj new file mode 100644 index 00000000..d3840258 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0;net10.0 + false + + + + + + + + diff --git a/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs b/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs new file mode 100644 index 00000000..c4bcc0af --- /dev/null +++ b/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace Fallout.MSBuildTasks.Protocol; + +/// Worker verbs — the first CLI argument the bridge passes to the net10 worker. +public static class WorkerVerbs +{ + public const string Codegen = "codegen"; + public const string EmbedPackages = "embed-packages"; + public const string PackTools = "pack-tools"; +} + +/// Shared (de)serialization so both ends use identical options. +public static class WorkerJson +{ + private static readonly JsonSerializerOptions Options = new() { WriteIndented = false }; + + public static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + + public static T Deserialize(string json) => JsonSerializer.Deserialize(json, Options); +} + +public sealed class CodegenRequest +{ + public string[] SpecificationFiles { get; set; } + public string BaseDirectory { get; set; } + public bool UseNestedNamespaces { get; set; } + public string BaseNamespace { get; set; } + public bool UpdateReferences { get; set; } +} + +public sealed class EmbedPackagesRequest +{ + public string ProjectAssetsFile { get; set; } +} + +public sealed class PackToolsRequest +{ + public string ProjectAssetsFile { get; set; } + public string NuGetPackageRoot { get; set; } + public string TargetFramework { get; set; } +} + +/// One discovered tool file plus the MSBuild item metadata the bridge re-attaches. +public sealed class PackagedToolFileDto +{ + public string File { get; set; } + public string BuildAction { get; set; } + public string PackagePath { get; set; } +} + +/// What the worker writes to its output JSON for the bridge to read back. +public sealed class WorkerResponse +{ + public bool Success { get; set; } + public string[] Messages { get; set; } = []; + public string[] Errors { get; set; } = []; + + /// embed-packages output: package file paths. + public string[] Files { get; set; } = []; + + /// pack-tools output: tool files with pack metadata. + public PackagedToolFileDto[] ToolFiles { get; set; } = []; +} diff --git a/src/Fallout.MSBuildTasks.Worker/Fallout.MSBuildTasks.Worker.csproj b/src/Fallout.MSBuildTasks.Worker/Fallout.MSBuildTasks.Worker.csproj new file mode 100644 index 00000000..aaf33b7f --- /dev/null +++ b/src/Fallout.MSBuildTasks.Worker/Fallout.MSBuildTasks.Worker.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + false + + + + + + + + + diff --git a/src/Fallout.MSBuildTasks.Worker/Program.cs b/src/Fallout.MSBuildTasks.Worker/Program.cs new file mode 100644 index 00000000..e76c5ed6 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Worker/Program.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Fallout.MSBuildTasks.Engine; +using Fallout.MSBuildTasks.Protocol; + +// Invoked by the net472 bridge as: +// Reads the request, runs the engine, writes a WorkerResponse, and signals via exit code +// (0 = success, 1 = handled failure, 2 = bad invocation). +if (args.Length != 3) +{ + Console.Error.WriteLine("usage: "); + return 2; +} + +var (verb, inputPath, outputPath) = (args[0], args[1], args[2]); +var messages = new List(); +var response = new WorkerResponse(); + +try +{ + var inputJson = File.ReadAllText(inputPath); + switch (verb) + { + case WorkerVerbs.Codegen: + var cg = WorkerJson.Deserialize(inputJson); + CodeGenerationEngine.Generate( + cg.SpecificationFiles, cg.BaseDirectory, cg.UseNestedNamespaces, + cg.BaseNamespace, cg.UpdateReferences, messages.Add); + break; + + case WorkerVerbs.EmbedPackages: + var ep = WorkerJson.Deserialize(inputJson); + response.Files = PackageToolingEngine.GetEmbeddablePackageFiles(ep.ProjectAssetsFile).ToArray(); + break; + + case WorkerVerbs.PackTools: + var pt = WorkerJson.Deserialize(inputJson); + response.ToolFiles = PackageToolingEngine + .GetPackageToolFiles(pt.ProjectAssetsFile, pt.NuGetPackageRoot, pt.TargetFramework) + .Select(x => new PackagedToolFileDto { File = x.File, BuildAction = x.BuildAction, PackagePath = x.PackagePath }) + .ToArray(); + break; + + default: + throw new ArgumentException($"Unknown verb '{verb}'."); + } + + response.Success = true; +} +catch (Exception exception) +{ + response.Success = false; + response.Errors = [exception.Message]; +} + +response.Messages = messages.ToArray(); +File.WriteAllText(outputPath, WorkerJson.Serialize(response)); +return response.Success ? 0 : 1; From 1982d02833d83e5191078fb0a494e993a0e52a2a Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 20:33:30 +1200 Subject: [PATCH 04/10] Add the net472 ToolTask bridge; route worker diagnostics through streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third step of ADR-0009. Fallout.MSBuildTasks.Bridge (net472, RootNamespace Fallout.MSBuildTasks so the task type names match the .targets UsingTask) is three ToolTask shims — CodeGenerationTask / EmbedPackagesForSelfContainedTask / PackPackageToolsTask — over a base WorkerBridgeTask that writes inputs to a temp JSON, shells out to `dotnet Fallout.MSBuildTasks.Worker.dll in out` (dotnet host via DOTNET_HOST_PATH, else PATH), and reads item outputs back. It references only the dependency-free Protocol — no Fallout.Tooling. To suit ToolTask, the worker now emits messages on stdout and errors on stderr (LogStandardErrorAsError), with the exit code as success; the output JSON carries only item outputs. WorkerResponse trimmed accordingly. Builds green (worker net10, bridge net472). The flip (TFMs net10-only + .targets runtime switch) is next. Co-Authored-By: Claude Opus 4.8 (1M context) --- fallout.slnx | 1 + .../CodeGenerationTask.cs | 33 ++++++ .../EmbedPackagesForSelfContainedTask.cs | 27 +++++ .../Fallout.MSBuildTasks.Bridge.csproj | 26 +++++ .../PackPackageToolsTask.cs | 41 +++++++ .../WorkerBridgeTask.cs | 105 ++++++++++++++++++ .../WorkerProtocol.cs | 10 +- src/Fallout.MSBuildTasks.Worker/Program.cs | 25 ++--- 8 files changed, 249 insertions(+), 19 deletions(-) create mode 100644 src/Fallout.MSBuildTasks.Bridge/CodeGenerationTask.cs create mode 100644 src/Fallout.MSBuildTasks.Bridge/EmbedPackagesForSelfContainedTask.cs create mode 100644 src/Fallout.MSBuildTasks.Bridge/Fallout.MSBuildTasks.Bridge.csproj create mode 100644 src/Fallout.MSBuildTasks.Bridge/PackPackageToolsTask.cs create mode 100644 src/Fallout.MSBuildTasks.Bridge/WorkerBridgeTask.cs diff --git a/fallout.slnx b/fallout.slnx index 6cf768c8..5af87cc8 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -26,6 +26,7 @@ + diff --git a/src/Fallout.MSBuildTasks.Bridge/CodeGenerationTask.cs b/src/Fallout.MSBuildTasks.Bridge/CodeGenerationTask.cs new file mode 100644 index 00000000..b639db26 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Bridge/CodeGenerationTask.cs @@ -0,0 +1,33 @@ +using System.Linq; +using Fallout.MSBuildTasks.Protocol; +using Microsoft.Build.Framework; + +namespace Fallout.MSBuildTasks; + +/// Full-framework shim for the tool-wrapper code generation task. +public sealed class CodeGenerationTask : WorkerBridgeTask +{ + [Required] + public ITaskItem[] SpecificationFiles { get; set; } + + [Required] + public string BaseDirectory { get; set; } + + public bool UseNestedNamespaces { get; set; } + + public string BaseNamespace { get; set; } + + public bool UpdateReferences { get; set; } + + protected override string Verb => WorkerVerbs.Codegen; + + protected override string BuildRequestJson() + => WorkerJson.Serialize(new CodegenRequest + { + SpecificationFiles = SpecificationFiles.Select(x => x.GetMetadata("FullPath")).ToArray(), + BaseDirectory = BaseDirectory, + UseNestedNamespaces = UseNestedNamespaces, + BaseNamespace = BaseNamespace, + UpdateReferences = UpdateReferences, + }); +} diff --git a/src/Fallout.MSBuildTasks.Bridge/EmbedPackagesForSelfContainedTask.cs b/src/Fallout.MSBuildTasks.Bridge/EmbedPackagesForSelfContainedTask.cs new file mode 100644 index 00000000..d2099cc7 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Bridge/EmbedPackagesForSelfContainedTask.cs @@ -0,0 +1,27 @@ +using System.Linq; +using Fallout.MSBuildTasks.Protocol; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Fallout.MSBuildTasks; + +/// Full-framework shim for resolving packages to embed for self-contained single-file builds. +public sealed class EmbedPackagesForSelfContainedTask : WorkerBridgeTask +{ + [Required] + public string ProjectAssetsFile { get; set; } + + [Required] + public string TargetFramework { get; set; } + + [Output] + public ITaskItem[] TargetOutputs { get; set; } + + protected override string Verb => WorkerVerbs.EmbedPackages; + + protected override string BuildRequestJson() + => WorkerJson.Serialize(new EmbedPackagesRequest { ProjectAssetsFile = ProjectAssetsFile }); + + protected override void ConsumeResponse(WorkerResponse response) + => TargetOutputs = response.Files.Select(x => (ITaskItem)new TaskItem(x)).ToArray(); +} diff --git a/src/Fallout.MSBuildTasks.Bridge/Fallout.MSBuildTasks.Bridge.csproj b/src/Fallout.MSBuildTasks.Bridge/Fallout.MSBuildTasks.Bridge.csproj new file mode 100644 index 00000000..230d2daa --- /dev/null +++ b/src/Fallout.MSBuildTasks.Bridge/Fallout.MSBuildTasks.Bridge.csproj @@ -0,0 +1,26 @@ + + + + net472 + false + + Fallout.MSBuildTasks + Fallout.MSBuildTasks.Bridge + + + + + + + + + + + + + diff --git a/src/Fallout.MSBuildTasks.Bridge/PackPackageToolsTask.cs b/src/Fallout.MSBuildTasks.Bridge/PackPackageToolsTask.cs new file mode 100644 index 00000000..cb2f6d5c --- /dev/null +++ b/src/Fallout.MSBuildTasks.Bridge/PackPackageToolsTask.cs @@ -0,0 +1,41 @@ +using System.Linq; +using Fallout.MSBuildTasks.Protocol; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Fallout.MSBuildTasks; + +/// Full-framework shim for collecting a package's tool files (with pack metadata). +public sealed class PackPackageToolsTask : WorkerBridgeTask +{ + [Required] + public string ProjectAssetsFile { get; set; } + + [Required] + public string NuGetPackageRoot { get; set; } + + [Required] + public string TargetFramework { get; set; } + + [Output] + public ITaskItem[] TargetOutputs { get; set; } + + protected override string Verb => WorkerVerbs.PackTools; + + protected override string BuildRequestJson() + => WorkerJson.Serialize(new PackToolsRequest + { + ProjectAssetsFile = ProjectAssetsFile, + NuGetPackageRoot = NuGetPackageRoot, + TargetFramework = TargetFramework, + }); + + protected override void ConsumeResponse(WorkerResponse response) + => TargetOutputs = response.ToolFiles.Select(x => + { + var item = new TaskItem(x.File); + item.SetMetadata("BuildAction", x.BuildAction); + item.SetMetadata("PackagePath", x.PackagePath); + return (ITaskItem)item; + }).ToArray(); +} diff --git a/src/Fallout.MSBuildTasks.Bridge/WorkerBridgeTask.cs b/src/Fallout.MSBuildTasks.Bridge/WorkerBridgeTask.cs new file mode 100644 index 00000000..c95d7c52 --- /dev/null +++ b/src/Fallout.MSBuildTasks.Bridge/WorkerBridgeTask.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using System.Linq; +using Fallout.MSBuildTasks.Protocol; +using Microsoft.Build.Utilities; + +namespace Fallout.MSBuildTasks; + +/// +/// Base for the full-framework MSBuild task shims. Serializes the task inputs to a temp JSON file, +/// shells out to the net10 worker (dotnet Fallout.MSBuildTasks.Worker.dll <verb> in out), +/// and lets surface the worker's stdout/stderr as build messages/errors. +/// Item outputs (if any) are read back from the worker's output JSON. +/// +public abstract class WorkerBridgeTask : ToolTask +{ + private string _inputPath; + private string _outputPath; + + protected WorkerBridgeTask() + { + // Worker errors arrive on stderr; treat them as build errors so they reach the VS error list. + LogStandardErrorAsError = true; + } + + /// The worker verb (see WorkerVerbs). + protected abstract string Verb { get; } + + /// Serialize this task's inputs to the worker request JSON. + protected abstract string BuildRequestJson(); + + /// Map the worker's item outputs back onto this task's [Output] properties. + protected virtual void ConsumeResponse(WorkerResponse response) { } + + protected override string ToolName => "dotnet"; + + protected override string GenerateFullPathToTool() => DotNetHost.Resolve(); + + private string WorkerDll => Path.Combine( + Path.GetDirectoryName(new Uri(typeof(WorkerBridgeTask).Assembly.Location).LocalPath) ?? ".", + "Fallout.MSBuildTasks.Worker.dll"); + + protected override string GenerateCommandLineCommands() + { + _inputPath = Path.GetTempFileName(); + _outputPath = Path.GetTempFileName(); + File.WriteAllText(_inputPath, BuildRequestJson()); + return $"\"{WorkerDll}\" {Verb} \"{_inputPath}\" \"{_outputPath}\""; + } + + public override bool Execute() + { + try + { + var succeeded = base.Execute(); + if (succeeded && _outputPath != null && File.Exists(_outputPath)) + { + var json = File.ReadAllText(_outputPath); + if (!string.IsNullOrWhiteSpace(json)) + ConsumeResponse(WorkerJson.Deserialize(json)); + } + + return succeeded && !Log.HasLoggedErrors; + } + finally + { + TryDelete(_inputPath); + TryDelete(_outputPath); + } + } + + private static void TryDelete(string path) + { + try + { + if (path != null && File.Exists(path)) + File.Delete(path); + } + catch + { + // Best-effort temp cleanup. + } + } +} + +/// Locates the dotnet host to run the net10 worker. +internal static class DotNetHost +{ + public static string Resolve() + { + // The SDK sets DOTNET_HOST_PATH for in-build processes; prefer it. + var hostPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + if (!string.IsNullOrEmpty(hostPath) && File.Exists(hostPath)) + return hostPath; + + var exe = Environment.OSVersion.Platform == PlatformID.Win32NT ? "dotnet.exe" : "dotnet"; + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var fromPath = path.Split(Path.PathSeparator) + .Where(d => !string.IsNullOrWhiteSpace(d)) + .Select(d => Path.Combine(d.Trim(), exe)) + .FirstOrDefault(File.Exists); + + return fromPath ?? "dotnet"; + } +} diff --git a/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs b/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs index c4bcc0af..86a987d5 100644 --- a/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs +++ b/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs @@ -49,13 +49,13 @@ public sealed class PackagedToolFileDto public string PackagePath { get; set; } } -/// What the worker writes to its output JSON for the bridge to read back. +/// +/// Item outputs the worker writes to its output JSON for the bridge to read back. Diagnostics and +/// success do NOT travel here — messages go to stdout, errors to stderr, and the exit code signals +/// success, so the bridge's ToolTask surfaces them natively. +/// public sealed class WorkerResponse { - public bool Success { get; set; } - public string[] Messages { get; set; } = []; - public string[] Errors { get; set; } = []; - /// embed-packages output: package file paths. public string[] Files { get; set; } = []; diff --git a/src/Fallout.MSBuildTasks.Worker/Program.cs b/src/Fallout.MSBuildTasks.Worker/Program.cs index e76c5ed6..2e1f8dad 100644 --- a/src/Fallout.MSBuildTasks.Worker/Program.cs +++ b/src/Fallout.MSBuildTasks.Worker/Program.cs @@ -1,13 +1,13 @@ using System; -using System.Collections.Generic; using System.IO; using System.Linq; using Fallout.MSBuildTasks.Engine; using Fallout.MSBuildTasks.Protocol; // Invoked by the net472 bridge as: -// Reads the request, runs the engine, writes a WorkerResponse, and signals via exit code -// (0 = success, 1 = handled failure, 2 = bad invocation). +// Diagnostics flow through the streams the bridge's ToolTask reads natively — messages to stdout, +// errors to stderr — and the exit code signals success (0 = ok, 1 = failure, 2 = bad invocation). +// The output JSON carries only item outputs (embed-packages / pack-tools). if (args.Length != 3) { Console.Error.WriteLine("usage: "); @@ -15,19 +15,18 @@ } var (verb, inputPath, outputPath) = (args[0], args[1], args[2]); -var messages = new List(); -var response = new WorkerResponse(); try { var inputJson = File.ReadAllText(inputPath); + var response = new WorkerResponse(); switch (verb) { case WorkerVerbs.Codegen: var cg = WorkerJson.Deserialize(inputJson); CodeGenerationEngine.Generate( cg.SpecificationFiles, cg.BaseDirectory, cg.UseNestedNamespaces, - cg.BaseNamespace, cg.UpdateReferences, messages.Add); + cg.BaseNamespace, cg.UpdateReferences, Console.Out.WriteLine); break; case WorkerVerbs.EmbedPackages: @@ -44,17 +43,15 @@ break; default: - throw new ArgumentException($"Unknown verb '{verb}'."); + Console.Error.WriteLine($"Unknown verb '{verb}'."); + return 2; } - response.Success = true; + File.WriteAllText(outputPath, WorkerJson.Serialize(response)); + return 0; } catch (Exception exception) { - response.Success = false; - response.Errors = [exception.Message]; + Console.Error.WriteLine(exception.Message); + return 1; } - -response.Messages = messages.ToArray(); -File.WriteAllText(outputPath, WorkerJson.Serialize(response)); -return response.Success ? 0 : 1; From 9b42596bad026c2c4d71e9d614c5cdd6dfd134e6 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 20:39:33 +1200 Subject: [PATCH 05/10] Drop the netstandard2.0 floor: net10 in-proc tasks + runtime-switched .targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flip (final step of ADR-0009). The in-proc CodeGeneration / EmbedPackages / PackPackageTools tasks now delegate to Fallout.MSBuildTasks.Engine and Fallout.MSBuildTasks goes net10-only. Fallout.Tooling drops netstandard2.0 (now net8.0;net10.0 — net8 kept for Fallout.ProjectModel's down-level targets; net8 consumes ns2.1, so Core absorption is unblocked) and Fallout.Tooling.Generator goes net10-only. The .targets picks the assembly by $(MSBuildRuntimeType): Full (VS2019/2022) -> the net472 bridge, Core (dotnet/SDK) -> the in-proc net10 task. ns2.0 — the only TFM that can't consume Fallout.Core (ns2.1) — is gone from the tooling stack, so AbsolutePath / Host / Configure / the tool contracts can now move into Core (Phase 2b/2c/2e). Full solution build + test suite green (StronglyTypedSolution snapshot refreshed for the four new projects). VS full-framework path can't be exercised on this OS; the net10/CLI path is verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CodeGenerationTask.cs | 34 ++++------------- .../EmbedPackagesForSelfContainedTask.cs | 12 ++---- .../Fallout.MSBuildTasks.csproj | 14 +++---- .../Fallout.MSBuildTasks.targets | 6 +++ .../PackPackageToolsTask.cs | 38 +++++-------------- .../Fallout.Tooling.Generator.csproj | 7 +++- src/Fallout.Tooling/Fallout.Tooling.csproj | 9 ++++- ...GeneratorSpecs.Test#Solution.g.verified.cs | 6 ++- 8 files changed, 53 insertions(+), 73 deletions(-) diff --git a/src/Fallout.MSBuildTasks/CodeGenerationTask.cs b/src/Fallout.MSBuildTasks/CodeGenerationTask.cs index 042b73cd..b18e46e7 100644 --- a/src/Fallout.MSBuildTasks/CodeGenerationTask.cs +++ b/src/Fallout.MSBuildTasks/CodeGenerationTask.cs @@ -1,10 +1,6 @@ -using System; using System.Linq; using Microsoft.Build.Framework; -using Fallout.CodeGeneration; -using Fallout.CodeGeneration.Model; -using Fallout.Common.IO; -using Fallout.Common.Utilities.Collections; +using Fallout.MSBuildTasks.Engine; namespace Fallout.MSBuildTasks; @@ -24,27 +20,13 @@ public class CodeGenerationTask : ContextAwareTask protected override bool ExecuteInner() { - var specificationFiles = SpecificationFiles.Select(x => x.GetMetadata("Fullpath")).ToList(); - - string GetFilePath(Tool tool) - => (AbsolutePath) BaseDirectory - / (UseNestedNamespaces ? tool.Name : ".") - / tool.DefaultOutputFileName; - - string GetNamespace(Tool tool) - => !UseNestedNamespaces - ? BaseNamespace - : string.IsNullOrEmpty(BaseNamespace) - ? tool.Name - : $"{BaseNamespace}.{tool.Name}"; - - specificationFiles - .ForEachLazy(x => LogMessage(message: $"Handling {x} ...")) - .ForEach(x => CodeGenerator.GenerateCode(x, GetFilePath, GetNamespace)); - - if (UpdateReferences) - ReferenceUpdater.UpdateReferences(specificationFiles); - + CodeGenerationEngine.Generate( + SpecificationFiles.Select(x => x.GetMetadata("FullPath")).ToList(), + BaseDirectory, + UseNestedNamespaces, + BaseNamespace, + UpdateReferences, + message => LogMessage(message: message)); return true; } } diff --git a/src/Fallout.MSBuildTasks/EmbedPackagesForSelfContainedTask.cs b/src/Fallout.MSBuildTasks/EmbedPackagesForSelfContainedTask.cs index 6304e11e..7becf2ed 100644 --- a/src/Fallout.MSBuildTasks/EmbedPackagesForSelfContainedTask.cs +++ b/src/Fallout.MSBuildTasks/EmbedPackagesForSelfContainedTask.cs @@ -1,10 +1,7 @@ -using System; -using System.IO; using System.Linq; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Fallout.Common.Tooling; -using Fallout.Common.Utilities; +using Fallout.MSBuildTasks.Engine; namespace Fallout.MSBuildTasks; @@ -21,11 +18,8 @@ public class EmbedPackagesForSelfContainedTask : ContextAwareTask protected override bool ExecuteInner() { - var packages = NuGetPackageResolver.GetLocalInstalledPackages(ProjectAssetsFile); - TargetOutputs = packages - .Where(x => !x.Id.StartsWithOrdinalIgnoreCase("microsoft.netcore.app.runtime")) - .Where(x => Directory.GetDirectories(x.Directory, "tools").Any()) - .Select(x => new TaskItem(x.File)).ToArray(); + TargetOutputs = PackageToolingEngine.GetEmbeddablePackageFiles(ProjectAssetsFile) + .Select(x => (ITaskItem)new TaskItem(x)).ToArray(); return true; } } diff --git a/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.csproj b/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.csproj index 4a9ebce1..1814d9ec 100644 --- a/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.csproj +++ b/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.csproj @@ -2,16 +2,16 @@ false - net10.0;net472 + + net10.0 - - - - - - + diff --git a/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.targets b/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.targets index dec28b91..16c62d7b 100644 --- a/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.targets +++ b/src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.targets @@ -12,6 +12,12 @@ --> $(NukeTasksAssembly) + + $(MSBuildThisFileDirectory)\Fallout.MSBuildTasks.Bridge.dll $(MSBuildThisFileDirectory)\$(MSBuildThisFileName).dll $(NukeContinueOnError) diff --git a/src/Fallout.MSBuildTasks/PackPackageToolsTask.cs b/src/Fallout.MSBuildTasks/PackPackageToolsTask.cs index 4de298be..2d4bc20f 100644 --- a/src/Fallout.MSBuildTasks/PackPackageToolsTask.cs +++ b/src/Fallout.MSBuildTasks/PackPackageToolsTask.cs @@ -1,11 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; using System.Linq; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Fallout.Common.Tooling; -using static Fallout.Common.IO.PathConstruction; +using Fallout.MSBuildTasks.Engine; namespace Fallout.MSBuildTasks; @@ -25,29 +21,15 @@ public class PackPackageToolsTask : ContextAwareTask protected override bool ExecuteInner() { - var packages = NuGetPackageResolver.GetLocalInstalledPackages(ProjectAssetsFile); - TargetOutputs = packages.SelectMany(x => GetFiles(x.Id, x.Version.ToString())).ToArray(); + TargetOutputs = PackageToolingEngine + .GetPackageToolFiles(ProjectAssetsFile, NuGetPackageRoot, TargetFramework) + .Select(x => + { + var item = new TaskItem(x.File); + item.SetMetadata("BuildAction", x.BuildAction); + item.SetMetadata("PackagePath", x.PackagePath); + return (ITaskItem)item; + }).ToArray(); return true; } - - private IEnumerable GetFiles(string packageId, string packageVersion) - { - var packageToolsDirectory = Path.Combine(NuGetPackageRoot, packageId.ToLowerInvariant(), packageVersion.ToLowerInvariant(), "tools"); - if (!Directory.Exists(packageToolsDirectory)) - yield break; - - foreach (var file in Directory.GetFiles(packageToolsDirectory, "*", SearchOption.AllDirectories)) - { - var taskItem = new TaskItem(file); - taskItem.SetMetadata("BuildAction", "None"); - taskItem.SetMetadata("PackagePath", - Path.Combine( - "tools", - TargetFramework, - "any", - packageId, - GetRelativePath(packageToolsDirectory, file))); - yield return taskItem; - } - } } diff --git a/src/Fallout.Tooling.Generator/Fallout.Tooling.Generator.csproj b/src/Fallout.Tooling.Generator/Fallout.Tooling.Generator.csproj index 8f9534c6..13d5ff46 100644 --- a/src/Fallout.Tooling.Generator/Fallout.Tooling.Generator.csproj +++ b/src/Fallout.Tooling.Generator/Fallout.Tooling.Generator.csproj @@ -1,7 +1,12 @@  - netstandard2.0 + + net10.0 Fallout.CodeGeneration diff --git a/src/Fallout.Tooling/Fallout.Tooling.csproj b/src/Fallout.Tooling/Fallout.Tooling.csproj index 2200aa63..f2ed1d26 100644 --- a/src/Fallout.Tooling/Fallout.Tooling.csproj +++ b/src/Fallout.Tooling/Fallout.Tooling.csproj @@ -1,7 +1,14 @@ - net10.0;netstandard2.0 + + net8.0;net10.0 diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs index 33249a2c..7107a814 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs @@ -1,4 +1,4 @@ -//HintName: Solution.g.cs +//HintName: Solution.g.cs // using Fallout.Persistence.Solution.Model; @@ -27,6 +27,10 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project Fallout_Migrate_Analyzers_Specs => this.GetProject("Fallout.Migrate.Analyzers.Specs"); public Fallout.Solutions.Project Fallout_Migrate_Specs => this.GetProject("Fallout.Migrate.Specs"); public Fallout.Solutions.Project Fallout_MSBuildTasks => this.GetProject("Fallout.MSBuildTasks"); + public Fallout.Solutions.Project Fallout_MSBuildTasks_Bridge => this.GetProject("Fallout.MSBuildTasks.Bridge"); + public Fallout.Solutions.Project Fallout_MSBuildTasks_Engine => this.GetProject("Fallout.MSBuildTasks.Engine"); + public Fallout.Solutions.Project Fallout_MSBuildTasks_Protocol => this.GetProject("Fallout.MSBuildTasks.Protocol"); + public Fallout.Solutions.Project Fallout_MSBuildTasks_Worker => this.GetProject("Fallout.MSBuildTasks.Worker"); public Fallout.Solutions.Project Fallout_Persistence_Solution => this.GetProject("Fallout.Persistence.Solution"); public Fallout.Solutions.Project Fallout_Persistence_Solution_Benchmarks => this.GetProject("Fallout.Persistence.Solution.Benchmarks"); public Fallout.Solutions.Project Fallout_ProjectModel => this.GetProject("Fallout.ProjectModel"); From 7220ab5ae5d00189101e9cc3cccc27deffa8aeac Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 20:42:53 +1200 Subject: [PATCH 06/10] Package the net472 bridge + net10 worker into the consumer build\netfx folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallout.Common (the consumer-facing build package) packed MSBuildTasks's net472 publish output into build\netfx; with MSBuildTasks now net10-only that glob is dead. Replace it with the net472 bridge plus the net10 worker (same folder, so the bridge finds Fallout.MSBuildTasks.Worker.dll beside itself). build\netcore still ships the in-proc net10 task. Also point the dogfood's inert FalloutTasksDirectory at the real net10 publish path (was a stale net8.0). NOTE: the consumer task path (especially full-framework VS) and `Pack` output are not exercised by this repo's build/test (the dogfood runs with FalloutTasksEnabled=False), and the net472/VS path can't be smoke-tested on non-Windows — both need a Windows + Pack verification before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- build/_build.csproj | 2 +- src/Fallout.Common/Fallout.Common.csproj | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/build/_build.csproj b/build/_build.csproj index cee68e3d..9eb83e5b 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -17,7 +17,7 @@ False - $(MSBuildThisFileDirectory)\..\src\Fallout.MSBuildTasks\bin\Debug\net8.0\publish + $(MSBuildThisFileDirectory)\..\src\Fallout.MSBuildTasks\bin\Debug\net10.0\publish diff --git a/src/Fallout.Common/Fallout.Common.csproj b/src/Fallout.Common/Fallout.Common.csproj index b0c922b1..c85f8dc5 100644 --- a/src/Fallout.Common/Fallout.Common.csproj +++ b/src/Fallout.Common/Fallout.Common.csproj @@ -35,8 +35,15 @@ + - + + + From d278f2a4f3da9fb124560494a805a229bf7d6a19 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 1 Jul 2026 10:12:58 +1200 Subject: [PATCH 07/10] Serialize the worker protocol with DataContractJsonSerializer so the net472 bridge loads The net472 bridge loads Fallout.MSBuildTasks.Protocol in-process inside full-framework MSBuild.exe (VS2019/2022). The Protocol serialized with System.Text.Json, whose packaged assembly version (10.0.0.8) never matches the compile-time reference (10.0.0.0); .NET Framework fusion needs an exact match or a host binding redirect, and MSBuild.exe has neither for STJ. The bridge threw FileNotFoundException in WorkerJson.Serialize before it could spawn the worker. .NET Core roll-forward masked this on the in-proc task and the worker, so only the Full path broke and CI (which runs FalloutTasksEnabled=False) missed it. Switch WorkerJson to the in-box DataContractJsonSerializer (System.Runtime.Serialization) and drop the System.Text.Json PackageReference. The DTOs are trivial POCOs that round-trip cleanly, both ends share WorkerJson so the wire format stays symmetric, and STJ no longer ships in build/netfx. This makes the bridge genuinely "Microsoft.Build.* + BCL only" as ADR-0009 intends. Verified: dotnet (Core), VS2019 and VS2022 (Full) now produce byte-identical codegen output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Fallout.MSBuildTasks.Protocol.csproj | 10 +++---- .../WorkerProtocol.cs | 30 +++++++++++++++---- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj b/src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj index d3840258..0b93b71a 100644 --- a/src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj +++ b/src/Fallout.MSBuildTasks.Protocol/Fallout.MSBuildTasks.Protocol.csproj @@ -6,12 +6,10 @@ - - - diff --git a/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs b/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs index 86a987d5..5a5284ed 100644 --- a/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs +++ b/src/Fallout.MSBuildTasks.Protocol/WorkerProtocol.cs @@ -1,4 +1,6 @@ -using System.Text.Json; +using System.IO; +using System.Runtime.Serialization.Json; +using System.Text; namespace Fallout.MSBuildTasks.Protocol; @@ -10,14 +12,30 @@ public static class WorkerVerbs public const string PackTools = "pack-tools"; } -/// Shared (de)serialization so both ends use identical options. +/// +/// Shared (de)serialization so both ends use identical wire format. Uses the in-box +/// rather than System.Text.Json: the bridge loads this +/// assembly in-process into full-framework MSBuild.exe, where an STJ package reference can't bind +/// without a host binding redirect (the packaged STJ asm version never matches the compile-time +/// reference). DataContractJsonSerializer resolves to the framework's in-box +/// System.Runtime.Serialization on net472 — no NuGet dependency, no redirect. See ADR-0009. +/// public static class WorkerJson { - private static readonly JsonSerializerOptions Options = new() { WriteIndented = false }; - - public static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + public static string Serialize(T value) + { + var serializer = new DataContractJsonSerializer(typeof(T)); + using var stream = new MemoryStream(); + serializer.WriteObject(stream, value); + return Encoding.UTF8.GetString(stream.ToArray()); + } - public static T Deserialize(string json) => JsonSerializer.Deserialize(json, Options); + public static T Deserialize(string json) + { + var serializer = new DataContractJsonSerializer(typeof(T)); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + return (T)serializer.ReadObject(stream); + } } public sealed class CodegenRequest From 8439b41614b79e5fa89e34fdb8de3327181eb480 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 1 Jul 2026 10:13:07 +1200 Subject: [PATCH 08/10] Add MSBuild bridge smoke-test runbook Documents how to verify the full-framework (VS2019/2022) MSBuild bridge from ADR-0009: which MSBuild runtime exercises which path, the homelab VM spec, VS version currency, the exact repro steps, and the System.Text.Json binding defect plus its fix. This path is Windows-only and not covered by CI (the dogfood runs FalloutTasksEnabled=False), so it needs a manual smoke test. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agents/msbuild-bridge-smoke-test.md | 120 +++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/agents/msbuild-bridge-smoke-test.md diff --git a/docs/agents/msbuild-bridge-smoke-test.md b/docs/agents/msbuild-bridge-smoke-test.md new file mode 100644 index 00000000..6b95320c --- /dev/null +++ b/docs/agents/msbuild-bridge-smoke-test.md @@ -0,0 +1,120 @@ +# MSBuild bridge smoke test (PR #447 / ADR-0009) + +Runbook for verifying the **full-framework MSBuild bridge** added by [ADR-0009](../adr/0009-lift-ns20-floor-via-msbuild-bridge.md) +(branch `tooling/lift-ns20-floor`). CI does **not** cover this path — the repo dogfood runs +`FalloutTasksEnabled=False`, and the bridge only runs under full-framework `MSBuild.exe`, which is +**Windows-only**. So it needs a manual, Windows + VS smoke test. + +## What exercises which path + +The `.targets` select by `$(MSBuildRuntimeType)`: + +| Driver | RuntimeType | Path | Covered by | +|---|---|---|---| +| `dotnet` / SDK MSBuild | `Core` | in-proc net10 task (`build/netcore`) | `dotnet pack`, CI | +| VS 2019 / 2022 `MSBuild.exe` | `Full` | **net472 bridge → net10 worker** (`build/netfx`) | **this runbook only** | +| VS 2026 (v18) `MSBuild.exe` | `Core` | in-proc net10 task | same as `dotnet` | + +**VS 2026 does not exercise the bridge** — its MSBuild is .NET-based, so it takes the in-proc path. +The bridge is reachable *only* via full-framework MSBuild (VS 2019/2022 or Build Tools). Per the ADR +the bridge is **time-boxed**: delete it once consumers are off full-framework MSBuild. + +## VS version currency (as of 2026-07) + +| Version | Status | Bridge? | +|---|---|---| +| VS 2026 (v18) | current | no (Core) | +| VS 2022 (v17) | prior gen, widely used | **yes** | +| VS 2019 (v16) | legacy, near EOL; its MSBuild can't resolve the net10 SDK | **yes** | +| VS for Mac | **retired 2024-08-31**; never had full-framework MSBuild | n/a | + +## Homelab VM spec + +A parked Windows image, spun up only when touching the bridge / MSBuild-task code: + +- Windows 10/11 or Windows Server. +- **VS Build Tools 2022** with the `Microsoft.Component.MSBuild` workload — the BuildTools SKU is + enough; full VS is not needed. (Optionally Build Tools 2019 too, for the oldest supported path.) +- The pinned **.NET SDK** (`global.json`, currently `10.0.100`) — provides `dotnet` for the worker. +- `git`. + +Does **not** need VS 2026 — that only re-tests the `Core` path the dev box's `dotnet` already covers. + +## How to run + +From a worktree on the PR branch (`MSBuild.exe` runs must use PowerShell, not Git Bash — Bash +mangles `/switch` args and `/c/...` paths): + +```powershell +# 1. Publish the three task projects (Release) +dotnet publish src/Fallout.MSBuildTasks/Fallout.MSBuildTasks.csproj -c Release +dotnet publish src/Fallout.MSBuildTasks.Bridge/Fallout.MSBuildTasks.Bridge.csproj -c Release +dotnet publish src/Fallout.MSBuildTasks.Worker/Fallout.MSBuildTasks.Worker.csproj -c Release +dotnet build src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj -c Release + +# 2. Pack Fallout.Common (gate on _IsPacking) and extract its build/ assets +dotnet pack src/Fallout.Common/Fallout.Common.csproj -c Release -p:_IsPacking=true -o +# unzip \Fallout.Common.*.nupkg -> , giving \build\{netcore,netfx} + +# 3. Consumer that imports \build\Fallout.Common.{props,targets}, sets a tool spec in +# @(FalloutSpecificationFiles), and runs the FalloutCodeGeneration target. A non-SDK .proj +# lets BOTH VS2019 and VS2022 evaluate it (VS2019's MSBuild can't resolve the net10 SDK). + +$env:DOTNET_HOST_PATH = "C:\Program Files\dotnet\dotnet.exe" # so the bridge can launch the worker +& "\MSBuild\Current\Bin\MSBuild.exe" Consumer.proj -t:FalloutCodeGeneration -p:PkgBuild=\build +``` + +**Pass criteria:** the `Full`-path runs produce the *same* generated output as `dotnet` (`Core`), +with the worker process actually spawned and no load/protocol errors. + +## Result — 2026-07-01 (commit 7220ab5a) + +| Driver | Outcome | +|---|---| +| `dotnet` (Core) | ✅ generated valid `Git.Generated.cs` (in-proc net10 task) | +| VS 2022 `MSBuild.exe` (Full) | ❌ bridge crashes before spawning the worker | +| VS 2019 `MSBuild.exe` (Full) | ❌ identical crash | + +**Defect — the net472 bridge can't load `System.Text.Json`.** + +``` +error MSB6003: System.IO.FileNotFoundException: Could not load file or assembly +'System.Text.Json, Version=10.0.0.0, ... PublicKeyToken=cc7b13ffcd2ddd51' + at Fallout.MSBuildTasks.Protocol.WorkerJson.Serialize[T] + at CodeGenerationTask.BuildRequestJson() -> WorkerBridgeTask.GenerateCommandLineCommands() +``` + +Root cause: +- `Fallout.MSBuildTasks.Protocol` (`WorkerProtocol.cs`) serializes with **`System.Text.Json`**. +- The packaged STJ is assembly version **`10.0.0.8`**; the code references **`10.0.0.0`**. +- The bridge runs **in-process inside .NET-Framework `MSBuild.exe`**, where fusion needs an exact + version or a binding redirect. There is none (and a `Bridge.dll.config` redirect would not help — + fusion only reads the host `MSBuild.exe.config` for in-proc assemblies). On .NET Core both the + in-proc task and the worker bind fine via roll-forward, which is why only `Full` fails. +- This contradicts ADR-0009 §3 ("the bridge references **only** `Microsoft.Build.*` + the BCL — + zero Fallout dependencies"). The Protocol's STJ dependency reintroduces exactly the version- + sensitive in-proc dependency the design set out to avoid. + +**Fix direction:** make `WorkerJson` framework-safe so the net472 bridge carries no redirect- +sensitive dependency — e.g. serialize the (trivial) DTOs with the in-box `DataContractJsonSerializer`, +or hand-roll, keeping STJ (if at all) on the net10 worker side only. The blocker is the bridge's +*outbound* serialization (`BuildRequestJson`); the worker (net10) is fine. + +### Fixed — 2026-07-01 + +`WorkerJson` (`Fallout.MSBuildTasks.Protocol`) now uses the in-box `DataContractJsonSerializer` +(`System.Runtime.Serialization`) instead of `System.Text.Json`, and the STJ `PackageReference` is +removed from the Protocol project. STJ no longer ships in `build/netfx`, so the net472 bridge loads +with no redirect-sensitive dependency. Re-run: + +| Driver | Outcome | +|---|---| +| `dotnet` (Core) | ✅ `Git.Generated.cs` | +| VS 2022 `MSBuild.exe` (Full) | ✅ bridge → worker, output **byte-identical** to Core | +| VS 2019 `MSBuild.exe` (Full) | ✅ identical | + +All three generated files share one SHA256 — behaviour parity (ADR-0009 §"Consequences") holds. + +> No automated tests cover the bridge/worker serialization — this smoke test is the only coverage. +> A `WorkerJson` round-trip unit test in a `Fallout.MSBuildTasks.Protocol.Specs` sibling would catch +> a regression without the manual VS run. From bb62e04d6477c9a37559442e463250c88d18ce1d Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 1 Jul 2026 10:17:37 +1200 Subject: [PATCH 09/10] Add worker-protocol specs: round-trip + no-System.Text.Json dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the wire contract the net472 bridge and net10 worker share via WorkerJson: - round-trip each DTO (CodegenRequest, EmbedPackagesRequest, PackToolsRequest, WorkerResponse, including empty/default collections) through DataContractJsonSerializer; - assert the Protocol assembly references no System.Text.Json — the regression guard for the bridge binding failure (STJ can't load in-process under full-framework MSBuild.exe). A round-trip test alone can't catch that (net10 roll-forward and the test host's binding redirects both mask it); the dependency invariant does, cross-platform. End-to-end bridge behaviour stays covered by the Windows-only smoke test in docs/agents/msbuild-bridge-smoke-test.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- fallout.slnx | 1 + ...Fallout.MSBuildTasks.Protocol.Specs.csproj | 11 ++ .../WorkerProtocolSpecs.cs | 100 ++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/Fallout.MSBuildTasks.Protocol.Specs/Fallout.MSBuildTasks.Protocol.Specs.csproj create mode 100644 tests/Fallout.MSBuildTasks.Protocol.Specs/WorkerProtocolSpecs.cs diff --git a/fallout.slnx b/fallout.slnx index 5af87cc8..7d68a032 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -46,6 +46,7 @@ + diff --git a/tests/Fallout.MSBuildTasks.Protocol.Specs/Fallout.MSBuildTasks.Protocol.Specs.csproj b/tests/Fallout.MSBuildTasks.Protocol.Specs/Fallout.MSBuildTasks.Protocol.Specs.csproj new file mode 100644 index 00000000..762eaa0c --- /dev/null +++ b/tests/Fallout.MSBuildTasks.Protocol.Specs/Fallout.MSBuildTasks.Protocol.Specs.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/tests/Fallout.MSBuildTasks.Protocol.Specs/WorkerProtocolSpecs.cs b/tests/Fallout.MSBuildTasks.Protocol.Specs/WorkerProtocolSpecs.cs new file mode 100644 index 00000000..3ecc512e --- /dev/null +++ b/tests/Fallout.MSBuildTasks.Protocol.Specs/WorkerProtocolSpecs.cs @@ -0,0 +1,100 @@ +using System.Linq; +using FluentAssertions; +using Xunit; + +namespace Fallout.MSBuildTasks.Protocol.Specs; + +/// +/// The wire contract between the net472 bridge and the net10 worker. Both ends share +/// , so these guard the two things that can break the bridge: +/// (1) the DTOs must survive a serialize→deserialize round-trip, and (2) the Protocol must carry +/// no System.Text.Json dependency — STJ can't bind when the bridge is loaded in-process into +/// full-framework MSBuild.exe (no host binding redirect). See ADR-0009 and +/// docs/agents/msbuild-bridge-smoke-test.md. +/// +public class WorkerProtocolSpecs +{ + [Fact] + public void CodegenRequest_round_trips() + { + var original = new CodegenRequest + { + SpecificationFiles = ["a/Tool.json", "b/Other.json"], + BaseDirectory = @"C:\src\gen", + UseNestedNamespaces = true, + BaseNamespace = "My.Tools", + UpdateReferences = false, + }; + + var roundTripped = WorkerJson.Deserialize(WorkerJson.Serialize(original)); + + roundTripped.Should().BeEquivalentTo(original); + } + + [Fact] + public void EmbedPackagesRequest_round_trips() + { + var original = new EmbedPackagesRequest { ProjectAssetsFile = @"obj\project.assets.json" }; + + var roundTripped = WorkerJson.Deserialize(WorkerJson.Serialize(original)); + + roundTripped.Should().BeEquivalentTo(original); + } + + [Fact] + public void PackToolsRequest_round_trips() + { + var original = new PackToolsRequest + { + ProjectAssetsFile = @"obj\project.assets.json", + NuGetPackageRoot = @"C:\Users\x\.nuget\packages", + TargetFramework = "net10.0", + }; + + var roundTripped = WorkerJson.Deserialize(WorkerJson.Serialize(original)); + + roundTripped.Should().BeEquivalentTo(original); + } + + [Fact] + public void WorkerResponse_with_data_round_trips() + { + var original = new WorkerResponse + { + Files = ["pkg/one.dll", "pkg/two.dll"], + ToolFiles = + [ + new PackagedToolFileDto + { + File = "tool.dll", + BuildAction = "Content", + PackagePath = "tools/net10.0/any/tool.dll", + }, + ], + }; + + var roundTripped = WorkerJson.Deserialize(WorkerJson.Serialize(original)); + + roundTripped.Should().BeEquivalentTo(original); + } + + [Fact] + public void WorkerResponse_defaults_round_trip_to_empty_collections() + { + var roundTripped = WorkerJson.Deserialize(WorkerJson.Serialize(new WorkerResponse())); + + roundTripped.Files.Should().BeEmpty(); + roundTripped.ToolFiles.Should().BeEmpty(); + } + + [Fact] + public void Protocol_has_no_System_Text_Json_dependency() + { + var referenced = typeof(WorkerJson).Assembly.GetReferencedAssemblies().Select(x => x.Name); + + referenced.Should().NotContain( + "System.Text.Json", + because: "the net472 bridge loads this assembly in-process into full-framework MSBuild.exe, " + + "where STJ can't bind without a host binding redirect (ADR-0009)"); + } +} From 5c171d48f50fbf6ddf2ce508a496842df0bf5944 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 1 Jul 2026 10:23:18 +1200 Subject: [PATCH 10/10] Update solution-generator snapshot for the new Protocol.Specs project Adding Fallout.MSBuildTasks.Protocol.Specs to fallout.slnx adds one project to the StronglyTypedSolutionGenerator output; refresh the Verify snapshot to match (one new line). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs index 7107a814..e74f6daf 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs @@ -30,6 +30,7 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project Fallout_MSBuildTasks_Bridge => this.GetProject("Fallout.MSBuildTasks.Bridge"); public Fallout.Solutions.Project Fallout_MSBuildTasks_Engine => this.GetProject("Fallout.MSBuildTasks.Engine"); public Fallout.Solutions.Project Fallout_MSBuildTasks_Protocol => this.GetProject("Fallout.MSBuildTasks.Protocol"); + public Fallout.Solutions.Project Fallout_MSBuildTasks_Protocol_Specs => this.GetProject("Fallout.MSBuildTasks.Protocol.Specs"); public Fallout.Solutions.Project Fallout_MSBuildTasks_Worker => this.GetProject("Fallout.MSBuildTasks.Worker"); public Fallout.Solutions.Project Fallout_Persistence_Solution => this.GetProject("Fallout.Persistence.Solution"); public Fallout.Solutions.Project Fallout_Persistence_Solution_Benchmarks => this.GetProject("Fallout.Persistence.Solution.Benchmarks");