From b41d761e4ab12e124668da3a3a2a5524e9dd7db1 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Tue, 6 Oct 2026 23:58:37 +0200 Subject: [PATCH 1/4] Add a rollForward policy when fallout-migrate pins the SDK in global.json BumpDotNetVersionStep pins sdk.version to 10.0.100. Without a rollForward property, the default policy accepts only 10.0.1xx SDKs. On a machine with only a later feature band, such as 10.0.4xx, every dotnet command then fails. When the sdk object has no rollForward yet, the step now adds "rollForward": "latestFeature" next to the new version. Co-Authored-By: Claude Opus 5.5 --- .../Steps/BumpDotNetVersionStep.cs | 51 ++++++++++++++++++- .../BumpDotNetVersionStepSpecs.cs | 28 ++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs index 9fcd30674..51e8dacc8 100644 --- a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs +++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs @@ -50,6 +50,20 @@ internal sealed class BumpDotNetVersionStep : IMigrationStep @"(?<=""sdk""\s*:\s*\{[^}]*?""version""\s*:\s*"")[^""]+", RegexOptions.Compiled | RegexOptions.Singleline); + /// + /// The roll-forward policy added to global.json when the SDK is pinned and no policy is set. + /// Without one, the default patch policy needs a 10.0.1xx SDK, so a machine with only a + /// later feature band (for example 10.0.4xx) fails every dotnet command. + /// + private const string RollForward = "latestFeature"; + + /// + /// Matches a rollForward property inside global.json's sdk object. + /// + private static readonly Regex sdkRollForwardPattern = new( + @"""sdk""\s*:\s*\{[^}]*""rollForward""", + RegexOptions.Compiled | RegexOptions.Singleline); + /// public Task ExecuteAsync(MigrationContext context, Summary summary) { @@ -91,7 +105,8 @@ private static RewriteResult BumpTargetFramework(string original) /// /// Rewrites global.json's sdk.version to only when the - /// current version is behind . + /// current version is behind , and adds + /// when the sdk object has no rollForward yet. /// /// The original global.json content. /// The rewritten content and the number of edits made. @@ -104,7 +119,39 @@ public static RewriteResult BumpSdkVersion(string original) } string content = sdkVersionPattern.Replace(original, SdkVersion, count: 1); - return new RewriteResult(content, 1); + if (sdkRollForwardPattern.IsMatch(content)) + { + return new RewriteResult(content, 1); + } + + // Insert after the closing quote of the new version value. + int insertAt = match.Index + SdkVersion.Length + 1; + content = content.Insert(insertAt, $",{PropertySeparator(original, match.Index)}\"rollForward\": \"{RollForward}\""); + return new RewriteResult(content, 2); + } + + /// + /// Returns the text to put before a new property next to the version property: a line + /// break plus the same indentation when version starts its own line, otherwise a space. + /// + /// The original global.json content. + /// The index of the sdk.version value. + private static string PropertySeparator(string original, int versionValueIndex) + { + int lineStart = original.LastIndexOf('\n', versionValueIndex) + 1; + int keyStart = lineStart; + while (original[keyStart] is ' ' or '\t') + { + keyStart++; + } + + if (string.CompareOrdinal(original, keyStart, "\"version\"", 0, "\"version\"".Length) != 0) + { + return " "; + } + + string lineBreak = original.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + return lineBreak + original[lineStart..keyStart]; } /// diff --git a/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs b/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs index ef9783c6b..cf2edb130 100644 --- a/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs +++ b/tests/Fallout.Migrate.Specs/BumpDotNetVersionStepSpecs.cs @@ -129,6 +129,34 @@ public async Task Old_sdk_version_in_global_json_is_bumped() summary.EditCount.Should().Be(1); } + [Fact] + public async Task Bumped_sdk_version_gets_a_roll_forward_policy_when_none_is_set() + { + // Arrange + var globalJson = tempDirectory / "global.json"; + globalJson.WriteAllText( + """ + { + "sdk": { + "version": "8.0.100" + } + } + """); + + // Act + await new BumpDotNetVersionStep().ExecuteAsync(context, summary); + + // Assert + globalJson.ReadAllText().Should().Contain( + """ + "version": "10.0.100", + "rollForward": "latestFeature" + } + """); + summary.FilesChanged.Should().Be(1); + summary.EditCount.Should().Be(2); + } + [Fact] public async Task Already_pinned_sdk_version_is_left_unchanged() { From cae0f7df274a9a2e1136e742fdaba6ca88eaf240 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Tue, 6 Oct 2026 23:58:44 +0200 Subject: [PATCH 2/4] Keep a Nuke segment inside another dotted name in fallout-migrate RewriteCsFilesStep matched `\bNuke\.`, so `using Acme.Nuke.Tools;` became `using Acme.Fallout.Tools;`. That namespace does not exist and the build fails with CS0246. The prefix swap now matches only a `Nuke.` that is not preceded by a letter, digit, underscore or dot. Co-Authored-By: Claude Opus 5.5 --- .../Steps/RewriteCsFilesStep.cs | 6 ++++-- .../RewriteCsFilesStepSpecs.cs | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs index 2b6064b12..82fdedc20 100644 --- a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs @@ -21,12 +21,14 @@ internal sealed class RewriteCsFilesStep : IMigrationStep private static readonly Regex projectModelNamespace = new(@"\b(?:Nuke|Fallout)\.Common\.ProjectModel\b", RegexOptions.Compiled); - // Anchored prefix swap: `\bNuke\.` → `Fallout.`. Covers using directives, + // Anchored prefix swap: `Nuke.` → `Fallout.`. Covers using directives, // attribute references, qualified type names, namespace declarations. + // The leading `(? Date: Tue, 6 Oct 2026 23:58:52 +0200 Subject: [PATCH 3/4] Migrate a repository cloned under a bin or obj folder in fallout-migrate MigrationFileOperations skipped every file whose absolute path contained /bin/, /obj/ or /.git/. For a repository cloned under such a folder, every step found no files, and the tool still reported "Migration complete". The check now looks only at the folders below the repository root. Co-Authored-By: Claude Opus 5.5 --- .../Common/MigrationFileOperations.cs | 22 +++++++++++++------ .../RewriteCsFilesStepSpecs.cs | 16 ++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/Fallout.Migrate/Common/MigrationFileOperations.cs b/src/Fallout.Migrate/Common/MigrationFileOperations.cs index a5ba5e44b..ba00eb7be 100644 --- a/src/Fallout.Migrate/Common/MigrationFileOperations.cs +++ b/src/Fallout.Migrate/Common/MigrationFileOperations.cs @@ -22,7 +22,7 @@ public static IEnumerable EnumerateFiles(AbsolutePath rootDirector { foreach (var file in rootDirectory.GetFiles(pattern, depth: int.MaxValue)) { - if (IsIgnored(file)) + if (IsIgnored(rootDirectory, file)) { continue; } @@ -88,13 +88,21 @@ public static string RelativePath(AbsolutePath rootDirectory, AbsolutePath absol /// /// Returns true if sits under a bin/, obj/, or - /// .git/ directory and should be skipped by . + /// .git/ directory below and should be skipped by + /// . Only the path below the root counts, so a repository cloned + /// under a folder named bin or obj is still migrated. /// - private static bool IsIgnored(AbsolutePath path) + private static bool IsIgnored(AbsolutePath rootDirectory, AbsolutePath path) { - string text = path; - return text.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - || text.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - || text.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}", StringComparison.Ordinal); + var segments = RelativePath(rootDirectory, path).Split('/'); + for (var i = 0; i < segments.Length - 1; i++) + { + if (segments[i] is "bin" or "obj" or ".git") + { + return true; + } + } + + return false; } } diff --git a/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs index 23e5a1ff4..0823e50c3 100644 --- a/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs +++ b/tests/Fallout.Migrate.Specs/RewriteCsFilesStepSpecs.cs @@ -76,6 +76,22 @@ public async Task Nuke_inside_another_dotted_name_is_left_alone() buildCs.Should().Contain("using Fallout.Common;"); } + [Fact] + public async Task Files_are_rewritten_when_the_repository_sits_under_a_bin_folder() + { + // Arrange + var repository = tempDirectory / "bin" / "repo"; + (repository / "Build.cs").WriteAllText("using Nuke.Common;"); + var repositoryContext = new MigrationContext(repository, dryRun: false, TextWriter.Null); + + // Act + await new RewriteCsFilesStep().ExecuteAsync(repositoryContext, summary); + + // Assert + summary.EditCount.Should().Be(1); + (repository / "Build.cs").ReadAllText().Should().Contain("using Fallout.Common;"); + } + [Fact] public async Task NukeBuild_base_type_is_renamed_to_FalloutBuild() { From ff43b40a4f6ee6637fa4dd12652e71a8ed376f4e Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Thu, 8 Oct 2026 10:37:10 +0200 Subject: [PATCH 4/4] Keep a Nuke ProjectModel namespace inside another dotted name in fallout-migrate Review feedback on the SDK pin and Nuke prefix changes: - Keep `Acme.Nuke.Common.ProjectModel` as it is. Before, it became `Acme.Fallout.Solutions`, the same error the prefix fix solves. - Warn when global.json keeps a rollForward policy (patch, latestPatch or disable) that needs the pinned 10.0.1xx feature band. - Add a spec that bin, obj and .git folders below the root are still skipped. - Explain why the migration adds latestFeature instead of latestMinor. Co-Authored-By: Claude Opus 5.5 --- .../Steps/BumpDotNetVersionStep.cs | 46 +++++++++++++++- .../Steps/RewriteCsFilesStep.cs | 4 +- .../BumpDotNetVersionStepSpecs.cs | 55 +++++++++++++++++++ .../RewriteCsFilesStepSpecs.cs | 40 ++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs index 51e8dacc8..993c8bf2f 100644 --- a/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs +++ b/src/Fallout.Migrate/Steps/BumpDotNetVersionStep.cs @@ -54,14 +54,24 @@ internal sealed class BumpDotNetVersionStep : IMigrationStep /// The roll-forward policy added to global.json when the SDK is pinned and no policy is set. /// Without one, the default patch policy needs a 10.0.1xx SDK, so a machine with only a /// later feature band (for example 10.0.4xx) fails every dotnet command. + /// Fallout's own global.json uses latestMinor. latestFeature is used here + /// because it allows a later feature band but never a later minor version, so it changes less + /// in the migrated repository. /// private const string RollForward = "latestFeature"; /// - /// Matches a rollForward property inside global.json's sdk object. + /// The roll-forward policies that accept only an SDK from the pinned feature band (10.0.1xx). + /// An existing one is kept, but the user gets a warning. + /// + private static readonly string[] pinnedFeatureBandPolicies = ["patch", "latestPatch", "disable"]; + + /// + /// Matches a rollForward property inside global.json's sdk object, and + /// captures its value. /// private static readonly Regex sdkRollForwardPattern = new( - @"""sdk""\s*:\s*\{[^}]*""rollForward""", + @"""sdk""\s*:\s*\{[^}]*""rollForward""(?:\s*:\s*""(?[^""]*)"")?", RegexOptions.Compiled | RegexOptions.Singleline); /// @@ -74,12 +84,42 @@ public Task ExecuteAsync(MigrationContext context, Summary summary) foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "global.json")) { - MigrationFileOperations.ApplyRewrite(context, path, BumpSdkVersion, summary); + MigrationFileOperations.ApplyRewrite( + context, + path, + original => + { + var result = BumpSdkVersion(original); + if (result.EditCount > 0 && KeptPinnedFeatureBandPolicy(result.Content) is { } policy) + { + summary.Warnings.Add( + $"{MigrationFileOperations.RelativePath(context.RootDirectory, path)} keeps \"rollForward\": \"{policy}\". " + + $"With the new SDK pin {SdkVersion}, this policy needs a 10.0.1xx SDK, so a machine with only " + + "a later feature band (for example 10.0.4xx) fails every dotnet command. " + + $"Change it to \"{RollForward}\" unless you need exactly that feature band."); + } + + return result; + }, + summary); } return Task.CompletedTask; } + /// + /// Returns the rollForward value of when it is one of + /// , otherwise null. + /// + /// The global.json content after the SDK pin was bumped. + private static string KeptPinnedFeatureBandPolicy(string content) + { + var value = sdkRollForwardPattern.Match(content).Groups["value"]; + return value.Success && Array.Exists(pinnedFeatureBandPolicies, p => p.Equals(value.Value, StringComparison.OrdinalIgnoreCase)) + ? value.Value + : null; + } + /// /// Rewrites a build project's TargetFramework element to /// when its current moniker is behind . diff --git a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs index 82fdedc20..77fb55b75 100644 --- a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs @@ -18,8 +18,10 @@ internal sealed class RewriteCsFilesStep : IMigrationStep // 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. + // The leading `(?