migrate-static-to-wrapper について
C# プロジェクトで、静的な依存呼び出しを既存のラッパーインターフェースや組み込み抽象に機械的に置き換えるスキルです。対象をファイル、プロジェクト、名前空間に限定し、依存性注入や静的クラス向けのアンビエントシームも扱います。
主な機能
- 01指定した静的呼び出し箇所の移行
- 02ファイル、プロジェクト、名前空間単位の範囲指定
- 03コンストラクター注入と呼び出し元の更新
- 04静的クラス向けアンビエントシーム
- 05呼び出し回数、順序、型の維持
活用シーン
- 01DateTime.UtcNow を TimeProvider に置換
- 02File の呼び出しを IFileSystem 経由に変更
- 03呼び出し箇所を変えずに静的ユーティリティをテスト
- 04プロジェクトや名前空間単位で段階的に移行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dotnet/ skills --skill migrate-static-to-wrapperインストール後、Claude Code で/migrate-static-to-wrapperを入力して読み込まれたか確認します。
詳細
静的呼び出しをラッパーに移行
静的依存関係の呼び出し箇所を、注入されたラッパーインターフェースまたは組み込みの抽象化への呼び出しに、機械的なコード変換形式で置き換えます。対象範囲(単一ファイル、プロジェクト、名前空間)を限定して動作するため、段階的に移行できます。
使用する場合
- ラッパーを生成した後(
generate-testability-wrappersを使用)、または組み込みの抽象化を特定した後 - プロジェクト全体で
DateTime.UtcNow→TimeProvider.GetUtcNow()に移行する場合 - 名前空間全体で
File.*→IFileSystem.File.*に移行する場合 - 新しい抽象化を、影響を受けるクラスにコンストラクターインジェクションで追加する場合
staticユーティリティクラスにアンビエントシーム(ステップ 3)を追加してテスト可能にしつつ、既存の呼び出し箇所を変更せずにコンパイルできる状態に保つ場合- 段階的に移行する場合:一度に 1 つのプロジェクトまたは名前空間
- 依頼された移行で置換先の抽象化が指定されている場合に、影響を受けるテストをフェイクで更新する場合
使用しない場合
- ラッパーも抽象化もまだ存在せず、ゼロから設計する必要がある場合(先に
generate-testability-wrappersを使用してください)。TimeProviderやIFileSystemなどの組み込みの抽象化は、常に既存のものとして扱います。 - ユーザーが静的依存関係の検出を求めており、移行を求めていない場合(
detect-static-dependenciesを使用してください) - テストフレームワーク間の移行を行う場合(該当する移行スキルを使用してください)
- ユーザーの主な依頼が決定的な動作テストであり、本番コードに導入するシームをまだ選択していない場合(
testability-obstacleを使用してください)
クラスが
staticであることや、プロジェクトに DI コンテナーがないことは、このスキルを使わない理由にはなりません。まさにそのためにステップ 3 のアンビエントシームがあります。呼び出し箇所を変更せずにコンパイルできる状態に保つ必要がある場合は、これを使用してください。
入力
| 入力 | 必須 | 説明 |
|---|---|---|
| 静的パターン | いいえ | 依頼内容と検出された呼び出し箇所から推測します(例:DateTime.UtcNow、File.ReadAllText) |
| 置換先の抽象化 | いいえ | 依頼内容と既存のプロジェクト抽象化から推測します。名前付きまたは既存の抽象化が利用できない場合にのみ停止します |
| 範囲 | いいえ | 指定されたファイル/プロジェクト/名前空間から推測します。指定がない場合は、関連するワークスペース内で最も狭い範囲を特定します |
| インジェクション方式 | いいえ | constructor(デフォルト)、primary-constructor、または ambient |
ワークフロー
移行における譲れない境界
- 抽象化が存在しない場合は停止します。 指定されたインターフェイス/パッケージがなく、依頼で許可されているのが呼び出し箇所の置き換えのみである場合、パッケージの追加、ローカルで類似のインターフェイスの作成、本番コードの編集は行わないでください。不足している前提条件と、続行に必要な許可を正確に報告してください。
- 置き換え前の読み取りが1回なら、置き換え後の読み取りも1回です。 キャプチャしたタイムスタンプを共有するとすっきりする場合でも、呼び出しをまとめたり、前倒ししたりしないでください。
- 依頼された範囲は網羅的かつ限定的です。 対象範囲内で指定された呼び出しをすべて置き換え、隣接するメンバーやファイルには手を加えないでください。
- リポジトリを対象とする依頼では、リポジトリ上で作業する必要があります。 まず現在のワークスペースからファイルを見つけてください。ワークスペース相対の検索で対象が見つからない場合を除き、リポジトリを利用できないと主張したり、ユーザーにパスやファイルの内容を尋ねたりしないでください。差分で証明できない限り、作業を実装したと述べないでください。
- 許可されている場合、見つかったワークスペースファイルはこのターン内に完了させてください。 ホストネイティブのシェル読み取りツール(
sed/catまたはGet-Content)を使用できるのは、リーダーの利用可否、転送、またはパス正規化の失敗が確認された後、かつ正規パスが現在のワークスペース内にあると確認した後に限ります。内容の除外、権限/ポリシー、ワークスペース境界、または原因不明の読み取り失敗があれば停止してください。シェル編集へのフォールバックが許可されるのは、エディターの利用可否、転送、またはパス正規化の失敗が確認された場合のみであり、古いコンテキスト、同時変更、権限/ポリシーによる拒否、パス境界エラーの場合は許可されません。フォールバックの前に、現在のワークスペース内で正規パスを解決し、ファイルをあらためて読み込み、期待する旧テキストと正確な一致件数を指定したアンカー付き置換を必須としてください。どちらかが変わっていれば中止してください。その後、ファイルを再度開いて差分を確認し、検証してください。読み取り可能な状態で見つかったファイルをユーザーに貼り付けるよう求めたり、提案パッチを完了済みの作業として報告したりしないでください。
手順 1:前提条件の確認
コードを変更する前に、次のことを行ってください。
-
ラッパー/抽象化が存在することを確認します:インターフェイスまたは組み込みの抽象化がプロジェクトで利用できることを確認してください。
TimeProviderの場合、対象フレームワークが .NET 8 以降であること、またはMicrosoft.Bcl.TimeProviderが参照されていることを確認してください。System.IO.Abstractionsの場合、NuGet パッケージが参照されていることを確認してください。抽象化を提供し得るパッケージがあることと、このプロジェクトで抽象化をすでに利用できることは同じではありません。 -
本番環境での構成を追跡します:編集前に
Program.cs、Startup.cs、ファクトリ、手動での構築箇所を確認してください。利用可能な差し込み口への明示的な移行には、ユーザーが編集を禁止している場合を除き、対象範囲を実行可能な状態に保つために必要な、最小限のコンストラクター、呼び出し元、登録の変更も含まれます。新しい依存関係がまだ登録されていない場合は、既存の DI ホストにTimeProvider.Systemを登録してください。ライブラリに DI を導入するのではなく、手動の呼び出し元を更新してください。パッケージの不足や新しいラッパーの設計には、別途許可が必要です。注入可能なクラスだけを用意し、実際の構築経路を壊れたままにしてはいけません。 -
対象範囲内のすべてのファイルを特定します:変更する
.csファイルを一覧にしてください。テストプロジェクト、obj/、bin/、生成コードは除外してください。 -
編集前に対象メンバーの集合を確定し、件数を記録します:ユーザーが指定したメンバーを使用するか、依頼内容と見つかった呼び出し箇所から、曖昧さのない最小の集合を推定してください。その集合を記録したうえで、すべてのメンバーを検索し、ファイル/行の一覧を取得してください。編集途中で集合を変更したり、部分的な読み取りから件数を推定したりしないでください。
ステップ 2:ファイルごとの移行計画を立てる
依頼された範囲だけを正確に移行し、関連する別の箇所には手を加えないでください。 ユーザーがメンバー(DateTime.UtcNow など)を指定した場合は、そのメンバーだけを移行し、DateTime.Now などのほかのメンバーはそのままにします。ユーザーがファイルを指定した場合は、ほかのファイルには手を加えないでください。コメントや名前で意図的なものと示されている呼び出し箇所(例:// intentional local time)は、ユーザーがその箇所を明示的に指定し、セマンティクスを維持した移行を依頼しない限り、そのまま残してください。意図的に対象外としたものはすべて「対象外として残した項目」に列挙し、ユーザーがフォローアップで依頼できるようにしてください。提案するのは構いませんが、黙って対象範囲を広げてはいけません。
静的なパターンを含むファイルごとに、次の点を確認します。
- 呼び出し箇所を含むクラス — クラス宣言を特定します
- クラスに依存関係がすでに注入されているか — コンストラクターに既存の
TimeProvider、IFileSystemなどのパラメーターがあるか確認します - 各呼び出し箇所の置換式
置換マッピング
| カテゴリ | 元のコード | DI による置換 |
|---|---|---|
| 時刻 | DateTime.Now | _timeProvider.GetLocalNow().LocalDateTime |
| 時刻 | DateTime.UtcNow | _timeProvider.GetUtcNow().UtcDateTime |
| 時刻 | DateTime.Today | _timeProvider.GetLocalNow().LocalDateTime.Date |
| 時刻 | DateTimeOffset.Now | _timeProvider.GetLocalNow() |
| 時刻 | DateTimeOffset.UtcNow | _timeProvider.GetUtcNow() |
| ファイル | File.ReadAllText(path) | _fileSystem.File.ReadAllText(path) |
| ファイル | File.WriteAllText(path, text) | _fileSystem.File.WriteAllText(path, text) |
| ファイル | File.Exists(path) | _fileSystem.File.Exists(path) |
| ファイル | Directory.Exists(path) | _fileSystem.Directory.Exists(path) |
| 環境変数 | Environment.GetEnvironmentVariable(name) | _env.GetEnvironmentVariable(name) |
| コンソール | Console.WriteLine(msg) | _console.WriteLine(msg) |
| プロセス | Process.Start(info) | _processRunner.Start(info) |
各カテゴリのほかのメンバーにも同じパターンを適用します。
DateTimeKindを維持してください。これは、気付かないうちに起こるリグレッションの最も一般的な原因です。TimeProvider.GetUtcNow()/GetLocalNow()はDateTimeOffsetを返します。DateTimeに戻すときは、元のKindを必ず維持する必要があります。そうしないと、コードがコンパイルできても動作が変わってしまいます。
DateTime.UtcNowのKindはUtcです →.UtcDateTimeを使用します(Kind == Unspecifiedになる.DateTimeは使用しません)。DateTime.NowのKindはLocalです →.LocalDateTimeを使用します(.DateTimeは使用しません)。- 呼び出し箇所で
DateTimeOffsetを直接使用する場合(フィールド/パラメーター/戻り値の型がすでにDateTimeOffsetの場合)は、.UtcDateTime/.LocalDateTimeの接尾辞を削除し、DateTimeOffsetをそのまま代入します。DateTimeに戻してはいけません。対象メンバーの型に合わせます。周囲のフィールド/プロパティが
DateTimeの場合は、上記のKindを正しく維持するプロパティを介してDateTimeのままにしてください。「機械的な」移行の一環としてDateTimeOffsetに変更してはいけません。それは委譲ではなく、設計変更です。読み取りの回数、順序、場所も、値の型と同様に維持してください。 元の時刻取得は、それぞれの位置でプロバイダーからの読み取り 1 回に置き換えます。たとえ同じオブジェクト初期化子やメソッド内でも、共通の
nowローカル変数にまとめたり、キャッシュしたり、読み取りを統合したりしてはいけません。連続する 2 回のDateTime.UtcNow呼び出しは、異なる時刻を観測する可能性があります。CreatedAtとExpiresAtを同じ取得値から設定すると、動作が変わり、機械的な移行ではなくなります。値の再利用が許されるのは、元のコードですでに値を取得して再利用していた場合に限られます。
ステップ 3:コンストラクターインジェクションを追加する
クラスの既存のパターンに従って、新しい依存関係を追加します。
- プライマリコンストラクター(C# 12 以降):プライマリコンストラクターにパラメーターを追加します:
public class OrderProcessor(ILogger<OrderProcessor> logger, TimeProvider timeProvider) - 従来のコンストラクター:
private readonlyフィールドとコンストラクターパラメーターを追加し、既存のフィールド命名規則(_camelCaseまたはm_camelCase)に合わせます。
static クラス:アンビエントコンテキストを使用する(コンストラクターインジェクションなし)
static メンバーのみを持つ static クラスは、コンストラクターインジェクションを受けられません。インスタンスコンストラクターやインスタンスフィールドを追加すると、クラスが壊れてしまいます。依存関係を注入するためだけに非 static クラスへ変更しないでください。設計とすべての呼び出し箇所が変わってしまいます。代わりに、実際の実装をデフォルトとし、プロセス全体の状態を漏らすことなくオーバーライドできる、スコープ付きのアンビエントシームを適用します。
ユーザーがクラスを static のまま維持したい場合、以下のアンビエントシームが答えです。これを唯一の解決策として提示し、そのまま実装してください。「非 static クラスに変更する」や「メソッドパラメーターとして TimeProvider を渡す」といった案を同等の代替案として並べないでください。これらはクラスの設計やパブリック API を変更するものであり、依頼された内容ではありません。まずシームを提示し、その後で並列実行に関するトレードオフに触れてください。
public static class TimestampFormatter
{
private static readonly AsyncLocal<TimeProvider?> s_clock = new();
private static TimeProvider Clock => s_clock.Value ?? TimeProvider.System;
public static string Now() => Clock.GetUtcNow().ToString("O");
public static IDisposable OverrideClock(TimeProvider clock)
{
ArgumentNullException.ThrowIfNull(clock);
var previous = s_clock.Value;
s_clock.Value = clock;
return new Scope(() => s_clock.Value = previous);
}
private sealed class Scope : IDisposable
{
private Action? _restore;
public Scope(Action restore)
{
_restore = restore;
}
public void Dispose() => Interlocked.Exchange(ref _restore, null)?.Invoke();
}
}
- オーバーライドが有効でない場合、本番環境では常に
TimeProvider.Systemを参照します。起動時の変更は不要です。 - テストでは、非同期フローごとに新しいフェイク/プロバイダーを作成し、返されたスコープを破棄します。ネストしたスコープを破棄すると、外側のプロバイダーが復元されます。
AsyncLocal<T>により、awaitをまたいでも、独立して確立されたテストフローは分離されます。スロットに変更可能なスタック/リストを格納したり、複数の子フローで継承されたフェイクを変更したりしないでください。- 置換、ネストしたスコープの復元、非同期並列分離に焦点を当てたテストを追加します。ビルドのみのチェックでは、このシームが機能することを証明できません。
- 同じ形式は他の static クラス(
IFileSystem、カスタムラッパー)にも適用できます。抽象化の値をAsyncLocal<T>に格納し、実際の実装をデフォルトにして、スコープから以前の値を復元します。
ステップ 4:呼び出し箇所を置き換える
各置換を機械的に実行します。呼び出し箇所ごとに、次の手順を行います。
- static 呼び出しをラッパー呼び出しに置き換えます
- 周囲の式の構造と評価順序を維持します。元の依存関係の読み取り 1 回につき、ラッパーの読み取りも 1 回にします
- 必要な
usingディレクティブがまだない場合は追加します
編集後、同じ検索を正確に再実行し、対象範囲内のすべての本番ファイルで該当箇所がゼロであることを確認します。変更した各ファイルを開き直し、編集前の一覧と照合します。メソッドを 1 つ見落としていても、集計件数ではその証拠になりません。
また、対象範囲外の側も確認します。ユーザーが明示的に変更対象外としたすべてのファイルを検索または比較し、元の static 呼び出しと内容が維持されていることを確認します。単一ファイルの移行でも、件数が少ない場合は両方の数を報告します:対象範囲内の呼び出しを置き換えた数(N/N)と、対象範囲外として指定された呼び出しを維持した数(M)。
using ディレクティブの追加
| 抽象化 | Using ディレクティブ |
|---|---|
TimeProvider | なし(System 名前空間内) |
IFileSystem | using System.IO.Abstractions; |
IHttpClientFactory | using System.Net.Http;(通常はすでにあります) |
| カスタムラッパー | using <wrapper namespace>; |
ステップ 5:影響を受けるテストファイルを更新する
移行したクラスにテストファイルがある場合:
- コンストラクター呼び出しを更新する — テストクラスのインスタンス化に新しいパラメーターを追加します
- テスト用ダブルを使用する:
TimeProvider→Microsoft.Extensions.TimeProvider.Testingのnew FakeTimeProvider()IFileSystem→System.IO.Abstractions.TestingHelpersのnew MockFileSystem()- カスタムラッパー →
new Mock<IWrapperName>()または手書きのフェイク
元の静的な結果に依存していた、観測可能なすべての分岐を維持してください。
たとえば、Environment.GetEnvironmentVariable(name) ?? "production" を移行する場合、設定値があるケースと、フォールバックが選択される null/未設定のケースの両方をテストする必要があります。フェイクだけを使ったハッピーパスでは、機械的な移行を証明するには不十分です。
既存のテストがある場合は、そのテストフレームワークとアサーションのスタイルを維持しつつ、置き換えた依存関係が観測できるようにしてください。元の静的 API で両方の結果があり得た場合は、設定済み/フェイク値のアサーションと、フォールバックまたはエラーパスのアサーションを少なくとも 1 つずつ含めてください。古いテストをコンパイルできるようにするだけでは、移行の証拠として不十分です。
対象となる単体テストで、実ファイルや環境変数へのアクセスを行わないよう明示的に求められた場合は、編集後に一時ファイル、実ディスク、または環境変数を変更する API がテストに残っていないか検索し、プロセス全体に影響する依存関係に触れなくなったことを確認してください。依頼の対象範囲外にある意図的な統合テストは維持してください。フェイクを追加したとだけ述べるのではなく、決定論的なフェイクで設定値とフォールバック/エラーのどのケースを確認したか報告してください。
ステップ 6:ビルドを検証する
現在の対象範囲に含まれるすべての変更後、影響を受ける本番プロジェクトをビルドし、テストが存在するか変更された場合は、影響を受ける最小範囲のテストプロジェクトを実行してください:
dotnet build <project.csproj>
dotnet test <affected-test-project.csproj>
実際に確認したビルド結果を報告してください。 コマンドの終了コードが 0 の場合に限り「ビルドに成功しました」と記載してください。失敗した場合は、「assets file not found」のような restore/NuGet の失敗も含めて、失敗したことを述べ、エラーを引用したうえで、修正(dotnet restore の実行、パッケージの追加)を行うか、ユーザーに具体的な阻害要因を伝えてください。誤った成功報告は、移行が未完了であることよりも重大です。
ビルドに失敗した場合:
- using の不足:必要な
usingディレクティブを追加します - NuGet パッケージの不足:依存関係の変更が明示的に許可されている場合に限り追加します。許可されていない場合は、満たされていない前提条件を報告して作業を停止します
- テストのコンストラクター不一致:テストのインスタンス化を更新します(ステップ 5)
- 呼び出しの曖昧さ:ラッパー呼び出しを完全修飾します
要求されたテスト実行の代わりに、ビルド成功をもって済ませないでください。移行によってコンストラクター呼び出し、フェイク、プロセス全体に影響する状態、または実 I/O が変更された場合、完全な経路を証明できるのは対象を絞ったテストだけです。テストコマンドを実行できない場合は、移行の検証が完全に済んだと主張せず、その阻害要因を報告してください。
ステップ 7:変更内容を報告する
実施した内容を要約してください。本番ファイル 1 つとテストファイル 1 つだけの場合でも、対象範囲内での置換数、変更されていないことを確認した対象範囲外のファイルまたは呼び出しの名前、および対象を絞ったビルド/テストの結果を正確に記載してください:
## Migration Summary
**Pattern**: DateTime.UtcNow → TimeProvider.GetUtcNow()
**Scope**: MyProject/Services/
### Files Modified (production)
| File | Call Sites Replaced | Injection Added |
|------|--------------------:|:----------------|
| OrderProcessor.cs | 3 | Yes (constructor) |
| NotificationService.cs | 1 | Yes (primary ctor) |
### Files Modified (tests)
| File | Change |
|------|--------|
| OrderProcessorTests.cs | Added FakeTimeProvider parameter |
### Remaining (out of scope)
- MyProject/Legacy/ — 8 call sites not migrated (different namespace)
検証
- 対象範囲内のすべての呼び出し箇所が置き換えられている(見落としがない)
- 変更前と変更後の完全一致メンバー検索により、対象範囲内の出現数がゼロになったことを確認できる
- 指定されたメンバー/ファイルの範囲外の呼び出し箇所は変更されていない
- 意図的なものとして文書化された呼び出し箇所はそのままにし、ユーザーがセマンティクスを維持した移行の対象として明示的に指定した場合を除き、その旨を報告している
- 影響を受けるすべてのクラスにコンストラクターインジェクションが追加されている
- フィールド名が既存のクラス規約に従っている
- 必要な
usingディレクティブが追加されている - 必要な NuGet パッケージが参照されている
- 移行後にビルドが成功し、報告した結果が実際のコマンド終了コードと一致している
- テストファイルが適切なテストダブルで更新されている
- 既存の設定済み、フォールバック/null、およびエラーの各分岐に、引き続き直接的なテストエビデンスがある
- テストが存在する、または変更された場合、影響を受ける対象テストが正常に実行されている
- 振る舞いの変更が導入されていない(ラッパーは静的メンバーに直接委譲する)
- 静的メンバーの読み取りは1対1で置き換えられている。まとめて移動、キャッシュ、または統合されていない
-
DateTimeKindが維持されている — 以前のDateTime.UtcNowは引き続きUtc(.UtcDateTime)、以前のDateTime.Nowは引き続きLocal(.LocalDateTime)である
よくある落とし穴
| 落とし穴 | 解決策 |
|---|---|
| テストコード内の静的メンバーを置き換える | 置き換えるのは本番コード内だけにし、テストではフェイク/モックを使用します |
| 静的クラスを壊す | 静的クラスにはコンストラクターを定義できません。非静的クラスに変更するのではなく、ステップ 3 のアンビエントコンテキストのシームを使用します |
FakeTimeProvider の NuGet パッケージを入れ忘れる | テストプロジェクトに Microsoft.Extensions.TimeProvider.Testing を追加します |
DateTimeOffset から .DateTime を取得して DateTime 値を置き換える | DateTimeOffset.DateTime は Kind == Unspecified を返します。元の DateTimeKind を維持するには、以前の DateTime.UtcNow には .UtcDateTime、以前の DateTime.Now には .LocalDateTime を使用します。ユーザーから依頼があった場合に限り、フィールド/戻り値の型を DateTimeOffset に変更してください。 |
| 複数の元の時計読み取りに対し、プロバイダー値を1つ取得して使い回す | 各読み取り箇所をその場で置き換えます。見た目がすっきりしていても、読み取りの統合は観測可能なタイミングを変えます。 |
| 一度に移行しすぎる | 定義された範囲を守り、1回の実行につき1つのプロジェクトまたは名前空間を対象にします |
UtcNow だけが依頼されたのに DateTime.Now も移行する | 文字どおりの依頼に従います。他の呼び出し箇所は書き換えず、範囲外の提案として列挙します |
| 復元に失敗した後で「ビルド成功」と報告する | 終了コードと出力を確認し、実際の失敗を報告して修正するか、阻害要因として提示します |
| 呼び出し箇所のみの移行中にパッケージを追加する | 停止して承認を求めるか、先にラッパー/導入のセットアップを実行します |
| 本番環境でのコンポジションを忘れる | 呼び出し箇所を置き換える前に、DI 登録、手動生成、またはアンビエントな本番環境の既定値を確認します |