コンテンツにスキップ

`rigor-tapioca`? — Tapioca DSL-RBI Coverage Investigation

ステータス: investigation, 2026-05-0920260509-rigor-tapioca-comparison.mdの補足。Tapioca生成のDSL RBIを消費するために専用のrigor-tapiocaプラグインが正当化されるか、それとも既存のrigor-sorbetプラグイン内でギャップを閉じる方が良いかを問う。

推奨(TL;DR): rigor-sorbet内でギャップを閉じる。Tapioca固有の問題が汎用のRBIサーフェスに収まらないものとして浮上するまでrigor-tapiocaを構築しない

Tapiocaのリルコンパイラは特定の構造パターンを出力する。bodyカラムを持つActiveRecordモデルPostの場合、生成されたsorbet/rbi/dsl/post.rbiは以下のようになる(compiler_activerecordcolumns.mdドキュメント参照):

# typed: true
class 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
# ...
end
end

sigは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 checkpost.bodycall.undefined-methodを発行する。プラグインはファイルを読み込み、sigをパースするが、解決機構は2つの名前空間を橋渡しするためにincludeチェーンを辿らない。

同じパターンがTapiocaのコンパイラファミリー全体に現れる:

コンパイラ生成されるモジュール名ミックスイン方向
ActiveRecordColumnsGeneratedAttributeMethodsinclude(インスタンス側)
ActiveRecordAssociationsGeneratedAssociationMethodsinclude(インスタンス側)
ActiveRecordRelationsGeneratedRelationMethodsinclude(インスタンス側)
ActiveRecordScopeGeneratedRelationMethodsextend(クラスメソッド側)
UrlHelpers(ホストモジュール)include(ヘルパーモジュールにミックスイン)
Protobuf(メッセージクラスごと)直接def(ミックスインなし)
SidekiqWorkerクラスへの直接defミックスインなし
ActiveSupportConcernClassMethodsextend

Tapiocaのコンパイラのほとんどがinclude/extendパターンを使用する。ミックスインチェーン解決なしでは、rigor-sorbetはそれらのコンパイラすべての提供を黙って捨てる — まさにプラグインがカバーするはずだった「DSL派生メソッド」の長いテール。

このパターンはsorbet/rbi/shims/下の手書きshimや、コミュニティでキュレートされたrbi-centralアノテーションでも使われる。つまりこれを修正することはTapioca固有の問題ではなく — SorbetのRBI方言のコンシューマーが必要とする一般的なRBSセマンティクスである。

オプションA — rigor-sorbetを拡張する(推奨)

Section titled “オプションA — rigor-sorbetを拡張する(推奨)”

カタログウォーカーにミックスインチェーン解決を追加する。2パスウォーク:

  1. パス1(宣言): すべてのファイルを辿り、クラスごとに記録する:
    • クラス独自のdef sig(スライス1のCatalogWalkerで既に実施)。
    • クラスが行うinclude/extend宣言のリスト(include GeneratedAttributeMethodsextend ClassMethods等)。
  2. パス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は変わらない(宣言クラス/モジュールでキーイングされたまま)。
  • Catalogmixins:マップ({class_name → {include: [...], extend: [...]}})を追加。
  • CatalogWalkerがクラス/モジュールボディ内のトップレベルのinclude/extend CallNodeを認識し、RHS定数名を記録する。
  • Sorbet#lookup_signatureがミスの場合に記録済みミックスインチェーンを辿る。
  1. パターンはTapioca固有ではない。sorbet/rbi/shims/の手書きshimでもコミュニティアノテーションrbi-centralでも使われる。Sorbetはコアやembeddedライブラリ/stdlib RBIで自分自身のために使う。rigor-sorbetを修正することはTapiocaユーザーだけでなく、すべてのRBIコンシューマーに恩恵をもたらす
  2. プラグイン増殖を避ける。独立したrigor-tapiocaはファイルウォーカー、カタログ、ルックアップ機構を複製する — 些細な追加機能のために。
  3. ディスパッチャー階層をきれいに保つ。同じ階層順序で競合する別のプラグインを追加すると、利益をもたらさない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等として認識された場合、ミックスインチェーンウォークを短絡する。
  1. コアのギャップに対処しない。ミックスインチェーン解決が実際の修正。上記のすべてはボーナス機能。
  2. ボーナス機能は小さい。合わせてもおそらく100-200行のコード。プラグインを正当化しない。
  3. エコシステムコスト。プラグインにはREADME、デモ、インテグレーションスペック、gemspec — 薄いフィーチャーサーフェスに対してのオーバーヘッド。
  4. クロスプラグイン調整コストrigor-tapiocaはRBIを読む(rigor-sorbetと重複)ANDPlugin::FactStore(ADR-9)経由でrigor-activerecordの出力を参照する必要がある。双方向クロスプラグイン依存関係は契約の最も脆弱な形状。

少なくともふたつが成立するまでプラグインを延期する:

  1. rigor-sorbetのミックスインチェーン拡張がランディングし、実際のTapioca使用プロジェクトで実証された。
  2. 陳腐化/ドリフト検出への具体的なユーザーリクエストが浮上した(つまり、誰かが古いRBIのバグにぶつかり要求した)。
  3. 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)
end
end

ステップ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)
end
end
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
nil
end

:extend:includeの区別は正確性のために重要: extend Fooはシングルトンクラスにメソッドを追加するため、Post.findextendされたモジュールを通じて解決されるが、Post.new.fooincludeされたモジュールを通じて解決される。)

ステップ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
RBI
end
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_empty
end

plugins/rigor-sorbet/README.mdに「Tapioca DSL RBI互換性」サブセクションを追加し、プラグインがサポートするようになったTapiocaコンパイラと、まだ作業が必要なもの(あれば)をリストアップする。

  • カタログフィールド + ウォーカー認識: 〜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/

プラグインの機能:

  1. sorbet/rbi/dsl/sorbet/rbi/gems/ツリーを辿る (sigを記録するためではなく — rigor-sorbetがそれを処理 — TapiocaのメタデータヘッダーをTo読むため)。
  2. db/schema.rbのmtime(ダイジェストトラッキングのためにIoBoundary#read_file経由)と対応するRBIのmtimeをクロスリファレンス。スキーマの方が新しければplugin.tapioca.stale-rbiを発行。
  3. rigor-activerecordmodel_indexファクト(ADR-9スライス5後にPlugin::FactStore経由)を消費し、そのカラムリストをRBIのGeneratedAttributeMethodsボディと比較。不一致はplugin.tapioca.driftとして発行。
  4. Tapiocaの# DO NOT EDIT THIS FILE BY HANDヘッダーを尊重 — マーク付きファイルを権威あるものとして扱い、dsl/内のマークなしファイルをユーザーshimとして扱う。

いずれも急ぎではない。まずrigor-sorbetスライス7を出荷し、実際のユーザーが汎用RBIパスでは浮上できない陳腐化やドリフトの問題にぶつかった場合にのみ再検討する。

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