コンテンツにスキップ

Typing plugin files against the `Plugin::Base` contract — spike findings

Date: 2026-06-03 Status:スパイク完了。Layer 1(RBS)+Option B(構造的スペック)が着地。 Option A(プラグインツリー上のstrictなSteep)は計測の結果成立せず棚上げ。 「Rigorはスタンドアロンで警告できるか?」というフォローアップはスコープ付きの推論欠落に たどり着き、ADR-43として書き起こされ完全に着地した(WD1–WD6):エンジン+ manifest.rbsでプラグインlibツリーに正味FPゼロ、16件の既存ツリー欠落を解消、そして make check-pluginsゲートをmake verify+CIに配線。

プラグインファイル(plugins/*/libexamples/*/lib)を、「プロトコル」(RBS/構造的 型付け)を使ってRigor::Plugin::Base契約(contract)に対して型検査/制約できるか。 それにより、契約を誤用またはオーバーライドミスするプラグインが機械的に捕捉されるように できるか。

  • sig/rigor/plugin/base.rbs約30個中4個のメソッド(manifestinitializeinitprepare)を宣言していた。ADR-37の拡張DSL、オーバーライドフック、エンジンが 実行するディスパッチャー、そしてすべての著作用ヘルパーは型なしだった。
  • make checkrigor check lib)とmake steep-checkはどちらもlibのみを 対象とする。base.rblib内にある(チェック対象)が、plugins/*/lib内のプラグイン サブクラスどちらのチェック対象でもない。
  • sig/は疎である──249個のlibファイル中37個──そしてSteepはD::Ruby.lenientで 走るため、シグネチャのない協力者はuntypedである。

著作者向けの全サーフェス(DSL、フック、ディスパッチャー、ヘルパー)を宣言し、まだ シグネチャのない協力者(Diagnostic、Cache::Descriptor、FlowContribution、NodeContext、 Prismノード)をuntypedとして受け取る。両方のセルフチェックはグリーンを維持する。 これを完成させるとlenientチェックがマスクしていた真のRBS欠落が即座に表面化したIoBoundary#cache_descriptor(および#open_url)はBaseから呼ばれていたが io_boundary.rbsには存在しなかった──いまは宣言されている。これは縮図としての価値で ある:完全な契約は、沈黙した型なしの穴を、チェックされた穴に変える。

Option A — strict Steep target over the plugin tree — NOT VIABLE

Section titled “Option A — strict Steep target over the plugin tree — NOT VIABLE”

仮説:NoMethodをerrorに昇格させた:plugins Steepターゲット (check "plugins/*/lib"、…)を追加し、プラグインコード内の契約誤用が、いまや完全な Base RBSに対して捕捉されるようにする。

計測した現実:

  1. Steepはプラグインファイルを確かにBase RBSに対してチェックする──プラグイン サブクラス内のプローブmanifest.totally_bogus_methodRuby::NoMethod(「 ::Rigor::Plugin::Manifestはメソッドtotally_bogus_methodを持たない」)として 捕捉される…ただし[information]重大度でのみである。lenientプリセットが NoMethodを格下げするからである。デフォルトのsteep checkはそれを隠し、 --severity-level=informationがそれを露わにする。

  2. 致命的な偽陽性の壁:プラグインはBase(RBS既知)をサブクラス化するが、自身の RBSをまったく出荷しないため、Steepはselfを素のRigor::Plugin::Baseとして 型付ける。したがってプラグインの自身のprivateヘルパーメソッドへのあらゆる 呼び出しがNoMethodを報告する。rigor-deprecations単独で計測:3件の偽陽性── matches?receiver_sourcedeprecation_diagnostic、いずれもプラグイン自身に 定義されている(86/93/99行)。37個のプラグイン全体でこれは遍在する:すべての プラグインがprivateヘルパーを定義する。

    真のシグナル(「存在しないBaseメソッドを呼んでいる」、たとえばタイポした node_rul)と、FP(「自身のヘルパーを呼んでいる」)を分離するきれいな方法はない ──どちらも「BaseはメソッドXを持たない」として描画される。

  3. Aを成立させるには、Steepが各プラグイン自身のメソッドを見えるよう、37個すべての プラグインに対するプラグインごとのRBSが必要になる。それは規模と、リポジトリの sig-gen優先/手書きRBS回避ポリシー(AGENTS.md §「RBS Authorship」)により却下され、 それ自体が大きなFPを孕むサーフェスになるだろう。

偽陽性の規律の価値(「動作しているコードを決して脅かさない」)に従い、Aは棚上げした。 :plugins Steepターゲットはコミットしなかった

⚠️ 再挑戦する者へのワナ:Steepターゲット内のlibrary "set"はRBS 4.0.2をクラッシュ させるsetはRuby 4.0ではコアであり、見つけられるライブラリではない)──シグネチャ サービスのスレッド例外を通じて実行をハングさせる。またsteep check <path>の位置 フィルタ/Dir.glob(...).each { check }はこのバージョンではゼロ個のファイルを 黙ってチェックした。動いたのはリテラルなcheck "<dir>"エントリーのみだった。どちらも ここで実時間を浪費させた。

Option B — structural conformance spec — LANDED

Section titled “Option B — structural conformance spec — LANDED”

spec/integration/plugin_contract_conformance_spec.rb。純RubyのMethod#parameters 比較で、RBSは不要、プラグイン自身のメソッドにFPなし。著作者がオーバーライド可能な 各フック(initprepareflow_contribution_fordiagnostics_for_file)に ついて、エンジンの呼び出し形状をBase自身のシグネチャから導出し(あらゆる契約変更に 自動追従)、すべてのプラグインオーバーライドがそれで依然として呼び出し可能であると 表明する──広げる方向には寛容(追加のオプションパラメータ/*rest**keyrestは すべて通る。ADR-5のPostel)であり、必須パラメータを落とす狭めるオーバーライドのみを 失敗させる。

実行時に実際にプラグインを壊す唯一の違反を捕捉する──def diagnostics_for_file(path:)scope:root:を落とす→ディスパッチ時のArgumentError。37個すべてのプラグインで グリーンを検証し、注入された違反で(正確な違反者メッセージとともに)失敗することを 検証した。

Can Rigor warn instead of Steep? (→ ADR-43)

Section titled “Can Rigor warn instead of Steep? (→ ADR-43)”

フォローアップの問い:Option Aは契約誤用を捕捉するのにSteepを使い、偽陽性の壁に ぶつかった。代わりにrigor check(スタンドアロン)でそれができるか?dump_type プローブが決着をつけた:

  • call.undefined-methodには効力があり、プラグイン自身のメソッドに対してFP安全で ある。 Rigor::Plugin::Manifest.new(...)Nominal[Manifest]に解決され、 m.totally_bogus_methodはルールを発火する(error)。決定的に、プラグイン内部の dump_type(self)は→Rigor::Plugin::ProbeDumpサブクラス)になる。Rigorが プラグインのdefをソースから読み取るからである──そのためSteepの自身ヘルパーFPの壁を 持たない。Rigorはここで構造的により良いツールである。

  • しかし契約サーフェスは不可視である:RBS祖先から継承された呼び出しは Dynamic[top]に解決される。 self.manifestio_boundarysignature_paths (いずれもRBSでBase上にある)→Dynamic[top]となるため、誤用を捕捉できない (Dynamicレシーバー=open)。class MyHash < Hashself.keysDynamic[top]で ある:これはプラグイン固有ではなく一般的な挙動である。

  • 特定した場所: rbs_dispatch.rb lookup_method(~L270)──Rubyソースの サブクラス名がRBSのclass_declsに存在しないため、メソッドルックアップは祖先ウォーク の前にnilへショートサーキットする。ディスパッチはDynamic[top]にデフォルトする。 class Sub < Baseエッジは確かに記録されている(Scope#discovered_superclasses、 ADR-24 Slice 2)が、RbsDispatchからは決して参照されない。

  • なぜグローバルに切り替えられないか: Dynamic[top]フォールバックは荷重を担う FP保護である。class MyController < ActionController::Baseparamsrender部分的なgem RBSに対して呼ぶと、RBSが省くすべての継承メソッドでFPになるだろう。 精度(継承された戻り型)とリスク(継承呼び出しでのundefined-method)は1本のパスを 共有する。

  • 成立する形 ──継承解決を実行する対象となるRBS-completeな祖先のアローリスト (シード{Rigor::Plugin::Base})であり、それ以外はすべてDynamicフォールバックを 維持する。構成上FP安全であり、ドッグフード可能で、SteepのFPの壁なしにOption Aの目標を Rigorを通じて実現する。ADR-43として書き起こした(提案、計測ゲート付き)。注入点は rbs_dispatch.rb lookup_methodである。これはADR-26のopen_receivers:つまみの双対 ──open-to-suppress対closed-to-enable──である。

  • 「プラグイン契約を型付ける」はきれいに分かれる:RBSが契約を述べる(Layer 1、 着地済み)構造的スペックがオーバーライド適合性を強制する(Option B、着地済み)プラグインファイル上のstrictなSteepはプラグインごとのRBSなしには誤用を強制できない (Option A、FPを理由に棚上げ);そしてRigorはスタンドアロンで誤用を強制できるが、 スコープ付きの推論欠落だけがそれを阻んでいる(ADR-43、提案)
  • 残る未強制のサーフェスはプラグイン内部からの型付き契約の誤用(存在しないBase/ ヘルパーメソッドの呼び出し)である。今日はプラグイン自身の統合スペックによって実行時に 捕捉されており、静的にではない。ADR-43のアローリスト祖先解決が、それをrigor checkで 捕捉する道である──プラグインごとのRBS(それもAの壁を溶かすが、37組の手書きシグネチャ セットのコストがかかる)より選好される。

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