コンテンツにスキップ

ADR-24 — implicit-selfメソッド呼び出し解決

ステータス: Accepted、2026-05-20(スライス(slice)4は別途FP評価が必要なためゲート中)。スライス1+3は2026-05-20に実装済み、スライス2は2026-05-21に実装済み。 メソッドボディの内側で明示的なレシーバーなしに書かれた呼び出し(implicit-selfメソッド呼び出し)を、囲むクラス / モジュールのメソッドセット — 自身の定義、その祖先、クロスファイルのプロジェクトクラス — に対して解決するというプロジェクトの決定を記録する。これにより、解決されたメソッドの推論された戻り型とパラメータ契約(contract)が呼び出しサイトで可視になる。現状ではそのような呼び出しはDynamic[top]として型付けされており、これは根本的な精度ギャップ。

rigor type-ofdef nr; raise "x"; endに対して実証的に確認:

呼び出しサイトnrの推論型
トップレベル(w = nrbot
別のメソッド内部(def g; v = nr; endDynamic[top]

トップレベルの呼び出しはトップレベルのdefに解決される;メソッドボディ内部からの呼び出しは解決されない — rigorはそれをDynamic[top]と型付けし、呼び出し先の推論された戻り型もパラメータ契約も見えない。

これはコーナーケースではない。docs/notes/20260519-oss-library-survey.md Mastodonサーベイで発見された最大のフォルスポジティブクラスタの根本原因。lib/mastodon/cli/accounts.rbのパターン:

def modify(username)
user = Account.find_local(username)&.user
fail_with_message 'No user with such username' if user.nil?
user.role_id = role.id # 40件以上の`possible-nil-receiver`診断がここに
user.save
...
end

fail_with_messageはボディがraise Thor::Error, messageのガードヘルパー — 常に発散するため、エンジンはその戻り型をbotと推論できるhelper(...) if user.nil?でのbotを返す呼び出しはraise ... if user.nil?と全く同じ終端ガードだ — フォールスルーはuserが非nilであることを観測する。しかしfail_with_message#modify内のimplicit-self呼び出しであるためDynamicと型付けされ、botではない;ガードが不可視になり;userUser | nilのまま;後続のすべてのuser.<x>possible-nil-receiverフォルスポジティブになる。1ファイルで42件。

さらに広く言えば、プロジェクト内のすべてのselfメソッド呼び出しがその実際の戻り型とパラメータ契約を失う — selfメソッドに対するチェインされた推論がDynamicに縮退し、self呼び出しに対する本物のアリティ(arity) / 引数型バグが検出されない。ガードヘルパークラスタは単に最も可視性の高い症状にすぎない。

これを修正するためのピースはほぼすべて既存:

  • クロスファイルクラス発見プレパスがプロジェクトクラスレジストリを埋める。
  • エンジンがモジュールミックスインコンテキスト向けのself_typeを持つ。
  • メソッドの戻り型推論が機能する(上記のトップレベルでのtype-ofの結果がそれを証明)。

欠けているのは配線:implicit-self呼び出しサイトが「ここでのselfは何か、そのクラス — または祖先 — はこのメソッドを定義しているか?」と問わない。

エンジンはメソッドボディ内部のimplicit-self呼び出しを、囲む定義のself型に対して解決する:

  1. 呼び出しサイトでのselfを決定する — インスタンスメソッド(def m)に対してはレキシカルに囲むclass/moduleインスタンス、シングルトンメソッド(def self.m / class << self)に対してはシングルトン
  2. その型のメソッドセットに対して呼び出し名を解決する:囲むクラス自身の定義、次にその祖先 — プロジェクト発見クラス(クロスファイル)とRBS既知の祖先の両方を参照しながら、スーパークラスチェーンとインクルードされたモジュール。
  3. ヒットした場合、呼び出しサイトは解決されたメソッドの推論された戻り型とパラメータ契約を採用する — 明示的な既知レシーバーを持つ呼び出しがすでに行うのと全く同様。
  4. ミスした場合、呼び出しはDynamic[top]のまま — 今日の動作が保持される(WD3)。

この変更はv1では精度加法的:既存のDynamic[top]をより精密な型に置き換えるだけ。v1では、未解決のself呼び出しに対してcall.undefined-methodを新たにemitしない(WD4)— それはRubyのメタプログラミングが「未解決」を「バグ」の弱いシグナルにするため、別個のゲートされた決定。

WD1 — 解決スコープ:囲むクラス + 祖先、クロスファイル

Section titled “WD1 — 解決スコープ:囲むクラス + 祖先、クロスファイル”

解決ターゲットはselfのクラスの完全な祖先チェーンであり、同一ファイルや同一クラスボディだけではない。fail_with_messageMastodon::CLI::Baseに存在;呼び出しは別のファイルのサブクラスにある。解決はクロスファイルプロジェクトクラスレジストリとRBS祖先を参照する。プロジェクト全体が解析されたからこそ解決するself呼び出しは例外ではなく普通のケース。

WD2 — selfdef mではインスタンス型、def self.mではシングルトン

Section titled “WD2 — selfはdef mではインスタンス型、def self.mではシングルトン”

def mの内部でimplicit-self呼び出しのレシーバーは囲むクラスのインスタンス;def self.m / class << selfの内部ではシングルトン。両者は異なるメソッドセットを持つ(インスタンスメソッド対クラスメソッド)。解決はマッチするセットを使う。これはエンジン既存のself_typeを再利用しており、並列の概念を発明しない。

WD3 — 未解決のself呼び出しはDynamic[top]のまま

Section titled “WD3 — 未解決のself呼び出しはDynamic[top]のまま”

名前が既知のメソッドに解決しないself呼び出しは今日のDynamic[top]型を保持する。Rubyのメソッドは日常的にdefine_methodmethod_missingattr_*、フレームワークDSL(ActiveRecord属性 / アソシエーション、delegateなど)で定義される。「静的に発見されない」を「存在しない」として扱うと、正しい場合よりも間違っている場合のほうがはるかに多くなる。不確実なディスパッチに対する寛容性はADR-5の堅牢性原則をselfディスパッチに適用したもの。

WD4 — 解決された閉じたself呼び出しに対するundefined-methodは別個のゲートされた決定

Section titled “WD4 — 解決された閉じたself呼び出しに対するundefined-methodは別個のゲートされた決定”

self呼び出しが解決されると、エンジンは解決されたが不一致のself呼び出し、またはメタプログラミングの逃げ道がないクラスで解決されないself呼び出しに対してcall.undefined-method / call.wrong-arity / 引数型診断をemitできる。これは本物のバグ検出の価値があるが — メタプログラミングが多いコードベース(すべてのRailsモデル)では大きな新たなフォルスポジティブサーフェス(surface)でもある。このADRのv1では開かない。スライス4として、独自の評価でゲートされている:レシーバークラスが自信を持って閉じている(すべてのメソッドが静的に既知;method_missing / respond_to_missing? / 動的定義なし;RBS-untyped汚染された祖先なし)ときのみフラグを立てる。それまで、self呼び出し解決は純粋に精度向上。

WD5 — プロジェクトメソッドサマリー、有界パスで計算

Section titled “WD5 — プロジェクトメソッドサマリー、有界パスで計算”

呼び出し元に呼び出し先の戻り型を渡すため、エンジンはメソッドサマリー — (class, method, kind) → 戻り型 + パラメーター契約 — が必要。毎回呼び出し先を再解析するのではなく一度計算してself呼び出しサイトで参照する。相互再帰(ABを呼び、BAを呼ぶ)はinference-budgets.mdに従い有界:計算中のサマリーはそのサイクルで発散するのではなくDynamic[top]に解決する。既存のプロジェクトスキャンプレパスとそのキャッシュ(ADR-6)が自然な置き場所;これは合成メソッドインデックスを拡張するか、並列のプロジェクトメソッドサマリーインデックスを追加することになるかもしれない。

WD6 — botブランチが制御フローをナローイング

Section titled “WD6 — botブランチが制御フローをナローイング”

botを返すself呼び出し(常にraise / exitするガードヘルパー)は、フロー解析がその後helper(...) if x.nil?を終端ガードとして扱う場合にのみ効果を発揮する。今日のeval_if / eval_unlessは終端ブランチを構文的に検出する — ハードコードされたEXIT_CALL_NAMESraise/throw/exit/abort/fail)が呼び出し名でマッチング。このADRはそれを一般化する:推論された型がBotブランチが、どのようにスペルされていても終端ブランチ。ブランチ型はeval_ifによってすでに計算されており;変更はそれをexitテストにORする。(この一般化のプロトタイプはクラスタ1b調査中に書かれ差し戻された — 正しいが、WD1〜WD5がガード呼び出しをまずbotに解決させるまで不活性。スライス3として、残りと並んでspecが書かれる。)

WD7 — バジェットとキャッシング

Section titled “WD7 — バジェットとキャッシング”

解決とサマリー計算は解析作業を増やす。両方がinference-budgets.mdエンベロープ内に収まる;クロスファイル解決は既存のクラス発見プレパスとADR-6キャッシュに乗る。バジェットを超えるself呼び出しは(WD3)Dynamic[top]にフォールバック — 遅い無制限のウォークにではなく。

  • Mastodonのnil-receiver FPクラスタ(1ファイルで≈42件、他の場所にも)は一度ガードヘルパーのbot戻りが可視になれば(WD1 + WD5 + WD6)クローズする。
  • すべてのself呼び出しが実際の戻り型を得る — selfメソッドに対するチェインされた推論がDynamicに縮退するのを停止。精度向上はガードヘルパーのケースをはるかに超える。
  • botブランチフローの一般化(WD6)により、ガード節が任意の発散するヘルパーを通じて正しくナローイング(narrowing)できるようになり、5つの組み込みexitコールだけでなく。
  • 新たな解析コスト(WD7) — 有界、キャッシュされ、フェイルソフト。
  • 相互再帰は有界サマリー処理(WD5)が必要;誤って実装すると発散またはミスキャッシュになる。
  • WD4は意図的に閉じたまま。ユーザーは「なぜrigorはこのタイポしたself呼び出しを検出しないのか?」と合理的に問うだろう — 答え(メタプログラミングの寛容性)は文書化されなければならず、スライス4がその問いが再開される場所。
  • スライス4(閉じたクラスのself呼び出しに対するundefined-method)は見送りであり、却下ではない。
  • ADR-34(2026-05-29)がWD4のトップレベルスライスを別の決定として切り出した: トップレベルスコープ(囲むdef / class / moduleなし)でのunresolved implicit-self呼び出しは、ADR-17のpre_eval:エスケープハッチが整い次第デフォルトで警告を発する。このADRのWD4が閉じたままにしたクラスボディ / defボディのケースは閉じたままである; ADR-34はそれを明示的に再開しない。

需要駆動;このADRによってスケジュールされるスライスはない。

スライス1 — 同一クラスself呼び出し解決 — 2026-05-20実装済み

Section titled “スライス1 — 同一クラスself呼び出し解決 — 2026-05-20実装済み”

implicit-self呼び出しを囲むクラスの自身のインスタンスメソッド定義とファイルのトップレベル定義に対して解決;解決されたメソッドの推論された戻り型を採用。祖先はまだなし、新しい診断もなし。

実装状況.配線ギャップは小さかった:discovered_def_nodes — クラスごと / トップレベルのメソッド → DefNodeテーブルであり、ScopeIndexerが構築し、エンジン既存の手続き間解決(Scope#user_def_for / #top_level_def_for)が参照する — はトップレベルの呼び出しサイトスコープには持ち込まれていたが、すべてのクラス / メソッドボディ向けに構築されるフレッシュスコープ(StatementEvaluator#build_fresh_body_scope)には持ち込まれていなかった。持ち込むことでメソッドボディ内部での解決が有効化される。

スライスのスケッチからの2つの逸脱、どちらも測定によって強制:

  • 保守的な採用ゲート.解決されたすべての戻り型を無条件に採用するとrigor check libが16件の診断で綺麗でなくなった — 解決された精密な型(Nominal[Manifest]、不精密なHash形状、nil)が、self呼び出しがDynamic[top]のままだった間はマスクされていた呼び出し先戻り型推論の既存の不精密さに対して、下流の厳密なチェック(undefined-method、引数型、フローフォールディング)を発火させてしまうためだ。そこでExpressionTyper#adoptable_self_call_result?が採用をゲート:クラスボディ内部(scope.self_typeがセット)では解決された型がBotのときのみ採用;トップレベル / DSLブロックスコープ(self_typeがnil — スライス1以前のサーフェス)では無変更で採用。BotのケースはADRの動機となるバグであり、FPなしと証明可能(Bot結果は正しい終端ブランチナローイングのみを有効化できる)。クラスボディ内部での一般的な非Bot採用は呼び出し先戻り型推論が十分精密になるまで見送り — 再評価トリガー1でゲートされる独自のフォローアップ。
  • 再帰ガードの再キー(WD5). infer_user_method_returnガードは(receiver, method, arg_types)でキーされていた;self呼び出しが解決されると、module_functionモジュール(Acceptance#acceptsaccepts_oneaccepts_dynamicaccepts)を通じた相互再帰が、持ち込まれた引数型がレベルごとに異なるたびに無制限に再帰した — SystemStackError。ガードは今(receiver, method)でキーされている:サマリーがまだ計算中のメソッドはそのサイクルでDynamic[top]に解決する、WD5が規定する通り。別個のメソッドサマリーインデックスは追加されなかった — (今は正しい)ガードのもとでの既存の呼び出しごとの再ウォークがスライス1のメカニズム;メモ化されたサマリーインデックスはWD7のパフォーマンスフォローアップのまま。

スライス2 — 祖先 + クロスファイル — 2026-05-21実装済み

Section titled “スライス2 — 祖先 + クロスファイル — 2026-05-21実装済み”

解決をスーパークラスチェーンとインクルードされたモジュールに拡張し、プロジェクトファイルをまたいで(クラス発見レジストリ)とRBS既知の祖先に対して。

実装状況. ExpressionTyper#try_user_method_inferenceが、同一クラスのuser_def_forミス時にユーザークラスのスーパークラスチェーンを歩く(resolve_user_def_through_ancestors)。チェーンは新しいScope#discovered_superclassesマップ(class → 書かれた通りのスーパークラス名)を通じて辿られる。as-writtenの名前はresolve_ancestor_class_nameによって呼び出しサイトで修飾クラスに解決される(サブクラスの各囲むネームスペース、最も内側が最初というModule.nesting定数ルックアップに従う)。クロスファイル解決は新しいプロジェクトプレパスScopeIndexer.discovered_def_index_for_pathsに乗る — すべてのプロジェクトファイルを一度歩いてマージされたdiscovered_def_nodesテーブルとマージされたスーパークラスマップを返す;Runnerがそれをすでにクロスファイルdiscovered_classesをシードしているのと同様に各ファイルスコープにシードする。ウォークは深さ制限(20)でサイクルガードされている。

インクルード / プリペンドされたモジュールは同日(2026-05-21)のフォローアップで追加された。ScopeIndexer.build_discovered_includesがクラス / モジュールごとに、それがinclude / prependするモジュール(定数引数のみ)を記録する。クロスファイルdiscovered_def_index_for_pathsがdef-nodeとスーパークラスマップの隣にincludeマップを返す;RunnerScope#discovered_includes / #includes_of経由でシードする。resolve_user_def_through_ancestorsはユーザークラスの完全な祖先セット(インクルード / プリペンドされたモジュール(推移的)が最初、次にスーパークラス)に対する幅優先ウォーク — サイクルガードされノード数上限あり(100)。extendは追跡されない:シングルトンメソッドを追加するものであり、インスタンス側チェーンのスコープ外。

スライスのスケッチからのスコープ逸脱:

  • RBS既知の祖先はここでは歩かない. MethodDispatcher RBSティアはtry_user_method_inference前に実行されRBS既知の祖先のメソッドをすでに解決する;ユーザークラスウォークは単に祖先名がプロジェクト発見クラス / モジュールに解決しないときに停止する。よって「RBS既知の祖先も」は既存のディスパッチ順序によって満たされており、ウォーク内の新しいコードによってではない。

採用はスライス1のadoptable_self_call_result?(クラスボディ内部ではBot戻りのみを採用)でゲートされたまま、よってFPプロファイルはスライス1から変わらない:祖先ガードヘルパーがbotに解決してナローイング(スライス3と合わせて);非Botの祖先戻りはDynamic[top]のまま。フィクスチャ:spec/integration/fixtures/inherited_guard.rb(スーパークラス、同一ファイル) + included_module_guard.rb(ミックスイン、同一ファイル) + 2つのクロスファイルRunner spec。

スライス3 — botブランチフローナローイング(WD6) — 2026-05-20実装済み

Section titled “スライス3 — botブランチフローナローイング(WD6) — 2026-05-20実装済み”

eval_if / eval_unlessの終端ブランチ検出を構文的なEXIT_CALL_NAMESリストから「ブランチの推論型がBot」に一般化。発散するヘルパーを通じたガード節がナローイングするようになる。

実装状況. StatementEvaluator#branch_terminates?(branch_node, branch_type)が既存の構文的branch_unconditionally_exits?branch_type.is_a?(Type::Bot)をORする。ブランチ型はeval_if / eval_unlessによってすでに計算されており(then_type / else_type)、それぞれの早期リターンナローイングテスト両方が単にbranch_unconditionally_exits?branch_terminates?に交換する — 余分な評価なし。スライスの順序外でスライス1と一緒に実装された、なぜならその2つは直接合成するから:スライス1が同一クラス / トップレベルのガードヘルパーをbotに解決させ、スライス3がそのbotがフォールスルーをナローイングさせる。祖先ヘルパーのケース(Mastodonのfail_with_messageクラスタ)はスライス2まで待つ。統合フィクスチャ:spec/integration/fixtures/bot_branch_guard.rb

フォローアップ(2026-05-21)が同じ一般化をeval_and_orに拡張した:&& / ||は独立した構文的branch_unconditionally_exits?チェックを持っていたため、x = src or fail_now(裸のraiseではなく発散するヘルパー)がナローイングしなかった。eval_and_orは今RHSを最初に評価してbranch_terminates?を使うため、Bot型のRHSがLHSをその残存エッジにナローイングする。フィクスチャ:spec/integration/fixtures/or_guard_narrowing.rb

スライス4(ゲート — 別個の決定) — 閉じたクラスself呼び出しに対する診断

Section titled “スライス4(ゲート — 別個の決定) — 閉じたクラスself呼び出しに対する診断”

WD4に従って閉じたクラスself呼び出しに対するcall.undefined-method / call.wrong-arity / 引数型。メタプログラミングが多いコードベースに対する独自のFP評価でゲート。

  1. スライス1/2がinference-budgets.mdエンベロープを超えて解析のwall-clockを測定可能に退行させる → WD7を締め、より多くの作業をキャッシュ / プレパスに押し込む。
  2. スライス4への需要(ユーザーがself呼び出しのタイポ検出を求める)が代表的なサーベイでメタプログラミングFPリスクを上回る → スライス4を閉じたクラスゲートでスケジュール。
  3. メソッドサマリーサイクルがミスキャッシュされることが観察される → WD5の有界パス / 不動点処理を再訪する。

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.