diff --git a/src/Fallout.Migrate/Common/Summary.cs b/src/Fallout.Migrate/Common/Summary.cs index c554568d7..903dc7e8f 100644 --- a/src/Fallout.Migrate/Common/Summary.cs +++ b/src/Fallout.Migrate/Common/Summary.cs @@ -17,6 +17,12 @@ internal sealed class Summary /// The number of directories renamed (currently just .nuke/.fallout/). public int DirectoriesRenamed { get; set; } + /// + /// Set when the user declined the confirmation prompt in . + /// stops executing further steps once this is set. + /// + public bool Cancelled { get; set; } + /// Human-readable warnings about conditions the migration could not resolve automatically. public List Warnings { get; } = new(); } diff --git a/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs b/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs new file mode 100644 index 000000000..39f703a93 --- /dev/null +++ b/src/Fallout.Migrate/Common/TargetFrameworkMonikers.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Fallout.Migrate.Common; + +/// +/// Shared parsing helpers for modern, dotted .NET target framework monikers (e.g. net10.0), +/// used by migration steps that compare a project's current TFM against a minimum. +/// +internal static class TargetFrameworkMonikers +{ + /// + /// Matches a modern, dotted .NET moniker such as net8.0 or net10.0. + /// + private static readonly Regex modernMonikerPattern = new( + @"^net(?\d+)\.\d+$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Extracts the major version segment from a modern TFM moniker such as net10.0. + /// + /// A modern, dotted target framework moniker. + /// The parsed major version. + public static int ExtractMajor(string moniker) + { + return int.Parse( + modernMonikerPattern.Match(moniker).Groups["major"].Value, + CultureInfo.InvariantCulture); + } + + /// + /// Returns true when targets an older .NET than + /// — including non-modern monikers (.NET Framework, .NET + /// Standard, out-of-support netcoreapp*) which never satisfy any minimum. + /// + /// A single target framework moniker, e.g. net8.0. + /// The minimum supported major version. + public static bool IsOlderThanMinimumSupported(string moniker, int minimumMajor) + { + Match match = modernMonikerPattern.Match(moniker); + if (!match.Success) + { + return true; + } + + int major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture); + return major < minimumMajor; + } +} diff --git a/src/Fallout.Migrate/Fallout.Migrate.csproj b/src/Fallout.Migrate/Fallout.Migrate.csproj index 6067a5548..666352d73 100644 --- a/src/Fallout.Migrate/Fallout.Migrate.csproj +++ b/src/Fallout.Migrate/Fallout.Migrate.csproj @@ -8,6 +8,7 @@ fallout-migrate CLI that migrates an existing NUKE consumer repo to Fallout in one command. build automation continuous-integration tools orchestration migration + 10.0.0 diff --git a/src/Fallout.Migrate/MigrateCommand.cs b/src/Fallout.Migrate/MigrateCommand.cs index 1d161f5e4..2078cde3a 100644 --- a/src/Fallout.Migrate/MigrateCommand.cs +++ b/src/Fallout.Migrate/MigrateCommand.cs @@ -52,6 +52,11 @@ public override async Task ExecuteAsync(CommandContext context, MigrateSett var migration = new Migration(rootDirectory, settings.DryRun, Console.Out); Summary summary = await migration.RunAsync(); + if (summary.Cancelled) + { + return 1; + } + PrintSummary(summary, settings.DryRun); return 0; } diff --git a/src/Fallout.Migrate/Migration.cs b/src/Fallout.Migrate/Migration.cs index d54d854e8..aa8d6e0ed 100644 --- a/src/Fallout.Migrate/Migration.cs +++ b/src/Fallout.Migrate/Migration.cs @@ -24,14 +24,18 @@ internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWri private static readonly IReadOnlyList steps = [ new ResolveFalloutVersionStep(), + new VerifyBuildTargetFrameworkStep(), + new ConfirmMigrationStep(), new RewriteCsprojsStep(), + new BumpDotNetVersionStep(), new RewriteCsFilesStep(), new RewriteBootstrapScriptsStep(), new RenameNukeDirectoryStep() ]; /// - /// Runs every step in against a fresh . + /// Runs every step in against a fresh , stopping + /// early if (or any later step) sets . /// /// A of the files changed, edits made, and any warnings. public async Task RunAsync() @@ -42,6 +46,10 @@ public async Task RunAsync() foreach (var step in steps) { await step.ExecuteAsync(context, summary); + if (summary.Cancelled) + { + break; + } } return summary; diff --git a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs new file mode 100644 index 000000000..163f3679a --- /dev/null +++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs @@ -0,0 +1,125 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Fallout.Migrate.Common; + +namespace Fallout.Migrate.Steps; + +/// +/// Bumps the repo's build orchestrator project (_build.csproj) to target +/// and pins global.json's SDK version to +/// — but only when what's there is behind those minimums. Already +/// up-to-date or newer values (e.g. a build project already on net11.0) are left alone. +/// +internal sealed class BumpDotNetVersionStep : IMigrationStep +{ + /// + /// The .NET target framework moniker to bump _build.csproj to when it's behind. + /// + private const string TargetFramework = "net10.0"; + + /// + /// The .NET SDK version to pin global.json to when it's behind. + /// + private const string SdkVersion = "10.0.100"; + + /// + /// The minimum .NET SDK version. Versions at or above this aren't touched. + /// + private static readonly Version minimumSupportedSdkVersion = new(10, 0, 100); + + /// + /// The minimum .NET target framework major version, derived from . + /// Monikers at or above this aren't touched. Shared with + /// so the minimum is only defined once. + /// + internal static readonly int MinimumSupportedMajor = TargetFrameworkMonikers.ExtractMajor(TargetFramework); + + /// + /// Matches the TargetFramework element's raw value. Build projects don't multi-target, + /// so TargetFrameworks (plural) is intentionally not matched here. + /// + private static readonly Regex targetFrameworkElementPattern = new( + @"(?[^<]+)", + RegexOptions.Compiled); + + /// + /// Matches the value of global.json's sdk.version property. + /// + private static readonly Regex sdkVersionPattern = new( + @"(?<=""sdk""\s*:\s*\{[^}]*?""version""\s*:\s*"")[^""]+", + RegexOptions.Compiled | RegexOptions.Singleline); + + /// + public Task ExecuteAsync(MigrationContext context, Summary summary) + { + foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "_build.csproj")) + { + MigrationFileOperations.ApplyRewrite(context, path, BumpTargetFramework, summary); + } + + foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "global.json")) + { + MigrationFileOperations.ApplyRewrite(context, path, BumpSdkVersion, summary); + } + + return Task.CompletedTask; + } + + /// + /// Rewrites a build project's TargetFramework element to + /// when its current moniker is behind . + /// + /// The original _build.csproj content. + /// The rewritten content and the number of edits made. + public static RewriteResult BumpTargetFramework(string original) + { + Match match = targetFrameworkElementPattern.Match(original); + if (!match.Success || + !TargetFrameworkMonikers.IsOlderThanMinimumSupported(match.Groups["value"].Value, MinimumSupportedMajor)) + { + return new RewriteResult(original, 0); + } + + string content = targetFrameworkElementPattern.Replace( + original, + $"{TargetFramework}", + count: 1); + + return new RewriteResult(content, 1); + } + + /// + /// Rewrites global.json's sdk.version to only when the + /// current version is behind . + /// + /// The original global.json content. + /// The rewritten content and the number of edits made. + public static RewriteResult BumpSdkVersion(string original) + { + Match match = sdkVersionPattern.Match(original); + if (!match.Success || !IsOlderThanMinimumSupportedSdk(match.Value)) + { + return new RewriteResult(original, 0); + } + + string content = sdkVersionPattern.Replace(original, SdkVersion, count: 1); + return new RewriteResult(content, 1); + } + + /// + /// Returns true when is behind + /// , or can't be parsed as a version at all. + /// + /// The sdk.version value from global.json. + private static bool IsOlderThanMinimumSupportedSdk(string version) + { + Version parsed; + if (!Version.TryParse(version, out parsed)) + { + return true; + } + + return parsed < minimumSupportedSdkVersion; + } +} diff --git a/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs new file mode 100644 index 000000000..b67bbc629 --- /dev/null +++ b/src/Fallout.Migrate/Steps/ConfirmMigrationStep.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Fallout.Migrate.Common; +using Spectre.Console; + +namespace Fallout.Migrate.Steps; + +/// +/// Asks the user to confirm before any subsequent step writes to disk. Runs last among the +/// read-only/advisory steps, immediately before — the first step +/// in that mutates files. Sets when +/// the user declines, which checks to stop early. +/// +internal sealed class ConfirmMigrationStep : IMigrationStep +{ + /// + public async Task ExecuteAsync(MigrationContext context, Summary summary) + { + if (context.DryRun) + { + // Nothing will be written; no point confirming. + return; + } + + if (Console.IsInputRedirected) + { + // Non-interactive (CI, piped input, automated tests) — nothing to prompt against, so + // proceed as if confirmed. + return; + } + + var prompt = new ConfirmationPrompt( + $"This will modify files under [blue]{context.RootDirectory}[/]. Continue?"); + + bool proceed = await prompt.ShowAsync(AnsiConsole.Console, CancellationToken.None); + if (!proceed) + { + summary.Cancelled = true; + AnsiConsole.MarkupLine("[yellow]Migration cancelled — no files were changed.[/]"); + } + } +} diff --git a/src/Fallout.Migrate/Steps/CsprojRewriter.cs b/src/Fallout.Migrate/Steps/CsprojRewriter.cs deleted file mode 100644 index 15ddae94f..000000000 --- a/src/Fallout.Migrate/Steps/CsprojRewriter.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Text.RegularExpressions; -using Fallout.Migrate.Common; - -namespace Fallout.Migrate.Steps; - -/// -/// Rewrites .csproj files: 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. Driven by . -/// -internal static class CsprojRewriter -{ - // Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X - // at the current Fallout version. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as - // Fallout.* packages and produce NU1603 ("not found, falling back to next-higher") which - // `WarningsAsErrors` in the migrated project escalates. Bumping in the same pass avoids - // a broken post-migrate build (#217). Tolerates extra attributes between Include and Version - // (e.g. `PrivateAssets="all"`). - private static readonly Regex nukePackageWithInlineVersionPattern = new( - @"(?[A-Z][A-Za-z0-9.]+)(?""[^>]*?\s+Version="")[^""]+", - RegexOptions.Compiled); - - // PackageReference / ProjectReference `Include="Nuke.X"` → `Include="Fallout.X"` — namespace - // only. Catches references that DON'T have an inline Version (central package management). - // Must run AFTER NukePackageWithInlineVersionPattern so it only touches what's left. - private static readonly Regex packageReferencePattern = - new(@"(?<=\b(?:Include|Update|Remove)="")Nuke\.(?=[A-Z])", RegexOptions.Compiled); - - // MSBuild element/property names that begin with `Nuke` followed by an uppercase - // letter (e.g. ...). Limited to known consumer-facing names from - // P3.5b so we don't rewrite unrelated user-defined identifiers that happen to start - // with the literal "Nuke". - private static readonly Regex msBuildPropertyPattern = new( - @"\bNuke(?=" + - "(?:RootDirectory|ScriptDirectory|TelemetryVersion|BaseDirectory|BaseNamespace|" + - "UseNestedNamespaces|RepositoryUrl|UpdateReferences|ContinueOnError|TaskTimeout|" + - "Timeout|TasksEnabled|DefaultExcludes|ExcludeBoot|ExcludeConfig|ExcludeLogs|" + - "ExcludeDirectoryBuild|ExcludeCi|SpecificationFiles|ExternalFiles|TasksAssembly|" + - "TasksDirectory)\\b)", - RegexOptions.Compiled); - - // Strip explicit `System.Security.Cryptography.Xml` PackageReferences. NUKE-era projects - // often pinned this directly at an older major (e.g. 9.x). Fallout.Common 10.2.12+ transitively - // requires a newer version (10.0.6+) and the conflict trips NU1605 ("Detected package - // downgrade"). Removing the explicit pin lets the transitive version win, which is what the - // migrated project wants (#217). Matches a self-closing element with optional surrounding - // indentation + trailing newline. - private static readonly Regex cryptographyXmlPackageRefPattern = new( - @"^[ \t]*\s*\r?\n?", - RegexOptions.Compiled | RegexOptions.Multiline); - - /// - /// Rewrites content, replacing Nuke.* references and MSBuild - /// properties with their Fallout.* equivalents and stripping stale pins. - /// - /// The original .csproj file content. - /// The Fallout version to pin into rewritten inline-versioned references. - /// The rewritten content and the number of edits made. - public static RewriteResult Rewrite(string original, string falloutVersion) - { - var edits = 0; - var content = original; - - // Pass 1 — combined Include + Version rewrite for Nuke.X PackageReferences with inline Version. - content = nukePackageWithInlineVersionPattern.Replace(content, m => - { - edits++; - return m.Groups["prefix"].Value - + "Fallout." + m.Groups["name"].Value - + m.Groups["between"].Value - + falloutVersion; - }); - - // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (CPM-managed - // PackageReferences without inline Version, ProjectReferences, MSBuild properties). - content = packageReferencePattern.Replace(content, _ => - { - edits++; - return "Fallout."; - }); - - content = msBuildPropertyPattern.Replace(content, _ => - { - edits++; - return "Fallout"; - }); - - // Pass 3 — strip the stale System.Security.Cryptography.Xml direct pin. - content = cryptographyXmlPackageRefPattern.Replace(content, _ => - { - edits++; - return string.Empty; - }); - - return new RewriteResult(content, edits); - } -} diff --git a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs index d51c26a0c..51d5c2c3d 100644 --- a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs +++ b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs @@ -1,35 +1,73 @@ +using System; +using System.Globalization; +using System.IO; +using System.Net.Http; using System.Reflection; +using System.Text.Json; using System.Threading.Tasks; using Fallout.Migrate.Common; +using Spectre.Console; namespace Fallout.Migrate.Steps; // Pinned into migrated `` lines. -// Uses the running migrate tool's own SemVer (Nerdbank.GitVersioning, set on +// Prefers the latest published, non-prerelease Fallout.Common version from NuGet within the +// running tool's own major (calendar year), so a migration always pins to what's actually +// installable without jumping to a newer, potentially breaking yearly major the tool wasn't +// built against. Falls back to the running migrate tool's own SemVer (Nerdbank.GitVersioning, +// set on AssemblyInformationalVersion) when NuGet can't be reached (offline, corporate proxy, +// etc.) or has no matching-major stable release yet. For dev/local builds without a `+` in +// InformationalVersion (i.e. no build-metadata suffix), falls back further to a +// known-published floor so we never emit a bogus pin like Version="LOCAL". Inlined to keep +// Fallout.Migrate dependency-free. -// AssemblyInformationalVersion) so the migration output aligns with the tool the user - -// just installed. For dev/local builds without a `+` in InformationalVersion (i.e. no - -// build-metadata suffix), falls back to a known-published floor so we never emit a - -// bogus pin like Version="LOCAL". Inlined to keep Fallout.Migrate dependency-free. /// -/// Resolves the running tool's own Fallout version and stores it on . +/// Resolves the Fallout version to pin and stores it on . /// Must run first in : reads the /// resolved version to pin rewritten package references. /// internal sealed class ResolveFalloutVersionStep : IMigrationStep { - /// The version to fall back to when the assembly carries no build-metadata suffix. + /// + /// The version to fall back to when the assembly carries no build-metadata suffix. + /// private const string Fallback = "10.3.49"; + /// + /// The NuGet package whose latest stable version is used to pin migrated references. + /// + private const string PackageId = "fallout.common"; + + /// + /// NuGet's flat-container index for : a JSON document listing every + /// published version (stable and prerelease), oldest first. + /// + private static readonly Uri flatContainerIndex = new($"https://api.nuget.org/v3-flatcontainer/{PackageId}/index.json"); + + private static readonly HttpClient httpClient = CreateHttpClient(); + /// - public Task ExecuteAsync(MigrationContext context, Summary summary) + public async Task ExecuteAsync(MigrationContext context, Summary summary) { - context.FalloutVersion = Resolve(); - return Task.CompletedTask; + string localVersion = ResolveFromAssembly(); + int localMajor = ExtractMajor(localVersion); + + string nuGetVersion = await ResolveFromNuGetAsync(localMajor); + if (nuGetVersion != null) + { + AnsiConsole.MarkupLineInterpolated( + $"[grey]Resolved Fallout version [bold]{nuGetVersion}[/] from NuGet (latest stable {localMajor}.x release).[/]"); + + context.FalloutVersion = nuGetVersion; + } + else + { + AnsiConsole.MarkupLineInterpolated( + $"[grey]No stable {localMajor}.x release found on NuGet; using the running tool's own version [bold]{localVersion}[/].[/]"); + + context.FalloutVersion = localVersion; + } } /// @@ -37,9 +75,9 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) /// build-metadata suffix, falling back to when none is present. /// /// The Fallout version to pin into rewritten package references. - private static string Resolve() + private static string ResolveFromAssembly() { - var informational = typeof(ResolveFalloutVersionStep).Assembly + string informational = typeof(ResolveFalloutVersionStep).Assembly .GetCustomAttribute() ?.InformationalVersion; @@ -56,4 +94,86 @@ private static string Resolve() return informational[..plusIndex]; } + + /// + /// Extracts the major version segment (e.g. the calendar year) from a version string, ignoring + /// any prerelease or build-metadata suffix. + /// + /// A version string such as "2026.1.0-preview.134". + /// The parsed major version. + private static int ExtractMajor(string version) + { + int dotIndex = version.IndexOf('.'); + string majorSegment = dotIndex == -1 ? version : version[..dotIndex]; + + return int.Parse(majorSegment, CultureInfo.InvariantCulture); + } + + /// + /// Queries NuGet for the latest non-prerelease version of whose major + /// version matches . + /// + /// The major version (calendar year) to restrict the lookup to. + /// The latest published stable version within , or null + /// if NuGet couldn't be reached or has no matching-major stable version. + private static async Task ResolveFromNuGetAsync(int major) + { + try + { + using HttpResponseMessage response = await httpClient.GetAsync(flatContainerIndex); + if (!response.IsSuccessStatusCode) + { + return null; + } + + using Stream stream = await response.Content.ReadAsStreamAsync(); + using JsonDocument document = await JsonDocument.ParseAsync(stream); + if (!document.RootElement.TryGetProperty("versions", out JsonElement versions)) + { + return null; + } + + Version latest = null; + foreach (JsonElement element in versions.EnumerateArray()) + { + string raw = element.GetString(); + + // Skip prerelease versions (e.g. "2026.1.0-preview.12+g1a2b3c") — only stable + // releases are pinned into migrated package references. + if (string.IsNullOrEmpty(raw) || raw.Contains('-')) + { + continue; + } + + if (Version.TryParse(raw, out Version parsed) && parsed.Major == major && + (latest is null || parsed > latest)) + { + latest = parsed; + } + } + + return latest?.ToString(); + } + catch (Exception ex) + { + // Best-effort: offline, proxy, DNS failure, malformed response, etc. all fall back + // to the running tool's own version above. + AnsiConsole.MarkupLineInterpolated($"[grey]NuGet lookup failed ({ex.Message}); falling back.[/]"); + return null; + } + } + + /// + /// Creates the used for the NuGet flat-container lookup, identifying + /// this tool via a User-Agent header so NuGet.org doesn't treat the request as anonymous + /// script traffic. + /// + /// A configured . + private static HttpClient CreateHttpClient() + { + HttpClient client = new() { Timeout = TimeSpan.FromSeconds(3) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("fallout-migrate"); + + return client; + } } diff --git a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs index aed7ea78b..b462a45cf 100644 --- a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs @@ -1,13 +1,56 @@ +using System.Text.RegularExpressions; using System.Threading.Tasks; using Fallout.Migrate.Common; namespace Fallout.Migrate.Steps; /// -/// Rewrites every *.csproj file under the repository root via . +/// 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. /// internal sealed class RewriteCsprojsStep : IMigrationStep { + // Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X + // at the current Fallout version. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as + // Fallout.* packages and produce NU1603 ("not found, falling back to next-higher") which + // `WarningsAsErrors` in the migrated project escalates. Bumping in the same pass avoids + // a broken post-migrate build (#217). Tolerates extra attributes between Include and Version + // (e.g. `PrivateAssets="all"`). + private static readonly Regex nukePackageWithInlineVersionPattern = new( + @"(?[A-Z][A-Za-z0-9.]+)(?""[^>]*?\s+Version="")[^""]+", + RegexOptions.Compiled); + + // PackageReference / ProjectReference `Include="Nuke.X"` → `Include="Fallout.X"` — namespace + // only. Catches references that DON'T have an inline Version (central package management). + // Must run AFTER NukePackageWithInlineVersionPattern so it only touches what's left. + private static readonly Regex packageReferencePattern = + new(@"(?<=\b(?:Include|Update|Remove)="")Nuke\.(?=[A-Z])", RegexOptions.Compiled); + + // MSBuild element/property names that begin with `Nuke` followed by an uppercase + // letter (e.g. ...). Limited to known consumer-facing names from + // P3.5b so we don't rewrite unrelated user-defined identifiers that happen to start + // with the literal "Nuke". + private static readonly Regex msBuildPropertyPattern = new( + @"\bNuke(?=" + + "(?:RootDirectory|ScriptDirectory|TelemetryVersion|BaseDirectory|BaseNamespace|" + + "UseNestedNamespaces|RepositoryUrl|UpdateReferences|ContinueOnError|TaskTimeout|" + + "Timeout|TasksEnabled|DefaultExcludes|ExcludeBoot|ExcludeConfig|ExcludeLogs|" + + "ExcludeDirectoryBuild|ExcludeCi|SpecificationFiles|ExternalFiles|TasksAssembly|" + + "TasksDirectory)\\b)", + RegexOptions.Compiled); + + // Strip explicit `System.Security.Cryptography.Xml` PackageReferences. NUKE-era projects + // often pinned this directly at an older major (e.g. 9.x). Fallout.Common 10.2.12+ transitively + // requires a newer version (10.0.6+) and the conflict trips NU1605 ("Detected package + // downgrade"). Removing the explicit pin lets the transitive version win, which is what the + // migrated project wants (#217). Matches a self-closing element with optional surrounding + // indentation + trailing newline. + private static readonly Regex cryptographyXmlPackageRefPattern = new( + @"^[ \t]*\s*\r?\n?", + RegexOptions.Compiled | RegexOptions.Multiline); + /// public Task ExecuteAsync(MigrationContext context, Summary summary) { @@ -16,10 +59,56 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) MigrationFileOperations.ApplyRewrite( context, path, - content => CsprojRewriter.Rewrite(content, context.FalloutVersion), + content => Rewrite(content, context.FalloutVersion), summary); } return Task.CompletedTask; } + + /// + /// Rewrites content, replacing Nuke.* references and MSBuild + /// properties with their Fallout.* equivalents and stripping stale pins. + /// + /// The original .csproj file content. + /// The Fallout version to pin into rewritten inline-versioned references. + /// The rewritten content and the number of edits made. + public static RewriteResult Rewrite(string original, string falloutVersion) + { + var edits = 0; + var content = original; + + // Pass 1 — combined Include + Version rewrite for Nuke.X PackageReferences with inline Version. + content = nukePackageWithInlineVersionPattern.Replace(content, m => + { + edits++; + return m.Groups["prefix"].Value + + "Fallout." + m.Groups["name"].Value + + m.Groups["between"].Value + + falloutVersion; + }); + + // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (CPM-managed + // PackageReferences without inline Version, ProjectReferences, MSBuild properties). + content = packageReferencePattern.Replace(content, _ => + { + edits++; + return "Fallout."; + }); + + content = msBuildPropertyPattern.Replace(content, _ => + { + edits++; + return "Fallout"; + }); + + // Pass 3 — strip the stale System.Security.Cryptography.Xml direct pin. + content = cryptographyXmlPackageRefPattern.Replace(content, _ => + { + edits++; + return string.Empty; + }); + + return new RewriteResult(content, edits); + } } diff --git a/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs new file mode 100644 index 000000000..961d5eca1 --- /dev/null +++ b/src/Fallout.Migrate/Steps/VerifyBuildTargetFrameworkStep.cs @@ -0,0 +1,59 @@ +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Fallout.Common.IO; +using Fallout.Migrate.Common; + +namespace Fallout.Migrate.Steps; + +/// +/// Warns when the repo's build orchestrator project (_build.csproj) targets an older .NET +/// than Fallout requires. Fallout's own tooling is built and tested against .NET 10; a build +/// project still on an older TFM can hit tool incompatibilities that aren't caught by this +/// migration's other, purely textual rewrites. +/// +internal sealed class VerifyBuildTargetFrameworkStep : IMigrationStep +{ + /// + /// Matches a `TargetFramework` or `TargetFrameworks` element's raw value. + /// + private static readonly Regex targetFrameworkElementPattern = new( + @"(?[^<]+)", + RegexOptions.Compiled); + + /// + public Task ExecuteAsync(MigrationContext context, Summary summary) + { + foreach (AbsolutePath path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "_build.csproj")) + { + string content = path.ReadAllText(); + Match match = targetFrameworkElementPattern.Match(content); + if (!match.Success) + { + continue; + } + + string[] monikers = match.Groups["value"].Value + .Split(';') + .Select(m => m.Trim()) + .Where(m => m.Length > 0) + .ToArray(); + + string[] outdated = monikers + .Where(moniker => TargetFrameworkMonikers.IsOlderThanMinimumSupported(moniker, BumpDotNetVersionStep.MinimumSupportedMajor)) + .ToArray(); + if (outdated.Length == 0) + { + continue; + } + + summary.Warnings.Add( + $"{MigrationFileOperations.RelativePath(context.RootDirectory, path)} targets " + + $"{string.Join(", ", outdated)}, older than .NET {BumpDotNetVersionStep.MinimumSupportedMajor}. " + + "Check that all tools you invoke from the build (SDKs, global tools, etc.) also " + + $"support .NET {BumpDotNetVersionStep.MinimumSupportedMajor} before finishing this migration."); + } + + return Task.CompletedTask; + } +} diff --git a/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs b/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs new file mode 100644 index 000000000..a294a6f15 --- /dev/null +++ b/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs @@ -0,0 +1,115 @@ +using FluentAssertions; +using Xunit; +using Fallout.Migrate.Steps; + +namespace Fallout.Migrate.Specs; + +public class BumpDotNetVersionStepSpecs +{ + [Fact] + public void BumpsOlderTargetFrameworkToNet10() + { + const string input = """ + + + Exe + net8.0 + + + """; + + var result = BumpDotNetVersionStep.BumpTargetFramework(input); + + result.EditCount.Should().Be(1); + result.Content.Should().Contain("net10.0"); + result.Content.Should().NotContain("net8.0"); + } + + [Fact] + public void LeavesAlreadyNet10Unchanged() + { + const string input = """ + + + net10.0 + + + """; + + var result = BumpDotNetVersionStep.BumpTargetFramework(input); + + result.EditCount.Should().Be(0); + result.Content.Should().Be(input); + } + + [Fact] + public void LeavesNewerTargetFrameworkUnchanged() + { + const string input = """ + + + net11.0 + + + """; + + var result = BumpDotNetVersionStep.BumpTargetFramework(input); + + result.EditCount.Should().Be(0); + result.Content.Should().Be(input); + } + + [Fact] + public void BumpsSdkVersionInGlobalJson() + { + const string input = """ + { + "sdk": { + "version": "8.0.100", + "rollForward": "latestMinor" + } + } + """; + + var result = BumpDotNetVersionStep.BumpSdkVersion(input); + + result.EditCount.Should().Be(1); + result.Content.Should().Contain(@"""version"": ""10.0.100"""); + result.Content.Should().Contain(@"""rollForward"": ""latestMinor"""); + result.Content.Should().NotContain("8.0.100"); + } + + [Fact] + public void LeavesAlreadyPinnedSdkVersionUnchanged() + { + const string input = """ + { + "sdk": { + "version": "10.0.100" + } + } + """; + + var result = BumpDotNetVersionStep.BumpSdkVersion(input); + + result.EditCount.Should().Be(0); + result.Content.Should().Be(input); + } + + [Fact] + public void LeavesNewerSdkVersionUnchanged() + { + const string input = """ + { + "sdk": { + "version": "11.0.100" + } + } + """; + + var result = BumpDotNetVersionStep.BumpSdkVersion(input); + + result.EditCount.Should().Be(0); + result.Content.Should().Be(input); + } +} diff --git a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs index 4430a5fb1..53a937e8e 100644 --- a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs +++ b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs @@ -95,6 +95,56 @@ public async Task WarnsWhenBothNukeAndFalloutDirectoriesExist() } } + [Fact] + public async Task WarnsWhenBuildProjectTargetsOlderThanNet10() + { + var temp = CreateVanillaFixture(); + var buildCsprojPath = Path.Combine(temp, "build", "_build.csproj"); + File.WriteAllText(buildCsprojPath, File.ReadAllText(buildCsprojPath).Replace("net10.0", "net8.0")); + + try + { + var summary = await new Migration(temp, dryRun: false, TextWriter.Null).RunAsync(); + + summary.Warnings.Should().Contain(w => + w.Contains("net8.0") && w.Contains(".NET 10") && w.Contains("_build.csproj")); + } + finally + { + Directory.Delete(temp, recursive: true); + } + } + + [Fact] + public async Task BumpsDotNetVersionAndSdk() + { + var temp = CreateVanillaFixture(); + var buildCsprojPath = Path.Combine(temp, "build", "_build.csproj"); + File.WriteAllText(buildCsprojPath, File.ReadAllText(buildCsprojPath).Replace("net10.0", "net8.0")); + File.WriteAllText(Path.Combine(temp, "global.json"), """ + { + "sdk": { + "version": "8.0.100", + "rollForward": "latestMinor" + } + } + """); + + try + { + await new Migration(temp, dryRun: false, TextWriter.Null).RunAsync(); + + File.ReadAllText(buildCsprojPath).Should().Contain("net10.0"); + + var globalJson = File.ReadAllText(Path.Combine(temp, "global.json")); + globalJson.Should().Contain(@"""version"": ""10.0.100"""); + } + finally + { + Directory.Delete(temp, recursive: true); + } + } + private static string CreateVanillaFixture() { var dir = Path.Combine(Path.GetTempPath(), "fallout-migrate-test-" + Guid.NewGuid().ToString("N")[..8]); @@ -106,7 +156,7 @@ private static string CreateVanillaFixture() Exe - net8.0 + net10.0 .\.. 1 diff --git a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs similarity index 90% rename from tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs rename to tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs index 5f5f07731..e11732f9f 100644 --- a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs +++ b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs @@ -4,7 +4,7 @@ namespace Fallout.Migrate.Specs; -public class CsprojRewriterSpecs +public class RewriteCsprojsStepSpecs { private const string TestFalloutVersion = "11.0.0"; @@ -20,7 +20,7 @@ public void RewritesPackageReferenceNamespace() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.EditCount.Should().Be(2); result.Content.Should().Contain(@"Include=""Fallout.Common"""); @@ -40,7 +40,7 @@ public void RewritesNukeRootDirectoryProperty() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.EditCount.Should().Be(4); // 2 opening + 2 closing tags result.Content.Should().Contain(""); @@ -60,7 +60,7 @@ public void LeavesUnrelatedNukePrefixedIdentifiersAlone() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.EditCount.Should().Be(0); result.Content.Should().Be(input); @@ -77,7 +77,7 @@ public void ReturnsZeroEditsForUnchangedContent() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.EditCount.Should().Be(0); result.Content.Should().Be(input); @@ -97,7 +97,7 @@ public void BumpsNukeInlineVersionToCurrentFalloutVersion() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.Content.Should().Contain(@"Include=""Fallout.Common"" Version=""11.0.0"""); result.Content.Should().Contain(@"Include=""Fallout.Components"" Version=""11.0.0"""); @@ -117,7 +117,7 @@ public void BumpsVersionAcrossExtraAttributesBetweenIncludeAndVersion() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.Content.Should().Contain(@"Include=""Fallout.Common"" PrivateAssets=""all"" Version=""11.0.0"""); } @@ -136,7 +136,7 @@ public void LeavesCpmManagedReferencesWithoutInlineVersionUntouchedByVersionPass """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.Content.Should().Contain(@""); result.Content.Should().NotContain(@"Version="); @@ -157,7 +157,7 @@ public void StripsSystemSecurityCryptographyXmlPackageReference() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.Content.Should().NotContain("System.Security.Cryptography.Xml"); result.Content.Should().Contain(@"Include=""Fallout.Common"" Version=""11.0.0"""); @@ -178,7 +178,7 @@ public void LeavesOtherSystemPackagesAlone() """; - var result = CsprojRewriter.Rewrite(input, TestFalloutVersion); + var result = RewriteCsprojsStep.Rewrite(input, TestFalloutVersion); result.EditCount.Should().Be(0); result.Content.Should().Be(input); diff --git a/version.json b/version.json index b5a80df54..fca891223 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2026.1.0-preview.{height}", + "version": "10.0.0-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/release/\\d{4}$", "^refs/heads/support/\\d{4}$",