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に配線。
Question
Section titled “Question”プラグインファイル(plugins/*/lib、examples/*/lib)を、「プロトコル」(RBS/構造的
型付け)を使ってRigor::Plugin::Base契約(contract)に対して型検査/制約できるか。
それにより、契約を誤用またはオーバーライドミスするプラグインが機械的に捕捉されるように
できるか。
What was already true
Section titled “What was already true”sig/rigor/plugin/base.rbsは約30個中4個のメソッド(manifest/initialize/init/prepare)を宣言していた。ADR-37の拡張DSL、オーバーライドフック、エンジンが 実行するディスパッチャー、そしてすべての著作用ヘルパーは型なしだった。make check(rigor check lib)とmake steep-checkはどちらもlibのみを 対象とする。base.rbはlib内にある(チェック対象)が、plugins/*/lib内のプラグイン サブクラスはどちらのチェック対象でもない。sig/は疎である──249個のlibファイル中37個──そしてSteepはD::Ruby.lenientで 走るため、シグネチャのない協力者はuntypedである。
Layer 1 — complete base.rbs (landed)
Section titled “Layer 1 — complete base.rbs (landed)”著作者向けの全サーフェス(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に対して捕捉されるようにする。
計測した現実:
-
Steepはプラグインファイルを確かにBase RBSに対してチェックする──プラグイン サブクラス内のプローブ
manifest.totally_bogus_methodはRuby::NoMethod(「::Rigor::Plugin::Manifestはメソッドtotally_bogus_methodを持たない」)として 捕捉される…ただし[information]重大度でのみである。lenientプリセットがNoMethodを格下げするからである。デフォルトのsteep checkはそれを隠し、--severity-level=informationがそれを露わにする。 -
致命的な偽陽性の壁:プラグインはBase(RBS既知)をサブクラス化するが、自身の RBSをまったく出荷しないため、Steepは
selfを素のRigor::Plugin::Baseとして 型付ける。したがってプラグインの自身のprivateヘルパーメソッドへのあらゆる 呼び出しがNoMethodを報告する。rigor-deprecations単独で計測:3件の偽陽性──matches?、receiver_source、deprecation_diagnostic、いずれもプラグイン自身に 定義されている(86/93/99行)。37個のプラグイン全体でこれは遍在する:すべての プラグインがprivateヘルパーを定義する。真のシグナル(「存在しないBaseメソッドを呼んでいる」、たとえばタイポした
node_rul)と、FP(「自身のヘルパーを呼んでいる」)を分離するきれいな方法はない ──どちらも「BaseはメソッドXを持たない」として描画される。 -
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なし。著作者がオーバーライド可能な
各フック(init、prepare、flow_contribution_for、diagnostics_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.manifest、io_boundary、signature_paths(いずれもRBSでBase上にある)→Dynamic[top]となるため、誤用を捕捉できない (Dynamicレシーバー=open)。class MyHash < Hash→self.keysもDynamic[top]で ある:これはプラグイン固有ではなく一般的な挙動である。 -
特定した場所:
rbs_dispatch.rblookup_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::Baseがparams/renderを 部分的なgem RBSに対して呼ぶと、RBSが省くすべての継承メソッドでFPになるだろう。 精度(継承された戻り型)とリスク(継承呼び出しでのundefined-method)は1本のパスを 共有する。 -
成立する形 ──継承解決を実行する対象となるRBS-completeな祖先のアローリスト (シード
{Rigor::Plugin::Base})であり、それ以外はすべてDynamicフォールバックを 維持する。構成上FP安全であり、ドッグフード可能で、SteepのFPの壁なしにOption Aの目標を Rigorを通じて実現する。ADR-43として書き起こした(提案、計測ゲート付き)。注入点はrbs_dispatch.rblookup_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.