Skip to content
4 changes: 2 additions & 2 deletions .agents/skills/adding-a-migration-step/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ description: How to make fallout-migrate handle a new rename, removal, or rewrit
`fallout-migrate` runs a fixed, ordered list of `IMigrationStep` implementations,
built in `src/Fallout.Migrate/Migration.cs`. A **step is one operation over one
set of files**, and it owns the rewrite rules for those files.
`RewriteCsprojsStep` holds the rules for `*.csproj`; `RewriteCsFilesStep` holds
`RewriteCsprojsStep` holds the rules for `*.csproj` and `*.props`; `RewriteCsFilesStep` holds
the rules for `*.cs`.

## Adding a new rename, removal, or rewrite

1. Find the step for that file type — `RewriteCsprojsStep` for `*.csproj`,
1. Find the step for that file type — `RewriteCsprojsStep` for `*.csproj` and `*.props`,
`RewriteCsFilesStep` for `*.cs`, `RewriteBootstrapScriptsStep` for the
bootstrap scripts.
2. Add a `private static readonly Regex` field to that step, with a comment
Expand Down
2 changes: 1 addition & 1 deletion docs/Migration/from-nuke.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Done. Read on if anything looks unusual, or you want to know what the tool did.

## The recommended path: `fallout-migrate`

`fallout-migrate` is a global `dotnet` tool that performs the entire migration in one command. It rewrites `PackageReference`s, `using` directives, MSBuild properties, bootstrap scripts, env vars, and renames `.nuke/` → `.fallout/`. It's idempotent and has a `--dry-run` mode.
`fallout-migrate` is a global `dotnet` tool that performs the entire migration in one command. It rewrites `PackageReference`s and central package versions (`Directory.Packages.props`), `using` directives, MSBuild properties, bootstrap scripts, env vars, and renames `.nuke/` → `.fallout/`. It's idempotent and has a `--dry-run` mode.

### Step 1 — install the tool

Expand Down
44 changes: 39 additions & 5 deletions src/Fallout.Migrate/Common/MigrationFileOperations.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Fallout.Common.IO;

namespace Fallout.Migrate.Common;
Expand Down Expand Up @@ -45,18 +46,51 @@ public static void ApplyRewrite(
Func<string, RewriteResult> rewriter,
Summary summary)
{
string original;
if (TryRead(context, path, summary, out string original))
{
ApplyRewrite(context, path, original, rewriter, summary);
}
}

/// <summary>
/// Reads a file and reports an unreadable file as a warning.
/// </summary>
/// <remarks>
/// Only file access failures are handled here: an <see cref="IOException"/>, such as a missing file,
/// and an <see cref="UnauthorizedAccessException"/>, such as a directory or a denied permission.
/// Exceptions from rewriting or writing a file propagate to the caller.
/// A failed read must be skipped, not classified as an empty file.
/// </remarks>
public static bool TryRead(MigrationContext context, AbsolutePath path, Summary summary, out string content)
{
try
{
original = path.ReadAllText();
// Not AbsolutePath.ReadAllText: it throws an ArgumentException for a missing file before reading.
content = File.ReadAllText(path, Encoding.UTF8);
return true;
}
catch (IOException ex)
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
summary.Warnings.Add($"could not read {RelativePath(context.RootDirectory, path)}: {ex.Message}");
return;
content = string.Empty;
return false;
}
}

var result = rewriter(original);
/// <summary>
/// Rewrites previously read content, records the edits, and writes them unless this is a dry run.
/// </summary>
/// <remarks>
/// Use this overload when classification and rewriting must use the same file contents.
/// </remarks>
public static void ApplyRewrite(
MigrationContext context,
AbsolutePath path,
string original,
Func<string, RewriteResult> rewriter,
Summary summary)
{
RewriteResult result = rewriter(original);
if (result.EditCount == 0)
{
return;
Expand Down
115 changes: 115 additions & 0 deletions src/Fallout.Migrate/Common/MsBuildPatterns.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Text.RegularExpressions;

namespace Fallout.Migrate.Common;

/// <summary>
/// Text patterns used to migrate MSBuild files without changing their XML formatting.
/// </summary>
/// <remarks>
/// These patterns are case-sensitive and match the supported MSBuild spellings, not arbitrary XML.
/// Attribute patterns expect double quotes with no whitespace around the equals sign.
/// </remarks>
internal static class MsBuildPatterns
{
/// <summary>
/// Finds the opening tag of a NuGet package element: <c>PackageReference</c>,
/// <c>PackageVersion</c>, or <c>PackageDownload</c>.
/// </summary>
/// <remarks>
/// The match is the whole opening tag, including all attributes. It does not capture the package
/// name or version separately. A <c>&gt;</c> inside an attribute value ends the match early.
/// </remarks>
public static readonly Regex PackageItem = new(
"""<(?:PackageReference|PackageVersion|PackageDownload)\b[^>]*>""",
RegexOptions.Compiled);

/// <summary>
/// Finds the <c>Nuke.</c> prefix in an <c>Include</c>, <c>Update</c>, or <c>Remove</c> attribute.
/// </summary>
/// <remarks>
/// Only the prefix is matched, and it must be followed by an uppercase letter.
/// The attribute name and the rest of the item name stay unchanged.
/// </remarks>
public static readonly Regex NukeItemName =
new("""(?<=\b(?:Include|Update|Remove)=")Nuke\.(?=[A-Z])""", RegexOptions.Compiled);

/// <summary>
/// Finds a literal version in a <c>Version</c> or <c>VersionOverride</c> attribute.
/// </summary>
/// <remarks>
/// Quotes and optional exact-version brackets are outside the match, so <c>[10.1.0]</c>
/// keeps its brackets. MSBuild variable references and ranges containing commas or parentheses
/// are excluded. Replacing only one bound of a range would leave an invalid version.
/// </remarks>
public static readonly Regex LiteralVersion = new(
"""(?<=\bVersion(?:Override)?="\[?)(?!\$\()[^"\[\],()]+(?=\]?")""",
RegexOptions.Compiled);

/// <summary>
/// Finds a <c>PackageReference</c> or <c>PackageVersion</c> opening tag whose
/// <c>Version</c> is a single MSBuild variable reference, such as <c>$(ToolsVersion)</c>.
/// </summary>
/// <remarks>
/// The whole tag is matched. The <c>include</c> group captures the package name, and the
/// <c>variable</c> group captures the name inside <c>$(...)</c>. Attributes may appear in either
/// order. This does not match <c>VersionOverride</c> or <c>PackageDownload</c>.
/// A <c>&gt;</c> inside an attribute value ends the tag match early.
/// </remarks>
public static readonly Regex VariableVersionItem = new(
"""<(?:PackageReference|PackageVersion)\b(?=[^>]*\bInclude="(?<include>[^"]+)")(?=[^>]*\bVersion="\$\((?<variable>[^)]+)\)")[^>]*>""",
RegexOptions.Compiled);

/// <summary>The package name in a <see cref="VariableVersionItem"/> match.</summary>
public static Group PackageNameOf(Match variableVersionItem) => variableVersionItem.Groups["include"];

/// <summary>The version variable name, without <c>$(...)</c>, in a <see cref="VariableVersionItem"/> match.</summary>
public static Group VariableNameOf(Match variableVersionItem) => variableVersionItem.Groups["variable"];

/// <summary>
/// The MSBuild properties Fallout renamed from <c>Nuke*</c> to <c>Fallout*</c>, without their prefix.
/// </summary>
private static readonly string[] RenamedNukePropertySuffixes =
[
"Version", "RootDirectory", "ScriptDirectory", "BaseDirectory", "BaseNamespace",
"UseNestedNamespaces", "RepositoryUrl", "UpdateReferences", "ContinueOnError", "TaskTimeout",
"Timeout", "TasksEnabled", "DefaultExcludes", "ExcludeBoot", "ExcludeConfig", "ExcludeLogs",
"ExcludeDirectoryBuild", "ExcludeCi", "SpecificationFiles", "ExternalFiles", "TasksAssembly",
"TasksDirectory"
];

/// <summary>
/// Finds the <c>Nuke</c> prefix of known MSBuild properties that were renamed in Fallout.
/// </summary>
/// <remarks>
/// Only the prefix is matched. <see cref="RenamedNukePropertySuffixes"/> limits the rename to known
/// names, including both element names and references such as <c>$(NukeVersion)</c>.
/// Unrelated user-defined names such as <c>NukeCustomProperty</c> are excluded.
/// </remarks>
public static readonly Regex NukePropertyName = new(
$"""\bNuke(?=(?:{string.Join('|', RenamedNukePropertySuffixes)})\b)""",
RegexOptions.Compiled);

/// <summary>
/// Finds a complete <c>NukeTelemetryVersion</c> or <c>FalloutTelemetryVersion</c> property line.
/// </summary>
/// <remarks>
/// The <c>tag</c> group requires matching opening and closing names. Indentation and a trailing
/// newline are included. The value must be on one line. Telemetry was removed from Fallout,
/// so the whole property is deleted rather than renamed.
/// </remarks>
public static readonly Regex TelemetryVersionProperty = new(
"""^[ \t]*<(?<tag>(?:Nuke|Fallout)TelemetryVersion)>.*?</\k<tag>>[ \t]*\r?\n?""",
RegexOptions.Compiled | RegexOptions.Multiline);

/// <summary>
/// Finds a self-closing <c>System.Security.Cryptography.Xml</c> package reference line.
/// </summary>
/// <remarks>
/// Attributes may appear in any order. Indentation and a trailing newline are included.
/// The migration removes this direct pin only from project files, allowing Fallout's transitive
/// dependency version to win. A pin in a shared props file is kept because it affects other projects.
/// </remarks>
public static readonly Regex CryptographyXmlPackageReference = new(
"""^[ \t]*<PackageReference\b(?=[^>]*\bInclude="System\.Security\.Cryptography\.Xml")[^/]*/>[ \t]*\r?\n?""",
RegexOptions.Compiled | RegexOptions.Multiline);
}
83 changes: 83 additions & 0 deletions src/Fallout.Migrate/Common/VersionVariables.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace Fallout.Migrate.Common;

/// <summary>
/// The version variable names found in the files of one migration run: those used only by
/// <c>Nuke.*</c>/<c>Fallout.*</c> packages and those shared with unrelated packages.
/// </summary>
internal sealed class VersionVariables
{
public const string FalloutVersionVariable = "FalloutVersion";

/// <summary>
/// Finds an opening <c>NukeVersion</c> or <c>FalloutVersion</c> property tag.
/// </summary>
/// <remarks>
/// Both spellings count because the rewrite renames <c>NukeVersion</c> to <c>FalloutVersion</c>.
/// The match is case-sensitive and permits whitespace before <c>&gt;</c>, but no attributes.
/// </remarks>
private static readonly Regex falloutVersionPropertyPattern =
new("""<(?:Nuke|Fallout)Version\s*>""", RegexOptions.Compiled);

/// <summary>
/// Property names whose values should be updated to the current Fallout version.
/// </summary>
/// <remarks>
/// Starts with <c>FalloutVersion</c> so an existing dedicated property is updated even when
/// no package references it before the rewrite.
/// </remarks>
public HashSet<string> ToBump { get; } = [FalloutVersionVariable];

/// <summary>
/// Variable names used by both Fallout packages and unrelated packages.
/// </summary>
/// <remarks>
/// Updating these properties would change unrelated package versions. Instead, Fallout package
/// references are redirected to <c>$(FalloutVersion)</c> and the shared property value is kept.
/// </remarks>
public HashSet<string> Ambiguous { get; } = [];

/// <summary>
/// Whether any file already defines <c>FalloutVersion</c>, including its old <c>NukeVersion</c> name.
/// </summary>
public bool DefinesFalloutVersion { get; private set; }

/// <summary>
/// Classifies version variable names over all files before any of them are rewritten.
/// </summary>
/// <remarks>
/// Classification uses names across the repository, without evaluating MSBuild imports or scopes.
/// A name used by Fallout in project A and by another package in project B is treated as shared,
/// even if the projects define separate properties. This can introduce extra reference changes,
/// but avoids updating unrelated package versions.
/// </remarks>
public static VersionVariables Collect(IEnumerable<string> fileContents)
{
var result = new VersionVariables();
var falloutVariables = new HashSet<string>();
var nonFalloutVariables = new HashSet<string>();

foreach (string content in fileContents)
{
foreach (Match match in MsBuildPatterns.VariableVersionItem.Matches(content))
{
string packageName = MsBuildPatterns.PackageNameOf(match).Value;
bool isFalloutPackage = packageName.StartsWith("Nuke.", StringComparison.Ordinal) ||
packageName.StartsWith("Fallout.", StringComparison.Ordinal);
(isFalloutPackage ? falloutVariables : nonFalloutVariables).Add(MsBuildPatterns.VariableNameOf(match).Value);
}

result.DefinesFalloutVersion |= falloutVersionPropertyPattern.IsMatch(content);
}

foreach (string variableName in falloutVariables)
{
(nonFalloutVariables.Contains(variableName) ? result.Ambiguous : result.ToBump).Add(variableName);
}

return result;
}
}
Loading
Loading