読みやすい sans-IO 状態遷移マシン
sans-IO ライブラリは、I/O を外部に追い出した状態遷移マシンです。呼び出し側は入力(handle_*、それぞれに now を伴う)を与え、出力(poll_transmit、poll_event、poll_timeout)を吸い出し、ソケットとタイマーを所有します。このようなコードは、人々がその動作を理解できる場合にのみ価値があります。つまり、次の保守担当者、ドライバの作成者、RFC に対して検証するレビュー担当者などです。それぞれが、次の4つの質問に素早く答えられる必要があります。
- 状態: 各長期生存エンティティ(接続、クエリ、ストリーム、セッション)はどの状態をとり得るか、そして各状態にはどのデータが存在するか?
- 遷移: どの入力がそれをある状態から別の状態に移し、その移動はコード内のどこで発生するか?
- 出力: 各移動は何をキューに入れるか(送信、イベント、書き込み)、そしてその順序は?
- 時間: いつ起床する必要があり、各期限を設定するルールは何か?
あなたの仕事は、マシンが以前とまったく同じ動作を続けながら、これらの答えを型とコード内で可視化することです。ワーキングツリーを編集し、最後にサマリーを出力します。
<skill> はこのスキルベースディレクトリ、つまりこの SKILL.md を保持するフォルダを意味します。
基本ルール
-
動作は同一に保つ。 sans-IO クレートでは、動作には一見表面的に見えるものも含まれます。
- 出力がキューに入れられる順序
- マップの反復順序(
BTreeMapまたは挿入順序を保持するVecが、誰が最初に行くかを決定する) poll_timeoutが返すインスタント- 空の書き込みや no-op イベントがまったく出力されるかどうか
- エンティティが失敗またはクローズするエラー
- スナップショット、ログアサーション、エラーメッセージが出力する場合に、変更したフィールドを持つ
#[derive(Debug)]型の出力
パブリック API を通過するテストとすべてのスナップショットは、変更なしにパスする必要があります。編集が必要な場合、そのステップは動作を変更したことになります。元に戻してください。クレート内部のプライベートな項目を名前指定する単体テストは、コードと同じ機械的な編集が必要になる場合があります(フラグがバリアントになった、ヘルパーがリネームされたなど)。その編集のみを行い、各アサーションがチェックする内容は維持し、サマリーにそのテストをリストアップしてください。
-
パブリック API は変更しない。
pubな項目の名前、シグネチャ、バリアント、フィールドはそのままにします。ドライバと下流クレートがそれらに依存しているためです(そしてセマンティックバージョニングにも)。pub(crate)とプライベートな項目は変更可能です。API の改善点はサマリーに提案として記載してください。確認方法はステップ5にあります。 -
パフォーマンスを読みやすさのために犠牲にしない。 入力ごと(パケット、メッセージ、クエリごと)に実行されるパス上で、新しいクローン、アロケーション、またはマップに対する余分なパスを追加しないでください。
mem::replaceやmem::takeはコストフリーです。借用チェッカーを回避するためにVecをクローンすることはコストフリーではありません。 -
小さなステップで作業する。 各ステップはビルドされ、チェックをパスするため、失敗したチェックは1つの変更を指し示し、差分を少しずつレビューできます。
-
プロジェクトに従う。 編集前に、そのエージェントとコントリビューター向けドキュメント(AGENTS.md、CLAUDE.md、CONTRIBUTING)、リント設定、CI ワークフローを読んでください。周囲のコメントの密度やイディオムに合わせてください。プロジェクトがコードと連動してドキュメントを維持している場合(ライフサイクルテーブル、関数をマッピングするコメント、仕様カバレッジテーブル)、同じステップでそれらも更新してください。
変更に合わせて作業規模を調整する
セーフティネットは、既存のテストが見逃すであろう動作変更をキャッチするために存在します。そのため、計画している変更に合わせて規模を調整してください。
- 小規模。 1つのハンドラまたはいくつかのヘルパーで、状態を保持するフィールドに変更はありません。プロジェクトのチェックを変更前と変更後に1回ずつ実行し、以下で大規模とマークされたものはすべてスキップします。サマリーでは、触れたエンティティのみをマッピングします。
- 中規模。 1つのモジュール内のいくつかの遷移または出力ヘルパー。各ステップの前と最後にチェックを実行します。既存のテストが変更するパスの正確な出力を固定していない場合にのみ、差分ハーネスを構築し、そのパスのみに対して行います。ファジング、追加のプロパティテストケース、ベンチマークは不要です。プロジェクト自身のチェックを通常の設定で実行すれば、ここでは適切な重みです。
- 大規模。 フラグがフェーズ enum に置き換えられる、状態フィールドの多くの書き込みが遷移に統合される、ほとんどのハンドラが再形成される。ワークフロー全体を使用します。
マッピング(ステップ2)の後に規模を決定してください。途中で変更が拡大した場合は、規模を1つ上げ、その旨をサマリーに記載してください。
ワークフロー
1. セーフティネットを設定する
最初の編集の前に行い、後続のすべての失敗が既存のものではなく、あなたのものであることを確認します。
- ベースを記録する。
git statusを実行し、ベースコミットを記録します。以降のすべての比較はこれに対して行われます。触れようとしているファイルにすでにコミットされていない変更がある場合は、あなたの編集をそれらに混ぜる前にユーザーに伝えてください。それらの上で進める場合は、git stash createをベースとして使用します。これはツリーに触れずにコミットとしてそれらを記録します。 - チェックコマンドを1つ修正する。 プロジェクト自身のものがあればそれを使用します(CI ワークフロー、justfile、xtask、エージェントドキュメントのコマンド)。それ以外の場合は、バンドルされたゲートを使用します。
<skill>/scripts/check.sh <repo> default all。ゲートはフォーマットを変更せずにチェックし、次に各機能セットに対して clippy とテストを実行し、何かが失敗した場合に非ゼロで終了します。CI が使用する機能セットをリストアップします(例:default all none tls)。変更するパッケージとそれに依存するメンバにスコープを絞るにはCHECK_PACKAGES="-p <crate> -p <dependent>"を使用し、最後にワークスペース全体を1回実行します。CI が他にも何かを実行している場合(MSRV チェック、追加のリントフラグ)、それも一緒に実行します。 - ベースラインがグリーンでない場合、修正しない。 どのチェックがどのように失敗するかを記録し、ユーザーに伝え、その後は同じ失敗を期待し、新しい失敗がないことを確認します。編集するファイルのみをフォーマットします。環境から来る失敗(nightly 専用フラグを持つグローバルな cargo 設定、欠落したツールチェーンやツール)は、「実行できなかった」として Verification の下に理由と共に記録します。リポジトリに CI がない場合は、
rust-version、edition、使用されている言語機能からツールチェーンの下限を取得し、何を実行できて何を実行できなかったかを述べてください。 - 大規模な変更: 差分ハーネスを構築する 既存のテストが変更するパスの正確な出力を固定していない場合。固定とは、出力シーケンスと期限を逐語的にアサートするトレーステストまたはスナップショットテストを意味します。最終状態のみのアサーションはカウントしません。ハーネスは、ベースとワーキングツリーを同じランダムな呼び出しで並行して実行し、すべての出力を比較します。
<skill>/scripts/difftest_scaffold.pyは、リポジトリ外にクレートペアとテンプレートを作成します。テンプレートを埋めるのが実際の作業なので、そのための予算を立ててください。最初に references/differential.md を読んでください。それに依存する前に、1行の変異を仕込んだものを1つキャッチできることを確認してください。1つで十分です。 - ハーネスをリファクタリングよりも安価に保つ。 再構築するパスを駆動し、クレート全体を駆動しないでください。パスをカバーするコストがその変更よりも高い場合は、代わりにそのギャップを Verification の下に報告し、そのパスの既存のテストに依存してください。例:TLS または暗号化の背後にあるパス、正規化する必要がある非決定論、シミュレートする必要があるピア。
- 大規模な変更のみ: 遅いチェックをより多くのケースで1回実行する。 プロパティテストを、数分で完了するサイズで実行します(デフォルトの10倍、またはケースが安価な場合は
PROPTEST_CASES=10000)、最後にもう一度実行します。変更するコードに到達する場合にのみ、ファズターゲットを30〜60秒のタイムボックスで実行します。ベンチマークは、変更が入力ごとのパスに作業を追加する場合にのみ実行します。制御フローの再構築には必要ありません。 - 変更後も残すべきカバレッジギャップを埋める。 再構築するパスにカバレッジがなく、ハーネスを構築しない場合は、最初にその現在の出力を固定するテストを追加します。ハーネスを構築する場合は、それはリファクタリングのためのギャップをカバーします。ハーネスが明らかにした動作で、維持する価値があるもの(意図的に保持した奇妙な動作)に対してのみテストを追加します。新しいテストは新しいファイルまたはモジュールに、独自のステップとして追加し、既存のテストは変更しないままにします。例外は、単一クレートのテストレイアウト(
tests/main.rsなど)が必要とする1つのmod行です。
2. マシンをマッピングする
リクエストの対象となるエンティティ、および計画された変更が触れる可能性のあるエンティティをマッピングします。リクエストがどの部分が重要かを指定していない大規模なクレートでは、ユーザーが名前を指定したエンティティを選択し、何を省略したかを述べてください。何かを変更する前にマップを書き留めてください(スクラッチファイルで構いません)。これはサマリーの状態マップとなり、多くの場合モジュールドキュメントになります。
- エンティティ。 スコープ内の各エンティティについて、そのフィールドをリストアップし、それらが意味を持つフェーズごとにグループ化します。それらの間でフェーズをエンコードする
boolとOptionの組み合わせが主なターゲットです。 - 入力。 外部データまたは
nowを取るすべてのpub fn、およびそれぞれが何をトリガーできるか。 - 書き込み。 各状態フィールドについて、それを代入するすべての場所(
.field =、mem::replace、.take()、+= 1を検索)。多くの書き込みがあるフィールドは、ファイル全体に分散した遷移です。 - 出力。 出力キューへのすべてのプッシュ、および
poll_timeoutが読み取るすべての期限。 - 不変条件。 常に保持される必要があるもの:正確に1つのマップ内のID、何かが待機するときに設定される期限、存在する参照された接続。
- 奇妙な動作。 偶発的に見える動作:寛容なパース、2つのエラー間の奇妙な優先順位、ガベージに応答して送信される応答、期限後もまだ受け入れられる入力。これは保持します(ルール1)が、ユーザーのためにリストアップします。
次に、マップをテーブルとして記述します:状態、入力、次の状態、出力。複数の関数をトレースせずにコードから埋められない場合、コードはまだ読みやすくなく、そこが作業の対象です。
3. 変更を選択する
references/patterns.md はカタログで、before/after の例と、各パターンが効果を発揮しないケースが含まれています。そのインデックスを読み、次にマップで明らかになったものに対するセクションを、「価値がない」段落も含めて読んでください。大規模な変更の場合はファイル全体を読んでください。候補となる変更を、それぞれが4つの質問のうちの1つへの回答をどの程度短縮するか、差分のサイズに対してランク付けします。通常、最も効果が大きいのは以下のものです。
- 各フェーズのデータを保持するバリアントを持つ enum。フラグとオプションがフェーズをエンコードする代わりに(パターン1)
- 遷移ごとに1つの名前付き関数。各状態フィールドの書き込みが少なくなるように(パターン2)
- 入力にマッチし、決定し、遷移を適用し、出力をキューに入れるハンドラ。一貫した1つのディスパッチ軸に沿って(パターン3)
- 出力の種類ごとに1つのヘルパー。その意図にちなんで名前が付けられている(パターン4)
- 各期限ルールが1回記述され、
poll_timeoutが状態から導出される(パターン5) - ライフサイクルが読者の目に触れる場所に記述され、フィールドに不変条件が記載されている(パターン9)
ユーザーが1つの関数または1つの部分を指定した場合は、それとそれが必要とするものだけを変更してください。抽出したヘルパーは自然な兄弟によって再利用される可能性がありますが、ヘルパーがそこにも適合するからといって、リクエストの範囲外のコードを再作業しないでください。それらの改善点は代わりに提案の下にリストアップしてください。1つの関数について尋ねたレビュー担当者が、2つ目の関数をチェックする必要があってはなりません。
コードを移動するだけの変更、好みに合わせたローカル変数の名前変更、そして質問に答えやすくせずにコードを長くするものは、すべて除外してください。ユーザーが移動自体を要求した場合(長いファイルをモジュールに分割する)、パターン11を参照してください。すでに読みやすいコードはそのままにしておいてください。作業を発明するのではなく、サマリーでそう述べてください。
4. ステップで適用する
各ステップがレビュー可能で、後のステップで削除されるコードを追加しないように順序付けます。
- 純粋な追加と抽出: 出力ヘルパー、名前付き述語、期限ヘルパー、フィーチャーゲートされたステップをヘルパーに移動、ワイヤーエンコーディングとパースを関数に移動(パターン4、5、6、10)。
- コアの変更: フェーズ enum、それによって強制される遷移とハンドラの形状(パターン1~3、7、12)。これは多くの場合、1つのステップとして投入する必要があります。その場合は理由を述べてください。
- 名前(パターン8)。
- ドキュメント(パターン9)。
- メソッドの並べ替え、最後に、それ自体で、もしあれば(パターン11)。
編集の間は、高速ループを使用します。変更しているパッケージの clippy とテスト、デフォルトの機能セットで、ハーネスを構築した場合はそれも含めて。各ステップの最後に、ステップ1のチェックコマンドを実行します。そして、ユーザーがコミットを希望する場合(要求したか、プロジェクトのワークフローが期待する場合)は、そのステップをコミットにします。それ以外の場合は、作業をコミットせずに残し、ステップをサマリーにリストアップします。次のステップで削除されるスキャフォールディング(ある1つのステップをグリーンにするためだけに追加された cfg ゲートや #[allow])でリントを黙らせないでください。代わりにステップを並べ替えてください。
借用チェッカーが強制的に複雑な構造を取らせる場所では、パターンをドロップしてください。複雑なコードは、開始したものよりも読みにくいからです。通常の兆候:
- 借用を回避するためだけに、引数として
selfの兄弟フィールド(出力キュー、例:&mut VecDeque<Transmit>)またはそれ自体のマップキーを取るメソッド - 設定を読み取るためだけにサブ構造体を取る
&selfメソッド - クローンされたキーまたは余分なルックアップ
通常の修正:
- 出力を返し、呼び出し側にキューに入れさせる
- ヘルパーをエンティティに配置し、必要な
Copy設定値を渡す - コードをインラインのままにする
5. 検証と要約
ワークスペース全体に対して、最後にもう一度チェックコマンドを実行します。さらに、開始時に実行した他のチェック(遅いチェックとハーネス)も実行します。
次に、差分が pub なアイテム、derive、trait impl、フィールド型(auto traits はプライベートフィールドに従います)、またはアイテムの cfg に触れる場合、パブリック API を確認します。
- 機能セットごとに1回ずつ、
cargo semver-checks --baseline-rev <base>を明示的なフラグ(--default-features、--all-features、--only-explicit-features(なし)、および--only-explicit-features --features x)と共に使用します。 - これは破壊的変更のみをキャッチするため、新しいものが公開されていないことも確認します。
cargo public-api diffがインストールされていればそれを使用し、そうでなければgit diff <base> | grep -E '^\+.*\bpub\b'を読んでください。 - ツールがインストールされていない場合は、インストールする前に尋ね、それ以外の場合は API チェックが実行されなかったと報告してください。
- 差分が関数本体とプライベートアイテムにのみ触れる場合は、そのように述べてください。
6. サマリーを書く
最後のチェックの後、サマリーを <skill>/docs/<request-keyword>-summary.md に書き込みます。これは削除されず、そのセッションの記録として残ります。含める内容:
- 状態マップ。ステップ2の表。
- 変更リスト。何が変わったか、そしてなぜか。
- 検証。実行したチェック(それらのうち、何が緑色で何がそうでないか、何を実行できなかったかとその理由)。
- 提案。API の改善点、価値があるかもしれないが今は適切ではないさらなるパターン、リクエストの一部ではなかった健全なコード(「このモジュールはすでに読みやすい」)。