`rigor-tapioca`? — Tapioca DSL-RBI Coverage Investigation
ステータス: investigation, 2026-05-09。20260509-rigor-tapioca-comparison.mdの補足。Tapioca生成のDSL RBIを消費するために専用のrigor-tapiocaプラグインが正当化されるか、それとも既存のrigor-sorbetプラグイン内でギャップを閉じる方が良いかを問う。
推奨(TL;DR): rigor-sorbet内でギャップを閉じる。Tapioca固有の問題が汎用のRBIサーフェスに収まらないものとして浮上するまでrigor-tapiocaを構築しない。
実際のギャップ
Section titled “実際のギャップ”Tapiocaのリルコンパイラは特定の構造パターンを出力する。bodyカラムを持つActiveRecordモデルPostの場合、生成されたsorbet/rbi/dsl/post.rbiは以下のようになる(compiler_activerecordcolumns.mdドキュメント参照):
# typed: trueclass Post include GeneratedAttributeMethods
module GeneratedAttributeMethods sig { returns(T.nilable(::String)) } def body; end
sig { params(value: T.nilable(::String)).returns(T.nilable(::String)) } def body=; end
# ... endendsigはPost#bodyではなくPost::GeneratedAttributeMethods#bodyに付いている。ユーザー向けの呼び出しpost.bodyはランタイムでincludeチェーンを通じてPost#bodyに解決される。
rigor-sorbetスライス4のカタログウォーカーは(class_name, method_name, kind) → MethodSignatureをそのまま記録する。ユーザーがpost.bodyと書き、レシーバーの型がNominal["Post"]の場合、プラグインのルックアップは("Post", :body, :instance) — MISS。sigは("Post::GeneratedAttributeMethods", :body, :instance)として記録されているから。
tmp/rigor_tapioca_check.rbでの再現により確認: Tapioca形式のRBIが所定の場所にあってもrigor checkはpost.bodyにcall.undefined-methodを発行する。プラグインはファイルを読み込み、sigをパースするが、解決機構は2つの名前空間を橋渡しするためにincludeチェーンを辿らない。
同じパターンがTapiocaのコンパイラファミリー全体に現れる:
| コンパイラ | 生成されるモジュール名 | ミックスイン方向 |
|---|---|---|
ActiveRecordColumns | GeneratedAttributeMethods | include(インスタンス側) |
ActiveRecordAssociations | GeneratedAssociationMethods | include(インスタンス側) |
ActiveRecordRelations | GeneratedRelationMethods | include(インスタンス側) |
ActiveRecordScope | GeneratedRelationMethods | extend(クラスメソッド側) |
UrlHelpers | (ホストモジュール) | include(ヘルパーモジュールにミックスイン) |
Protobuf | (メッセージクラスごと) | 直接def(ミックスインなし) |
SidekiqWorker | クラスへの直接def | ミックスインなし |
ActiveSupportConcern | ClassMethods | extend |
Tapiocaのコンパイラのほとんどがinclude/extendパターンを使用する。ミックスインチェーン解決なしでは、rigor-sorbetはそれらのコンパイラすべての提供を黙って捨てる — まさにプラグインがカバーするはずだった「DSL派生メソッド」の長いテール。
このパターンはsorbet/rbi/shims/下の手書きshimや、コミュニティでキュレートされたrbi-centralアノテーションでも使われる。つまりこれを修正することはTapioca固有の問題ではなく — SorbetのRBI方言のコンシューマーが必要とする一般的なRBSセマンティクスである。
ふたつの修正方法
Section titled “ふたつの修正方法”オプションA — rigor-sorbetを拡張する(推奨)
Section titled “オプションA — rigor-sorbetを拡張する(推奨)”カタログウォーカーにミックスインチェーン解決を追加する。2パスウォーク:
- パス1(宣言): すべてのファイルを辿り、クラスごとに記録する:
- クラス独自の
defsig(スライス1のCatalogWalkerで既に実施)。 - クラスが行う
include/extend宣言のリスト(include GeneratedAttributeMethods、extend ClassMethods等)。
- クラス独自の
- パス2(ルックアップ):
flow_contribution_forが("Post", :body, :instance)を問い合わせるとき:- まず直接ルックアップを試みる。
- ミスの場合、Postの記録済み
includeチェーンを辿る。 各include Moduleについて("Post::Module", :body, :instance)とPost::Module内のトランジティブincludeを試みる。 - シングルトン側ルックアップには
extendチェーンを対称的に処理する。
実装は既存のカタログの小さな拡張(ADR-11のスライスと同形式)。おそらく「スライス7(スライス1から延期)」エントリーに収まる。新しいカタログフィールド: Catalog#includes_for(class_name) → [module_name, ...]。RubyのMRO走査を模倣しカタログの記録済みミックスインのみを対象とする新しいCatalog#walk_lookup_chain(...)ヘルパー。
MethodSignatureは変わらない(宣言クラス/モジュールでキーイングされたまま)。Catalogにmixins:マップ({class_name → {include: [...], extend: [...]}})を追加。CatalogWalkerがクラス/モジュールボディ内のトップレベルのinclude/extendCallNodeを認識し、RHS定数名を記録する。Sorbet#lookup_signatureがミスの場合に記録済みミックスインチェーンを辿る。
なぜこれが正しいパスか
Section titled “なぜこれが正しいパスか”- パターンはTapioca固有ではない。
sorbet/rbi/shims/の手書きshimでもコミュニティアノテーションrbi-centralでも使われる。Sorbetはコアやembeddedライブラリ/stdlib RBIで自分自身のために使う。rigor-sorbetを修正することはTapiocaユーザーだけでなく、すべてのRBIコンシューマーに恩恵をもたらす。 - プラグイン増殖を避ける。独立した
rigor-tapiocaはファイルウォーカー、カタログ、ルックアップ機構を複製する — 些細な追加機能のために。 - ディスパッチャー階層をきれいに保つ。同じ階層順序で競合する別のプラグインを追加すると、利益をもたらさないcontributionマージのコンフリクトを招く。
オプションB — 独立したrigor-tapiocaプラグイン
Section titled “オプションB — 独立したrigor-tapiocaプラグイン”Tapiocaの生成済みDSL RBIを特別扱いする並列プラグインを構築する。
rigor-sorbetに収まらないTapioca固有のことを独立プラグインができること:
- 陳腐化検出:
db/schema.rbのmtimeがsorbet/rbi/dsl/post.rbiのmtimeより新しい —bin/tapioca dsl Postを提案するplugin.tapioca.stale-rbi警告を発行。 - ドリフト検出: Tapioca生成のカラムリストと
rigor-activerecordの同じdb/schema.rbの静的パースを比較。不一致をplugin.tapioca.driftとして浮上させる。 - 生成マーカーの尊重: TapiocaはRBIに
# DO NOT EDIT THIS FILE BY HANDと# This file is autogenerated by tapiocaを前置する。プラグインはマーカーのないRBIの読み込みを拒否する(ユーザーshimとして扱う)か、異なる優先度を付けることができる。 - 既知のGenerated*モジュール名のファストパス: includeがTapiocaの
GeneratedAttributeMethods/GeneratedAssociationMethods等として認識された場合、ミックスインチェーンウォークを短絡する。
なぜこれは今間違ったパスか
Section titled “なぜこれは今間違ったパスか”- コアのギャップに対処しない。ミックスインチェーン解決が実際の修正。上記のすべてはボーナス機能。
- ボーナス機能は小さい。合わせてもおそらく100-200行のコード。プラグインを正当化しない。
- エコシステムコスト。プラグインにはREADME、デモ、インテグレーションスペック、gemspec — 薄いフィーチャーサーフェスに対してのオーバーヘッド。
- クロスプラグイン調整コスト。
rigor-tapiocaはRBIを読む(rigor-sorbetと重複)ANDPlugin::FactStore(ADR-9)経由でrigor-activerecordの出力を参照する必要がある。双方向クロスプラグイン依存関係は契約の最も脆弱な形状。
rigor-tapiocaが正当化される時
Section titled “rigor-tapiocaが正当化される時”少なくともふたつが成立するまでプラグインを延期する:
rigor-sorbetのミックスインチェーン拡張がランディングし、実際のTapioca使用プロジェクトで実証された。- 陳腐化/ドリフト検出への具体的なユーザーリクエストが浮上した(つまり、誰かが古いRBIのバグにぶつかり要求した)。
- Tapiocaが
rigor-sorbetの汎用RBIサポートでは本当にモデル化できない機能を進化させた — 例えば# typed:コメントヘッダー内のTapioca固有のアノテーション、または新しいファイル命名規則。
これらのうちひとつだけが成立する場合、作業はrigor-sorbetのスコープにきれいに収まる。
実装スケッチ — rigor-sorbetスライス7
Section titled “実装スケッチ — rigor-sorbetスライス7”(参照のために番号付け。実際のスライス番号はADR-11の実装時にADR-11の連番に従う。)
ステップ1 — ミックスイントラッキングでCatalogを拡張
Section titled “ステップ1 — ミックスイントラッキングでCatalogを拡張”class Catalog def initialize @entries = {} @mixins = {} # class_name → { include: [], extend: [] } @frozen_after_build = false end
def record_mixin(class_name:, kind:, module_name:) raise "Catalog already finalised" if @frozen_after_build @mixins[class_name] ||= { include: [], extend: [] } @mixins[class_name][kind] << module_name end
def mixins_for(class_name) @mixins[class_name] || { include: [], extend: [] } end
# ...endステップ2 — ウォーカーがinclude/extend宣言を記録する
Section titled “ステップ2 — ウォーカーがinclude/extend宣言を記録する”CatalogWalker.walk_nodeのクラスボディハンドラで、引数がConstantReadNode/ConstantPathNodeの:include/:extendという名前のPrism::CallNode呼び出しを認識する:
def record_mixin_declaration(call_node, lexical_path, catalog) return if lexical_path.empty?
target_class = lexical_path.join("::") call_node.arguments.arguments.each do |arg| name = qualified_name_for(arg) or next # `class Bar`内の`include Foo`は # `(Bar, include, Foo)`として記録し、レキシカルルックアップに基づいて # `Bar::Foo`/`Foo`に解決される。カタログは両候補を記録し、 # ルックアップは順番に試みる。 catalog.record_mixin(class_name: target_class, kind: kind_for(call_node.name), module_name: name) endendステップ3 — Sorbet#lookup_signatureがチェーンを辿る
Section titled “ステップ3 — Sorbet#lookup_signatureがチェーンを辿る”def lookup_signature(call_node, scope) receiver = call_node.receiver method_name = call_node.name return nil if method_name.nil?
if (singleton_target = constant_receiver_name(receiver)) chain_lookup(singleton_target, method_name, kind: :singleton, mixin_kind: :extend) elsif receiver instance_lookup_with_chain(receiver, method_name, scope) endend
def chain_lookup(class_name, method_name, kind:, mixin_kind:) # まず直接ルックアップを試みる。 direct = @catalog.lookup(class_name: class_name, method_name: method_name, kind: kind) return direct if direct
# 記録済みミックスインチェーンを辿る。 visited = Set.new queue = @catalog.mixins_for(class_name)[mixin_kind].dup until queue.empty? candidate = queue.shift next unless visited.add?(candidate)
# ネームスペース付き形式(`Post::GeneratedAttributeMethods`)と # ベア形式(`GeneratedAttributeMethods`)の両方を試みる — # Tapiocaはネームスペース付き形式を使い、手書きshimはベア形式を多用する。 namespaced = "#{class_name}::#{candidate}" direct = @catalog.lookup(class_name: namespaced, method_name: method_name, kind: :instance) || @catalog.lookup(class_name: candidate, method_name: method_name, kind: :instance) return direct if direct
# 解決済みモジュールのミックスインに再帰する。 [namespaced, candidate].each do |intermediate| queue.concat(@catalog.mixins_for(intermediate)[:include]) end end nilend(:extend対:includeの区別は正確性のために重要: extend Fooはシングルトンクラスにメソッドを追加するため、Post.findはextendされたモジュールを通じて解決されるが、Post.new.fooはincludeされたモジュールを通じて解決される。)
ステップ4 — インテグレーションスペック
Section titled “ステップ4 — インテグレーションスペック”spec/integration/plugins/sorbet_plugin_spec.rbにTapioca形式のRBIフィクスチャを追加する:
let(:tapioca_dsl_rbi) do <<~RBI # typed: true class Post include GeneratedAttributeMethods module GeneratedAttributeMethods extend T::Sig sig { returns(T.nilable(::String)) } def body; end end end RBIend
it "resolves Tapioca-style mixin sigs through the include chain" do result = run_plugin( source: "#{SIG_STUB}post = Post.new; post.body\n", files: { "sorbet/rbi/dsl/post.rbi" => tapioca_dsl_rbi } ) expect(result.diagnostics.select { |d| d.rule == "call.undefined-method" }).to be_emptyendステップ5 — README + CHANGELOG
Section titled “ステップ5 — README + CHANGELOG”plugins/rigor-sorbet/README.mdに「Tapioca DSL RBI互換性」サブセクションを追加し、プラグインがサポートするようになったTapiocaコンパイラと、まだ作業が必要なもの(あれば)をリストアップする。
見積もり工数
Section titled “見積もり工数”- カタログフィールド + ウォーカー認識: 〜50行
- ルックアップチェーン走査: 〜30行
- インテグレーションスペックカバレッジ: 〜80行
- README/CHANGELOG: 〜20行
合計: 〜180行、ひとつの焦点を絞ったコミット。スライス4のRBIウォーカーより小さい。
rigor-tapiocaが後で何になりえるか
Section titled “rigor-tapiocaが後で何になりえるか”将来のユーザーリクエストが独立したプラグインを正当化するなら、正しい最小スコープは:
plugins/rigor-tapioca/├── README.md├── rigor-tapioca.gemspec├── lib/│ ├── rigor-tapioca.rb│ └── rigor/plugin/tapioca.rb ← 単一ファイル、〜150行└── demo/プラグインの機能:
sorbet/rbi/dsl/とsorbet/rbi/gems/ツリーを辿る (sigを記録するためではなく —rigor-sorbetがそれを処理 — TapiocaのメタデータヘッダーをTo読むため)。db/schema.rbのmtime(ダイジェストトラッキングのためにIoBoundary#read_file経由)と対応するRBIのmtimeをクロスリファレンス。スキーマの方が新しければplugin.tapioca.stale-rbiを発行。rigor-activerecordのmodel_indexファクト(ADR-9スライス5後にPlugin::FactStore経由)を消費し、そのカラムリストをRBIのGeneratedAttributeMethodsボディと比較。不一致はplugin.tapioca.driftとして発行。- Tapiocaの
# DO NOT EDIT THIS FILE BY HANDヘッダーを尊重 — マーク付きファイルを権威あるものとして扱い、dsl/内のマークなしファイルをユーザーshimとして扱う。
いずれも急ぎではない。まずrigor-sorbetスライス7を出荷し、実際のユーザーが汎用RBIパスでは浮上できない陳腐化やドリフトの問題にぶつかった場合にのみ再検討する。
20260509-rigor-tapioca-comparison.md— この調査が基づく戦略的比較。- ADR-11 — Sorbet入力アダプタとしてのプラグイン
—
rigor-sorbetの拘束力のある契約。 tapioca/manual/compiler_activerecordcolumns.md— この調査がテストしたTapioca生成DSL RBI形式のサンプル。tmp/rigor_tapioca_check.rb— ギャップを確認した使い捨て再現スクリプト。コミットされていない。このドキュメントの「実際のギャップ」セクションから再作成できる。
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.