From 59328f8f09329383d88ee6dae1f38444141b7b29 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 21 Jul 2026 14:17:16 +1200 Subject: [PATCH 1/3] Map Common.ProjectModel to Fallout.Solutions in the CLI migrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallout-migrate CLI rewriter only swapped the `Nuke.` prefix, so a NUKE-era `using Nuke.Common.ProjectModel;` landed on the now-dead `Fallout.Common.ProjectModel` namespace — the solution types moved to `Fallout.Solutions` in #257. Add a rule mapping both the Nuke and interim Fallout `Common.ProjectModel` namespaces to `Fallout.Solutions`, run before the generic prefix swap so each reference migrates in a single edit. Mirrors the analyzer codefix from #253, which never reached the CLI tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Migrate/Steps/CodeRewriter.cs | 24 +++++++++-- .../CodeRewriterSpecs.cs | 41 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/Fallout.Migrate/Steps/CodeRewriter.cs b/src/Fallout.Migrate/Steps/CodeRewriter.cs index d90858e38..2549f771a 100644 --- a/src/Fallout.Migrate/Steps/CodeRewriter.cs +++ b/src/Fallout.Migrate/Steps/CodeRewriter.cs @@ -4,12 +4,22 @@ namespace Fallout.Migrate.Steps; /// -/// Rewrites .cs files: Nuke.* namespace prefixes become Fallout., and the bare -/// NukeBuild/INukeBuild types become FalloutBuild/IFalloutBuild. -/// Driven by . +/// Rewrites .cs files: Nuke.* namespace prefixes become Fallout., the bare +/// NukeBuild/INukeBuild types become FalloutBuild/IFalloutBuild, and the +/// solution-model namespace (which moved out of *.Common.ProjectModel in v11) becomes +/// Fallout.Solutions. Driven by . /// internal static class CodeRewriter { + // The solution types moved from `(Nuke|Fallout).Common.ProjectModel` to the + // dedicated `Fallout.Solutions` namespace in v11 (#248 + onion layering). + // Run this BEFORE the generic prefix swap so a NUKE-era reference lands on + // the canonical v11 namespace in one edit instead of the now-dead + // `Fallout.Common.ProjectModel`. Matching both source prefixes also fixes + // already-partially-migrated code. Mirrors the codefix mapping from #253. + private static readonly Regex projectModelNamespace = + new(@"\b(?:Nuke|Fallout)\.Common\.ProjectModel\b", RegexOptions.Compiled); + // Anchored prefix swap: `\bNuke\.` → `Fallout.`. Covers using directives, // attribute references, qualified type names, namespace declarations. // The trailing `(?=[A-Z])` lookahead avoids matching `Nuke.json` filenames @@ -31,7 +41,13 @@ public static RewriteResult Rewrite(string original) { var edits = 0; - var content = namespacePrefix.Replace(original, _ => + var content = projectModelNamespace.Replace(original, _ => + { + edits++; + return "Fallout.Solutions"; + }); + + content = namespacePrefix.Replace(content, _ => { edits++; return "Fallout."; diff --git a/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs index 37bab7dcc..d156a60c9 100644 --- a/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs +++ b/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs @@ -67,4 +67,45 @@ public void DoesNotMatchLowercaseNukePrefix() var result = CodeRewriter.Rewrite(input); result.EditCount.Should().Be(0); } + + [Fact] + public void RewritesNukeProjectModelUsingToSolutions() + { + // The solution types moved to Fallout.Solutions in v11 — a NUKE-era + // `using` must land there, not on the dead Fallout.Common.ProjectModel. + const string input = "using Nuke.Common.ProjectModel;"; + var result = CodeRewriter.Rewrite(input); + result.EditCount.Should().Be(1); + result.Content.Should().Be("using Fallout.Solutions;"); + } + + [Fact] + public void RewritesQualifiedNukeProjectModelTypeToSolutions() + { + const string input = "Nuke.Common.ProjectModel.Solution x;"; + var result = CodeRewriter.Rewrite(input); + result.EditCount.Should().Be(1); + result.Content.Should().Be("Fallout.Solutions.Solution x;"); + } + + [Fact] + public void RewritesAlreadyPartiallyMigratedProjectModelNamespace() + { + // Code previously run through a prefix-only migrator lands on the dead + // Fallout.Common.ProjectModel; the ProjectModel rule salvages it. + const string input = "using Fallout.Common.ProjectModel;"; + var result = CodeRewriter.Rewrite(input); + result.EditCount.Should().Be(1); + result.Content.Should().Be("using Fallout.Solutions;"); + } + + [Fact] + public void DoesNotMatchProjectModelAsPartOfAnotherIdentifier() + { + // `ProjectModelFoo` must not be truncated by the ProjectModel rule. + const string input = "using Nuke.Common.ProjectModelFoo;"; + var result = CodeRewriter.Rewrite(input); + result.EditCount.Should().Be(1); + result.Content.Should().Be("using Fallout.Common.ProjectModelFoo;"); + } } From 46c537fe80032041acab58381fd6d586b44ad77f Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 21 Jul 2026 14:17:16 +1200 Subject: [PATCH 2/3] Add Fallout.Common.ProjectModel entry-point transition shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The solution types shipped under `Fallout.Common.ProjectModel` in Fallout 11.0.1–11.0.12 and moved to `Fallout.Solutions` in 11.0.13 (#257), breaking that upgrade with no shim. Restore the common entry-point pattern (`[Solution] readonly Solution Solution;`) by mirroring `Solution` + `SolutionAttribute` back into the old namespace inside Fallout.Common. Shallow by design — deep graph navigation, GenerateProjects source-gen, and binary compat still require `fallout-migrate` — matching the ceiling of the generated `Nuke.Common.ProjectModel` shim. A new compile-time consumer sentinel (Fallout.Consumer.ProjectModelShim, in fallout.slnx) fails CI if the shim regresses. Co-Authored-By: Claude Opus 4.8 (1M context) --- fallout.slnx | 1 + .../ProjectModel/TransitionShims.cs | 54 +++++++++++++++++++ .../Build.cs | 24 +++++++++ .../Fallout.Consumer.ProjectModelShim.csproj | 29 ++++++++++ 4 files changed, 108 insertions(+) create mode 100644 src/Fallout.Common/ProjectModel/TransitionShims.cs create mode 100644 tests/Consumers/Fallout.Consumer.ProjectModelShim/Build.cs create mode 100644 tests/Consumers/Fallout.Consumer.ProjectModelShim/Fallout.Consumer.ProjectModelShim.csproj diff --git a/fallout.slnx b/fallout.slnx index d954d9003..c994a30a5 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -51,6 +51,7 @@ + diff --git a/src/Fallout.Common/ProjectModel/TransitionShims.cs b/src/Fallout.Common/ProjectModel/TransitionShims.cs new file mode 100644 index 000000000..1de3d7348 --- /dev/null +++ b/src/Fallout.Common/ProjectModel/TransitionShims.cs @@ -0,0 +1,54 @@ +using Fallout.Common.IO; +using Fallout.Persistence.Solution.Model; + +namespace Fallout.Common.ProjectModel; + +// Transition shims for the solution entry-point types, which shipped under +// `Fallout.Common.ProjectModel` in Fallout 11.0.1–11.0.12 and moved to the +// dedicated `Fallout.Solutions` namespace in 11.0.13 (#248 / #257). Mirroring the +// pair below keeps the canonical consumer pattern +// +// using Fallout.Common.ProjectModel; +// [Solution] readonly Solution Solution; +// +// compiling for consumers upgrading from those releases, without their having to +// run `fallout-migrate` first. +// +// SHALLOW BY DESIGN. This is an entry-point grace period, not a full alias of the +// old namespace. Navigating the graph (e.g. `solution.Projects`) hands back +// canonical `Fallout.Solutions.*` instances, so intermediate variables typed +// against `Fallout.Common.ProjectModel` will not bind, and `GenerateProjects` +// source-generation keys off the canonical attribute type and won't fire through +// the shim. `fallout-migrate` (or the Nuke→Fallout codefix) is the complete +// migration path — it rewrites every reference, shallow and deep. This mirrors the +// same ceiling the generated `Nuke.Common.ProjectModel` transition shim has. + +/// +/// Transition shim for the relocated . See the file-level +/// remarks — shallow by design; run fallout-migrate for a complete rewrite. +/// +public class Solution(SolutionModel model, AbsolutePath path = null) + : global::Fallout.Solutions.Solution(model, path) +{ + // C# does not inherit user-defined conversion operators onto a subclass, so the + // canonical Solution's string / AbsolutePath coercions would silently disappear + // on the shim. Re-expose them so `string s = Solution;` and `AbsolutePath p = Solution;` + // keep working. The parameter type (shim Solution) is more specific than the + // canonical's, so these win over the inherited operators without ambiguity. + public static implicit operator string(Solution solution) => (global::Fallout.Solutions.Solution)solution; + public static implicit operator AbsolutePath(Solution solution) => (global::Fallout.Solutions.Solution)solution; +} + +/// +/// Transition shim for the relocated . Deserializes +/// into the annotated member's declared type, so [Solution] readonly Solution Solution; against +/// the shim above resolves correctly. +/// +public class SolutionAttribute(string relativePath) + : global::Fallout.Solutions.SolutionAttribute(relativePath) +{ + public SolutionAttribute() + : this(relativePath: null) + { + } +} diff --git a/tests/Consumers/Fallout.Consumer.ProjectModelShim/Build.cs b/tests/Consumers/Fallout.Consumer.ProjectModelShim/Build.cs new file mode 100644 index 000000000..fa1f82c03 --- /dev/null +++ b/tests/Consumers/Fallout.Consumer.ProjectModelShim/Build.cs @@ -0,0 +1,24 @@ +// +// Pre-rename Fallout consumer pattern (Fallout 11.0.1–11.0.12), compiled against +// the Fallout.Common.ProjectModel transition shim. If a Build.cs from those +// releases stops compiling against the latest Fallout, this fails — protecting +// users upgrading across the SolutionModel → Solution / Fallout.Solutions rename. + +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.ProjectModel; // the interim namespace, now served by the transition shim + +internal class Build : FalloutBuild +{ + public static int Main() => Execute(x => x.Default); + + [Solution] + private readonly Solution Solution; + + private Target Default => _ => _ + .Executes(() => + { + Serilog.Log.Information("hello from fallout consumer (Fallout.Common.ProjectModel shim)"); + Serilog.Log.Information("solution name: {Name}", Solution?.Name ?? ""); + }); +} diff --git a/tests/Consumers/Fallout.Consumer.ProjectModelShim/Fallout.Consumer.ProjectModelShim.csproj b/tests/Consumers/Fallout.Consumer.ProjectModelShim/Fallout.Consumer.ProjectModelShim.csproj new file mode 100644 index 000000000..6080d9cdb --- /dev/null +++ b/tests/Consumers/Fallout.Consumer.ProjectModelShim/Fallout.Consumer.ProjectModelShim.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + Exe + false + Fallout.Consumer.ProjectModelShim + $(NoWarn);CS0649 + + + + + + + + + + + From b6469a33c5ec30f0e251af19e5f6e0a93193257b Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 21 Jul 2026 17:28:55 +1200 Subject: [PATCH 3/3] Stop the ProjectModel entry-point shim colliding with the Nuke.Common shim generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new Fallout.Common.ProjectModel.Solution/SolutionAttribute entry-point shims are public types under Fallout.Common, so the Nuke.Common shim's 'Fallout.Common -> Nuke.Common' generator rule swept them up and emitted Nuke.Common.ProjectModel.Solution — which the 'Fallout.Solutions -> Nuke.Common.ProjectModel' rule also emits from the canonical type. Two partial declarations with different base classes → CS0263/CS0111, failing Compile. Give ShimAllPublicTypesUnder an ExceptNamespacePrefixes option and exclude Fallout.Common.ProjectModel from the broad Fallout.Common rule. Those types relocated to Fallout.Solutions in v11 and are shimmed by the dedicated rule; the Fallout.Common.ProjectModel re-exports are a Fallout-side grace shim that must not be re-shimmed into Nuke. Regenerated the affected Verify snapshots (attribute surface + the solution generator picking up the new sentinel consumer project #528 adds). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TransitionShimGenerator.cs | 51 +++++++++++++++++-- src/Shims/Nuke.Common/ShimMarker.cs | 9 +++- ...led_code_generation#Solution.g.verified.cs | 1 + ...n_with_fancy_naming#Solution.g.verified.cs | 1 + ...AllPublicTypesUnderAttribute.g.verified.cs | 8 +++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/Fallout.SourceGenerators/TransitionShimGenerator.cs b/src/Fallout.SourceGenerators/TransitionShimGenerator.cs index a2eab8a93..0736dac58 100644 --- a/src/Fallout.SourceGenerators/TransitionShimGenerator.cs +++ b/src/Fallout.SourceGenerators/TransitionShimGenerator.cs @@ -90,13 +90,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private readonly struct ShimMarker { - public ShimMarker(string fromPrefix, string toPrefix) + public ShimMarker(string fromPrefix, string toPrefix, ImmutableArray exceptPrefixes) { FromPrefix = fromPrefix; ToPrefix = toPrefix; + ExceptPrefixes = exceptPrefixes; } public string FromPrefix { get; } public string ToPrefix { get; } + + // Sub-namespaces under FromPrefix to leave unshimmed — e.g. a namespace + // whose types relocated elsewhere and are shimmed by a different marker. + // Without this, two markers can emit the same target shim type. + public ImmutableArray ExceptPrefixes { get; } } private static ImmutableArray ExtractMarkers(ISymbol target) @@ -114,7 +120,20 @@ private static ImmutableArray ExtractMarkers(ISymbol target) var to = attr.ConstructorArguments[1].Value as string; if (string.IsNullOrEmpty(from) || string.IsNullOrEmpty(to)) continue; - results.Add(new ShimMarker(from!, to!)); + + var except = ImmutableArray.Empty; + foreach (var named in attr.NamedArguments) + { + if (named.Key != "ExceptNamespacePrefixes" || named.Value.IsNull) + continue; + except = named.Value.Values + .Select(v => v.Value as string) + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => s!) + .ToImmutableArray(); + } + + results.Add(new ShimMarker(from!, to!, except)); } return results.ToImmutable(); } @@ -192,7 +211,7 @@ private static void CountFqnsInNamespace(INamespaceSymbol ns, ShimMarker marker, if (string.IsNullOrEmpty(fullNamespace)) continue; var matches = fullNamespace == marker.FromPrefix || fullNamespace.StartsWith(marker.FromPrefix + ".", StringComparison.Ordinal); - if (!matches) continue; + if (!matches || IsUnderExcludedPrefix(fullNamespace, marker)) continue; var fqn = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); counts[fqn] = counts.TryGetValue(fqn, out var existing) ? existing + 1 : 1; } @@ -213,7 +232,7 @@ private static void VisitNamespace(SourceProductionContext ctx, INamespaceSymbol if (string.IsNullOrEmpty(fullNamespace)) continue; var matches = fullNamespace == marker.FromPrefix || fullNamespace.StartsWith(marker.FromPrefix + ".", StringComparison.Ordinal); - if (!matches) continue; + if (!matches || IsUnderExcludedPrefix(fullNamespace, marker)) continue; EmitOrSkipType(ctx, type, marker, emittedHints, ambiguousFqns, handBridgedFqns); } @@ -532,6 +551,22 @@ private static string SwapNamespace(string original, ShimMarker marker) return marker.ToPrefix + original.Substring(marker.FromPrefix.Length); } + // True when the namespace sits under one of the marker's excluded prefixes + // (the prefix itself or a sub-namespace of it). Such types are left unshimmed + // by this marker — typically because another marker owns their shim. + private static bool IsUnderExcludedPrefix(string fullNamespace, ShimMarker marker) + { + if (marker.ExceptPrefixes.IsDefaultOrEmpty) + return false; + foreach (var except in marker.ExceptPrefixes) + { + if (fullNamespace == except + || fullNamespace.StartsWith(except + ".", StringComparison.Ordinal)) + return true; + } + return false; + } + private static string FormatGenericParameters(INamedTypeSymbol type) { if (type.TypeParameters.IsDefaultOrEmpty || type.TypeParameters.Length == 0) @@ -684,6 +719,14 @@ public ShimAllPublicTypesUnderAttribute(string fromNamespacePrefix, string toNam public string FromNamespacePrefix { get; } public string ToNamespacePrefix { get; } + + /// + /// Sub-namespaces under fromNamespacePrefix to leave unshimmed, + /// e.g. one whose types relocated to another namespace that a separate + /// marker already shims. Prevents two markers emitting the same target + /// shim type. + /// + public string[]? ExceptNamespacePrefixes { get; set; } } """; } diff --git a/src/Shims/Nuke.Common/ShimMarker.cs b/src/Shims/Nuke.Common/ShimMarker.cs index 0f314f14e..9c643102c 100644 --- a/src/Shims/Nuke.Common/ShimMarker.cs +++ b/src/Shims/Nuke.Common/ShimMarker.cs @@ -5,9 +5,16 @@ // the Fallout.Common namespace despite being declared in the Fallout.Build // project). +// ProjectModel is excluded here: those solution types relocated to +// Fallout.Solutions in v11 and are shimmed by the marker below. Fallout.Common +// also re-exports Solution/SolutionAttribute under Fallout.Common.ProjectModel as +// a Fallout-side entry-point grace shim (#257 mitigation) — without this +// exclusion, Rule 1 and the Fallout.Solutions rule would both emit +// Nuke.Common.ProjectModel.Solution, colliding (CS0263/CS0111). [assembly: Fallout.Migrate.Shims.ShimAllPublicTypesUnder( fromNamespacePrefix: "Fallout.Common", - toNamespacePrefix: "Nuke.Common")] + toNamespacePrefix: "Nuke.Common", + ExceptNamespacePrefixes = new[] { "Fallout.Common.ProjectModel" })] // The solution-handling types moved from Fallout.Common.ProjectModel to the // dedicated Fallout.Solutions namespace in v11 (see #248 and the broader diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation#Solution.g.verified.cs index 22e095752..01f7da782 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation#Solution.g.verified.cs @@ -20,6 +20,7 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project Fallout_Components_Specs => this.GetProject("Fallout.Components.Specs"); public Fallout.Solutions.Project Fallout_Consumer_Local => this.GetProject("Fallout.Consumer.Local"); public Fallout.Solutions.Project Fallout_Consumer_NuGet => this.GetProject("Fallout.Consumer.NuGet"); + public Fallout.Solutions.Project Fallout_Consumer_ProjectModelShim => this.GetProject("Fallout.Consumer.ProjectModelShim"); public Fallout.Solutions.Project Fallout_Core => this.GetProject("Fallout.Core"); public Fallout.Solutions.Project Fallout_Core_Specs => this.GetProject("Fallout.Core.Specs"); public Fallout.Solutions.Project Fallout_Migrate => this.GetProject("Fallout.Migrate"); diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation_with_fancy_naming#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation_with_fancy_naming#Solution.g.verified.cs index aec0fb68b..cf958a75b 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation_with_fancy_naming#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Enabled_code_generation_with_fancy_naming#Solution.g.verified.cs @@ -20,6 +20,7 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project FalloutꞏComponentsꞏSpecs => this.GetProject("Fallout.Components.Specs"); public Fallout.Solutions.Project FalloutꞏConsumerꞏLocal => this.GetProject("Fallout.Consumer.Local"); public Fallout.Solutions.Project FalloutꞏConsumerꞏNuGet => this.GetProject("Fallout.Consumer.NuGet"); + public Fallout.Solutions.Project FalloutꞏConsumerꞏProjectModelShim => this.GetProject("Fallout.Consumer.ProjectModelShim"); public Fallout.Solutions.Project FalloutꞏCore => this.GetProject("Fallout.Core"); public Fallout.Solutions.Project FalloutꞏCoreꞏSpecs => this.GetProject("Fallout.Core.Specs"); public Fallout.Solutions.Project FalloutꞏMigrate => this.GetProject("Fallout.Migrate"); diff --git a/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.EmitsShimsForEachKindAndSkipsHardTier#ShimAllPublicTypesUnderAttribute.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.EmitsShimsForEachKindAndSkipsHardTier#ShimAllPublicTypesUnderAttribute.g.verified.cs index 274831710..f48c1f7fe 100644 --- a/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.EmitsShimsForEachKindAndSkipsHardTier#ShimAllPublicTypesUnderAttribute.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.EmitsShimsForEachKindAndSkipsHardTier#ShimAllPublicTypesUnderAttribute.g.verified.cs @@ -20,4 +20,12 @@ public ShimAllPublicTypesUnderAttribute(string fromNamespacePrefix, string toNam public string FromNamespacePrefix { get; } public string ToNamespacePrefix { get; } + + /// + /// Sub-namespaces under fromNamespacePrefix to leave unshimmed, + /// e.g. one whose types relocated to another namespace that a separate + /// marker already shims. Prevents two markers emitting the same target + /// shim type. + /// + public string[]? ExceptNamespacePrefixes { get; set; } } \ No newline at end of file