Migration Engine
WrapGod.Migration.Engine is the Roslyn-backed rewrite pipeline that consumes a
MigrationSchema (authored or generated via WrapGod.Migration) and applies its rules
against C# source files.
Overview
The migration engine automates the mechanical part of upgrading a codebase from one version of a library to another. Given a migration schema that describes the breaking changes (renames, moves, removals, restructurings), the engine:
- Walks every
.csfile in your project - Parses each file into a Roslyn syntax tree (no compilation needed)
- Applies each applicable rule through its dedicated rewriter
- Preserves all whitespace and comments
- Injects any missing
usingdirectives for new namespaces - Writes the result back to disk atomically
The engine is purely syntactic — it never requires a compilable project or a SemanticModel. It handles broken code gracefully, which matters when part of your upgrade is fixing the very errors the migration introduces.
When to use the migration engine
| Scenario | Recommendation |
|---|---|
| Upgrading a NuGet package that shipped a migration schema | Use migrate apply directly |
| Upgrading a package with no schema available | Use migrate generate to draft a schema first |
| Bulk-renaming types across a large codebase after an internal refactor | Author a schema manually; run migrate apply |
| Complex structural changes (method splits, parameter objects) | Author B-level rules; run migrate apply with confidence: "manual" first |
| Programmatic integration (CI, build scripts) | Use the MigrationEngine or StatefulMigrationEngine API directly |
Architecture Diagram
┌─────────────────────────────────────────────────────────────────────────┐
│ User inputs │
│ ───────── │
│ MigrationSchema JSON Source files (.cs) │
└──────────┬──────────────────────────────────┬───────────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌────────────────────────┐
│ MigrationSchema │ │ IMigrationFileSystem │
│ (deserialized) │ │ ReadAllText() │
└──────────┬──────────┘ └──────────┬─────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ MigrationEngine │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Per-file loop (deduplicated paths) │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ CSharpSyntaxTree.ParseText() │ │ │
│ │ │ │ │ │ │
│ │ │ Manual-rule scan → MatchedFiles │ │ │
│ │ │ │ │ │ │
│ │ │ Auto-rule chain (sequential): │ │ │
│ │ │ rule1 → IRuleRewriter.TryRewrite() │ │ │
│ │ │ rule2 → IRuleRewriter.TryRewrite() │ │ │
│ │ │ ... │ │ │
│ │ │ │ │ │ │
│ │ │ Using injection post-pass │ │ │
│ │ │ │ │ │ │
│ │ │ WriteAllTextAtomic() (Apply mode only) │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
│ MigrationResult aggregate │
└──────────────────────────┬──────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Applied[] Skipped[] Manual[]
Full Lifecycle
For each file in the deduplicated filePaths set:
Read —
IMigrationFileSystem.ReadAllText(filePath).IOException→ records aSkippedRewritewithRuleId = "<io>"and continues to the next file.Parse —
CSharpSyntaxTree.ParseText(text). Syntax-only; no project references or compilation required. Broken code is accepted.Manual scan — For every rule with
Confidence = Manual, the matchingIRuleRewriterperforms a read-only tree-walk to identify whether the file contains matching patterns. Matches populateManualRewrite.MatchedFiles. Nothing is written.Auto-rule chain — For every rule with
Confidence = AutoorVerified, in schema order:- Lookup the
IRuleRewriterwhoseKindmatchescamelCase(rule.Kind). - If no rewriter is registered → record
SkippedRewrite("no rewriter for kind '…'"). - If found → call
TryRewrite(currentRoot, rule, ctx). The returned node replacescurrentRootfor the next rule in the chain. - This sequential strategy lets each rule see the output of previous rules (e.g.,
Foo → BarthenBar → Bazin two passes).
- Lookup the
Using injection — After all rules run, inspect the final
CompilationUnitSyntax. For any namespace introduced by an appliedChangeTypeReferenceRule,RenameNamespaceRule, orRenameTypeRulethat is not already present in the file, inject ausingdirective at the top.Write — In
Applymode (not dry-run), write the modified file atomically: write to{path}.tmp, thenFile.Move(tmp, path, overwrite: true). InDryRunmode, skip the write.
After all files: aggregate Applied, Skipped, Manual, and RewrittenFiles into a MigrationResult.
Public contracts
The project (net10.0, depends on Microsoft.CodeAnalysis.CSharp) exposes the following
types under the WrapGod.Migration.Engine namespace:
IRuleRewriter— interface implemented by every concrete rewriter.Kindidentifies theMigrationRuleKind(camelCase) the rewriter handles;TryRewritereturns a rewrittenSyntaxNodewith trivia preserved, ornullwhen the rule does not match.RewriteContext— per-file context that records the audit trail. Exposes the sourceFilePath, accumulatesAppliedandSkippedcollections (externally immutable viaReadOnlyCollection<T>), and offersRecordApplied/RecordSkippedfor rewriters to log outcomes.AppliedRewrite— sealed positional record describing a successful rewrite (RuleId,File,Line,OriginalText,ReplacedWith); value-equal across instances.SkippedRewrite— sealed positional record describing a rewrite that was evaluated but not applied (RuleId,File,Line,Reason).ManualRewrite— sealed positional record forManual-confidence rules that require human intervention (RuleId,Note,MatchedFiles).MigrationResult— sealed aggregator class withApplied,Skipped,Manual,RewrittenFiles, andDryRunproperties. Provides aggregate*Countproperties and a staticEmptyfactory.TriviaPreservation— extension helper exposingWithReplacedToken<T>, which replaces aSyntaxTokenwhile copying the leading and trailing trivia from the original token onto the replacement.
The IRuleRewriter contract
using Microsoft.CodeAnalysis;
using WrapGod.Migration;
using WrapGod.Migration.Engine;
public interface IRuleRewriter
{
/// <summary>The MigrationRuleKind discriminator this rewriter handles (camelCase).</summary>
string Kind { get; }
/// <summary>
/// Returns a rewritten node (with trivia preserved via WithTriviaFrom) when the rule
/// applies, or null to leave the node unchanged.
/// </summary>
SyntaxNode? TryRewrite(SyntaxNode node, MigrationRule rule, RewriteContext ctx);
}
Example implementation skeleton
public sealed class MyRenameRewriter : IRuleRewriter
{
public string Kind => "renameType";
public SyntaxNode? TryRewrite(SyntaxNode node, MigrationRule rule, RewriteContext ctx)
{
if (rule is not RenameTypeRule typed)
return null;
if (node is not IdentifierNameSyntax id || id.Identifier.ValueText != typed.OldName)
return null;
var replacement = SyntaxFactory.IdentifierName(typed.NewName);
// Trivia contract — every replacement MUST preserve leading/trailing trivia.
var rewritten = replacement.WithTriviaFrom(id);
ctx.RecordApplied(
rule,
id.Span,
originalText: id.ToString(),
replacementText: rewritten.ToString(),
line: id.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
return rewritten;
}
}
Contract rules
- Trivia must be preserved. Every rewritten node MUST copy trivia from the
original via
WithTriviaFrom(or the token-levelTriviaPreservation.WithReplacedToken). Skipping this corrupts whitespace and comments in the output. - Return
nullwhen the rule does not match. Never throw, never return a partially-modified node. Ambiguous matches should record aSkippedRewriteand returnnull. - No semantic lookup. The engine works on syntax only so it can operate on
broken code; rewriters must not require a
SemanticModel.
Rewriters
Eleven concrete IRuleRewriter implementations are available. See Rewriters
for the full catalogue, per-rewriter contracts, and before/after examples.
A-level (#195)
| Class | Kind |
Rule type |
|---|---|---|
RenameTypeRewriter |
renameType |
RenameTypeRule |
RenameNamespaceRewriter |
renameNamespace |
RenameNamespaceRule |
RenameMemberRewriter |
renameMember |
RenameMemberRule |
ChangeParameterRewriter |
changeParameter |
ChangeParameterRule |
RemoveMemberRewriter |
removeMember |
RemoveMemberRule |
AddRequiredParameterRewriter |
addRequiredParameter |
AddRequiredParameterRule |
ChangeTypeReferenceRewriter |
changeTypeReference |
ChangeTypeReferenceRule |
B-level structural (#202)
| Class | Kind |
Rule type |
|---|---|---|
SplitMethodRewriter |
splitMethod |
SplitMethodRule |
ExtractParameterObjectRewriter |
extractParameterObject |
ExtractParameterObjectRule |
PropertyToMethodRewriter |
propertyToMethod |
PropertyToMethodRule |
MoveMemberRewriter |
moveMember |
MoveMemberRule |
Orchestrator — MigrationEngine (#196)
MigrationEngine is the top-level class that connects a MigrationSchema to a set of
source files and drives the rewrite pipeline from start to finish.
Public API
namespace WrapGod.Migration.Engine;
public sealed class MigrationEngine
{
// Primary constructors
public MigrationEngine(IEnumerable<IRuleRewriter> rewriters);
// Convenience: pre-loads all 11 rewriters (7 A-level + 4 B-level).
public static MigrationEngine CreateDefault();
// Apply all auto rules to files, write results to disk.
public MigrationResult Apply(MigrationSchema schema, IEnumerable<string> filePaths);
// Same pipeline, no disk writes; RewrittenFiles is still populated.
public MigrationResult DryRun(MigrationSchema schema, IEnumerable<string> filePaths);
}
Lifecycle diagram
foreach file in filePaths (deduplicated):
1. Read source text via IMigrationFileSystem.ReadAllText()
└─ IOException → record SkippedRewrite("<io>", …) and continue
2. CSharpSyntaxTree.ParseText() — syntax-only, no compilation
3. Manual rules: scan file with matching rewriter, collect MatchedFiles
4. Auto rules (schema order, Option A — PatternKit sequential chain):
for each rule:
- lookup IRuleRewriter by camelCase(rule.Kind)
- missing → SkippedRewrite("no rewriter for kind '…'", …)
- found → TryRewrite(currentRoot, rule, ctx) → update currentRoot
5. Using injection: add missing using directives for introduced namespaces
6. If modified (Apply mode): IMigrationFileSystem.WriteAllTextAtomic()
Aggregate Applied, Skipped, Manual into MigrationResult
Composition strategy
Option A — sequential chain is implemented with PatternKit's
AsyncActionChain<T> primitive and was chosen over Option B (single-pass dispatcher)
because:
- Every existing
IRuleRewriteralready encapsulates its ownCSharpSyntaxRewritertree-walk; there is nothing to refactor. - Sequential chaining lets each rule see the output of the previous rule, enabling
transformation chains such as
Foo → BarthenBar → Baz. - The perf budget (<5 s for 1 000 files) is met with room to spare (~620 ms measured in CI on this machine).
- Option B can be introduced incrementally if profiling identifies a bottleneck.
The public MigrationEngine, IRuleRewriter, and RewriteContext contracts remain
unchanged. PatternKit owns only the per-file auto-rule composition: unknown rule kinds
are still reported once per schema up front, already-applied rules are skipped, and each
matching rule receives the root produced by the previous rule.
Each (file, rule) pair performs one tree-walk. For a schema with N rules and F files:
total walks = N × F.
File I/O abstraction (IMigrationFileSystem)
The internal IMigrationFileSystem interface is injected for testability via the
internal MigrationEngine(IEnumerable<IRuleRewriter>, IMigrationFileSystem) constructor
(exposed to WrapGod.Tests via InternalsVisibleTo).
The default implementation, RealFileSystem, uses System.IO.File.ReadAllText and
an atomic write: write to path + ".tmp", then File.Move(tmp, path, overwrite: true).
Cross-namespace using injection
After all per-file rewrites complete, the orchestrator inspects the final
CompilationUnitSyntax and adds using directives for any namespace introduced by a
ChangeTypeReferenceRule, RenameNamespaceRule, or RenameTypeRule that was actually
applied and whose namespace is not already present. This closes the #195 deferred
should-fix.
Performance
| Scenario | Result (Release) |
|---|---|
| 1 000 synthetic files, 5 rules | ~620 ms |
| Per-file overhead | ~0.6 ms |
| Budget (hard gate) | 10 s |
| Target | 5 s |
Manual-confidence rules
Rules with Confidence = RuleConfidence.Manual are never applied automatically.
The engine runs a syntax-only "would this rule match?" scan and populates
MigrationResult.Manual[].MatchedFiles with every file where the pattern was
detected. The Applied list contains no entries for manual rules.
Extension Points
Writing a Custom IRuleRewriter
If the eleven built-in rewriters do not cover your transformation, you can implement IRuleRewriter yourself:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using WrapGod.Migration;
using WrapGod.Migration.Engine;
public sealed class MyCustomRewriter : IRuleRewriter
{
// Must match the camelCase "kind" string in your schema JSON.
public string Kind => "myCustomKind";
public SyntaxNode? TryRewrite(SyntaxNode node, MigrationRule rule, RewriteContext ctx)
{
// Cast to your rule type (define it in WrapGod.Migration or a sibling project).
if (rule is not MyCustomRule typed)
return null;
// Only handle the node type you care about.
if (node is not SomeSpecificSyntaxNode target)
return null;
// Perform the transformation.
var replacement = BuildReplacement(target, typed);
// REQUIRED: preserve trivia so whitespace and comments are not corrupted.
replacement = replacement.WithTriviaFrom(target);
// Record the outcome.
ctx.RecordApplied(
rule,
target.Span,
originalText: target.ToString(),
replacementText: replacement.ToString(),
line: target.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
return replacement;
}
}
Key obligations:
| Obligation | Why |
|---|---|
Preserve trivia via WithTriviaFrom |
Corrupting trivia mangling whitespace or stripping comments |
Return null when no match |
The orchestrator uses null to skip file writes when no rule matched |
Record SkippedRewrite for ambiguous matches |
Ambiguous matches should be visible to the author — don't silently skip |
No SemanticModel access |
The engine never has a compilation; rewriters must be syntax-only |
| Never throw | Exceptions propagate to the orchestrator and abort the entire file |
Registering a Custom Rewriter
Inject your rewriter into a MigrationEngine instead of using CreateDefault():
var engine = new MigrationEngine(
MigrationEngine.CreateDefault().Rewriters
.Append(new MyCustomRewriter()));
Or build from scratch if you only need a subset of rewriters:
var engine = new MigrationEngine([
new RenameTypeRewriter(),
new RenameMemberRewriter(),
new MyCustomRewriter(),
]);
Injecting a Custom File System
For testing or virtual file system scenarios, pass an IMigrationFileSystem via the internal constructor (exposed to WrapGod.Tests via InternalsVisibleTo):
// In test code
var engine = new MigrationEngine(
rewriters: [new RenameTypeRewriter()],
fileSystem: new InMemoryFileSystem(files));
IMigrationFileSystem exposes ReadAllText(path) and WriteAllTextAtomic(path, content). The default RealFileSystem implementation uses System.IO.File with atomic writes.
State-tracking (idempotent re-runs)
StatefulMigrationEngine (namespace WrapGod.Migration.Engine) wraps the
base engine with persistent state so that apply runs are idempotent. The
state is stored in a sibling file next to the schema (e.g.
schema.json.state.json). See Migration State for the full
specification, hash semantics, and API reference.
See Also
- Migration Schema — schema format and all rule kinds
- Authoring a Migration Schema — guide for library maintainers
- Rewriters — per-rewriter documentation with before/after examples
- Migration State — idempotency, state file format, hash semantics
- Applying Migrations — consumer workflow
- Back to Migration index