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/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/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/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 + + + + + + + + + + + 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;"); + } } 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