diff --git a/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs b/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs index 39f703a93..d222fea8f 100644 --- a/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs +++ b/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs @@ -46,4 +46,21 @@ public static bool IsOlderThanMinimumSupported(string moniker, int minimumMajor) int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); return major < minimumMajor; } + + /// + /// Returns true for modern .NET monikers (net5.0 and later). + /// .NET Framework (net48, net472) and .NET Standard are false. + /// + /// A single target framework moniker, e.g. net10.0 or net48. + public static bool TargetsModernDotNet(string moniker) + { + Match match = modernMonikerPattern.Match(moniker.Trim()); + if (!match.Success) + { + return false; + } + + int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); + return major >= 5; + } } diff --git a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs index bda0d3f54..8cc1ed1ee 100644 --- a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -10,7 +11,9 @@ namespace Fallout.Migrate.Steps; /// Rewrites every *.csproj file under the repository root: Nuke.* package/project /// references become Fallout.* (pinning the current Fallout version where an inline /// Version attribute was present), Nuke* MSBuild properties are renamed to -/// Fallout*, and stale explicit System.Security.Cryptography.Xml pins are stripped. +/// Fallout*, stale explicit System.Security.Cryptography.Xml pins are stripped, +/// and a temporary NuGet.Framework 7.9.0 pin is added on _build.csproj when that +/// project targets modern .NET (stripped once the marker major is reached). /// internal sealed class RewriteCsprojsStep : IMigrationStep { @@ -74,6 +77,16 @@ internal sealed class RewriteCsprojsStep : IMigrationStep @"^[ \t]*[ \t]*\r?\n?", RegexOptions.Compiled | RegexOptions.Multiline); + // Temporary pin for .NET SDK 10.0.400. The marker names the Fallout major that drops it + // (the next major after MigrationContext.FalloutVersion). + private static readonly Regex nugetFrameworkPinPattern = new( + @"\r?\n[ \t]*[\s\S]*?\r?\n?", + RegexOptions.Compiled); + + private static readonly Regex targetFrameworkElementPattern = new( + @"(?[^<]+)", + RegexOptions.Compiled); + /// public Task ExecuteAsync(MigrationContext context, Summary summary) { @@ -82,7 +95,10 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) MigrationFileOperations.ApplyRewrite( context, path, - content => Rewrite(content, context.FalloutVersion), + content => Rewrite( + content, + context.FalloutVersion, + isBuildProject: path.Name.Equals("_build.csproj", StringComparison.OrdinalIgnoreCase)), summary); } @@ -91,12 +107,14 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) /// /// Rewrites content, replacing Nuke.* references and MSBuild - /// properties with their Fallout.* equivalents and stripping stale pins. + /// properties with their Fallout.* equivalents, stripping stale pins, and adding or + /// removing the temporary NuGet.Framework pin for .NET SDK 10.0.400. /// /// The original .csproj file content. /// The Fallout version to pin into rewritten inline-versioned references. + /// true when is _build.csproj. /// The rewritten content and the number of edits made. - private static RewriteResult Rewrite(string original, string falloutVersion) + private static RewriteResult Rewrite(string original, string falloutVersion, bool isBuildProject) { var edits = 0; var content = original; @@ -139,7 +157,8 @@ private static RewriteResult Rewrite(string original, string falloutVersion) return string.Empty; }); - return HandleMsBuildVariable(falloutVersion, content, edits); + var result = HandleMsBuildVariable(falloutVersion, content, edits); + return HandleNugetFrameworkPin(result, falloutVersion, isBuildProject); } // Pass 5 — extract variables used by Fallout.* PackageReferences, decouple the ones ambiguously @@ -267,4 +286,70 @@ private static (string content, int edits) BumpVariableProperties(string content return (content, edits); } + + // Pass 6 — add the NuGet.Framework pin on `_build.csproj` only, and only when that + // project targets modern .NET (NuGet.Framework 7.9.0 dropped netstandard2.0 / .NET + // Framework). Strip the pin once FalloutVersion is on the marker major, or when the + // build project cannot take the package. + private static RewriteResult HandleNugetFrameworkPin( + RewriteResult result, string falloutVersion, bool isBuildProject) + { + if (!isBuildProject) + { + return result; + } + + var content = result.Content; + var edits = result.EditCount; + var major = new Version(falloutVersion).Major; + var pin = nugetFrameworkPinPattern.Match(content); + var canPin = CanPinNugetFramework(content); + + if (pin.Success) + { + if (major == int.Parse(pin.Groups["major"].Value) || !canPin) + { + return new RewriteResult(nugetFrameworkPinPattern.Replace(content, string.Empty, 1), edits + 1); + } + + return result; + } + + // This pin is a 10.x workaround. v11+ only strips a marker that already names that major. + if (major != 10 || !canPin) + { + return result; + } + + var deleteAtMajor = major + 1; + var itemGroupClose = content.IndexOf("", StringComparison.Ordinal); + if (itemGroupClose < 0) + { + return result; + } + + var newLine = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var block = newLine + + $" " + newLine + + " " + newLine + + @" " + newLine + + $" " + newLine; + + return new RewriteResult(content.Insert(content.LastIndexOf('\n', itemGroupClose) + 1, block), edits + 1); + } + + private static bool CanPinNugetFramework(string content) + { + var match = targetFrameworkElementPattern.Match(content); + if (!match.Success) + { + return false; + } + + return match.Groups["value"].Value + .Split(';') + .Select(moniker => moniker.Trim()) + .Where(moniker => moniker.Length > 0) + .All(TargetFrameworkMonikers.TargetsModernDotNet); + } } diff --git a/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs index f13466cc0..254d5fa41 100644 --- a/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs +++ b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs @@ -313,7 +313,7 @@ public async Task Recognizes_a_version_variable_prefixed_with_Nuke() """; (tempDirectory / "build" / "_build.csproj").WriteAllText(input); - + await new RewriteCsprojsStep().ExecuteAsync(context, summary); var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); @@ -341,7 +341,7 @@ public async Task Leaves_an_arbitrary_version_variable_alone_but_updates_the_ver """; (tempDirectory / "build" / "_build.csproj").WriteAllText(input); - + await new RewriteCsprojsStep().ExecuteAsync(context, summary); var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); @@ -367,7 +367,7 @@ public async Task Leaves_an_unreferenced_arbitrary_variable_alone() """; (tempDirectory / "build" / "_build.csproj").WriteAllText(input); - + await new RewriteCsprojsStep().ExecuteAsync(context, summary); var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); @@ -392,7 +392,7 @@ public async Task Does_not_bump_variables_used_for_non_Fallout_packages() """; (tempDirectory / "build" / "_build.csproj").WriteAllText(input, eofLineBreak: false); - + await new RewriteCsprojsStep().ExecuteAsync(context, summary); var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); @@ -417,7 +417,7 @@ public async Task Decouples_ambiguously_used_variables() """; (tempDirectory / "build" / "_build.csproj").WriteAllText(input); - + await new RewriteCsprojsStep().ExecuteAsync(context, summary); var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); @@ -441,7 +441,7 @@ public async Task Decouples_ambiguously_used_variables_even_when_no_property_gro """; (tempDirectory / "build" / "_build.csproj").WriteAllText(input); - + await new RewriteCsprojsStep().ExecuteAsync(context, summary); var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); @@ -468,7 +468,7 @@ public async Task Decouples_ambiguously_used_variables_when_a_property_group_but """; (tempDirectory / "build" / "_build.csproj").WriteAllText(input); - + await new RewriteCsprojsStep().ExecuteAsync(context, summary); var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); @@ -479,4 +479,116 @@ public async Task Decouples_ambiguously_used_variables_when_a_property_group_but .And.Contain("$(FalloutVersion)", Exactly.Once()) .And.Contain("$(PkgVersion)"); } + + [Fact] + public async Task Correctly_adds_nuget_framework_package_version_pin() + { + const string input = """ + + + net10.0 + + + + + """; + + (tempDirectory / "build" / "_build.csproj").WriteAllText(input, eofLineBreak: false); + + context.FalloutVersion = "10.4.2"; + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); + + buildCsproj.Should().Be(""" + + + net10.0 + + + + + + + + + + """); + } + + [Fact] + public async Task Removes_nuget_framework_package_version_pin_on_v11() + { + const string input = """ + + + + + + + + + + + + """; + + (tempDirectory / "build" / "_build.csproj").WriteAllText(input, eofLineBreak: false); + + context.FalloutVersion = "11.0.0"; + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); + + buildCsproj.Should().Be(""" + + + + + + + """); + } + + [Fact] + public async Task Does_not_add_nuget_framework_pin_to_a_non_build_csproj() + { + const string input = """ + + + net10.0 + + + + + """; + + (tempDirectory / "Lib.csproj").WriteAllText(input, eofLineBreak: false); + + context.FalloutVersion = "10.4.2"; + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + (tempDirectory / "Lib.csproj").ReadAllText().Should().Be(input); + } + + [Fact] + public async Task Does_not_add_nuget_framework_pin_when_the_build_project_targets_netframework() + { + const string input = """ + + + net48 + + + + + """; + + (tempDirectory / "build" / "_build.csproj").WriteAllText(input, eofLineBreak: false); + + context.FalloutVersion = "10.4.2"; + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + (tempDirectory / "build" / "_build.csproj").ReadAllText().Should().Be(input); + } }