コンテンツにスキップ

Mangrove(Result / Option / Enum)— ライブラリ調査 + `rigor-mangrove`の形状

日付: 2026-05-30。

ステータス: 調査ノート、設計上のコミットメントはなし

将来の plugins/rigor-mangroveに資する内容であり、すでに出荷済みの rigor-sorbetADR-11)との関係を明確にする。

対象: kazzix14/mangrove(v0.40.0、MIT)。 ソースはGitHub API経由で読んだ。サブモジュールとしてベンダリングしたものは何もない。 目標は、Mangroveを使うRubyコードの型検査——result.and_then { … }opt.unwrap_or(x)case variant; when MyEnum::IntVariantunwrap_in(ctx)の 早期リターンDSL——を、そのコードがDynamic[top]に劣化することなく行うことである。

Rust/HaskellのイディオムをRubyに移植したSorbet対応のツールキット:

キャリア(carrier)形状ファイル
Result[Ok, Err]sealed! interface!Ok / Errのバリアント、リッチなモナディックAPI(map_okand_thenor_elseunwrap!expect!and_err_if、…)lib/mangrove/result.rb
Option[Inner]sealed! interface!Some / Nonefrom_nilablelib/mangrove/option.rb
EnumADT——variants do variant X, Type end、各バリアントが固有の内部型を持つlib/mangrove/enum.rb
早期リターンDSLCollector#collecting + CollectingContextresult.unwrap_in(ctx)Errでショートサーキットするlib/mangrove/result/collector.rb
Extvalue.in_ok / value.in_errのラッパーlib/mangrove/result/ext.rb
TryFromExttry_convert_from(from:, to:, err:) { … } → 動的なtry_into_<snake>lib/mangrove/try_from_ext.rb
Tapiocaコンパイラlib/tapioca/dsl/compilers/mangrove_{enum,result_ext,try_from_ext}.rbが動的なサーフェス(surface)向けのRBIを生成する

マクロ展開調査からの5つの問いを、2つの自明でないサーフェスに適用する:

  1. DSLclass MyEnum; extend Mangrove::Enum; variants do variant IntVariant, Integer; variant ShapeVariant, { name: String, age: Integer }; end; end
  2. メカニズム — 純粋なランタイムメタプログラミング。variantsは各 バリアントの内部型をvariant.instance_variable_set(:@__mangrove__enum_inner_type, …) 経由で退避させ、続いて、定数が初めて参照されたときに各バリアントクラス (initializeinneras_superserialize==)を遅延定義するために、 文字列化されたRubyヒアドキュメントclass_evalするconst_missingを組み込む。
  3. 生成されるサーフェス — バリアントごとに1つのサブクラス。それぞれ MyEnum::IntVariant < MyEnumであり#inner : <宣言された型>を持つ。
  4. 静的展開可能性 — ソースから復元可能。variant X, Typeのペアはブロック内の リテラル引数であり、生成されるメソッド集合は固定である。これはまさに ADR-16の展開対象であり、rigor-dry-struct (Tier C)と同じ形状である。
  5. 最も近い類例 — リテラルな仕様リストに対するLispのdefmacro。Sorbet 自身はこれを静的に見ることができず、それこそがMangroveがTapioca DSL コンパイラを出荷している理由である。

早期リターンDSL(unwrap_in / collecting

Section titled “早期リターンDSL(unwrap_in / collecting)”
  1. DSLCollector[Ok, Err].new.collecting { |ctx| step1.unwrap_in(ctx); step2.unwrap_in(ctx); … }
  2. メカニズムcollectingはブロックを catch(:__mangrove_result_collecting_context_return)でラップする。Err#unwrap_inは 包んでいるErrthrowし、Ok#unwrap_inは内部の値を返す。 Rustの?演算子を手で実装したものである。
  3. 生成されるサーフェス — なし。これは制御フロー構文である。
  4. 静的展開可能性(ハッピーパスでのunwrap_in : Ok)はsigにあるが、 Errでの制御フローの分岐はsigでは表現できない。フローを意識した アナライザーによってのみ復元可能である。
  5. 最も近い類例T.must / T.absurdrigor-sorbetがすでに flow_contribution_for + 例外的エッジとしてモデル化している)、またはRustの?

主要な知見——型のソースはすでにカバー済み

Section titled “主要な知見——型のソースはすでにカバー済み”

2つのファクト(fact)が「型ソースプラグイン」案を崩す:

  1. sig/mangrove.rbsは空であるVERSION: Stringのみ)。したがって、 ADR-25signature_paths:ルート—— ライブラリのRBSをプラグインと一緒に出荷する——は利用できない。出荷すべきRBSが 存在しないのである。
  2. rigor-sorbet(スライス(slice)1〜8、機能完備)はすでにMangroveの 本物の型ソースを取り込んでいる。 MangroveのResult/Option APIは完全に sig {}で注釈されており(スライス1〜3がこれらを、ジェネリックの適用も含めて カバーする)、Enumの動的なバリアントはMangroveのTapioca DSLコンパイラによって sorbet/rbi/{gems,dsl}/に実体化される——これをrigor-sorbetのスライス4 (RBIツリーウォーク)とスライス8(Generated*include/extendの引き上げ)が すでにカタログへ取り込んでいる。

したがってTapiocaを使うMangroveプロジェクトでは、rigor-sorbetを有効化すれば すでにResult / Option / EnumのシグネチャがRigorに流れ込む。専用プラグインは新たな 型ソースを一切追加しない。これはrigor-ffi-plugin-authorの「まずプラグインを やめる方向に自分を説得せよ」という結末である。

rigor-mangroveやはり存在価値を持つ場面

Section titled “rigor-mangroveがやはり存在価値を持つ場面”

rigor-sorbet(≈ Sorbetレベルの精度)が構造的にできず、Rigorのエンジンにとっては ネイティブである3つのこと:

#ケイパビリティ(capability)sig取り込みではなぜ不可能かRigorのサーフェス
フローとしてのunwrap_in(ctx) / collectingハッピーパスはOkだが、Errthrowはsigにない制御フローの分岐である制御フロー解析 + flow_contribution_for(例外的エッジ)。rigor-sorbetT.absurd(スライス6)/ T.mustに対して行うのとまったく同じ
is_a?(Result::Ok) / Some/Noneの網羅的ナローイング(narrowing)READMEはok?/err?を非推奨とし、「Sorbetが静的に型付けできるように」is_a?を推奨している。sealed!なADTは教科書的な網羅性の対象である制御フローのナローイング(sealedな階層に対する判別子)
Tapioca抜きのEnum DSLプロジェクトがTapiocaを実行しない場合、const_missing/class_evalのバリアントはSorbetにもrigor-sorbetにも見えないADR-16のマクロ展開でvariants do … endをソースから直接展開する——rigor-dry-structと同じパターン

④(限界的)Result/Optionはファンクタ/モナドなので、ADR-20App[F, A]map_ok / and_thenを精密に貫通させられる——だがSorbetのsigは すでにtype_parameter(:NewOkType)を通過させているので、ここでの向上は小さい。 ローンチの正当化理由にはならない。

正直なフレーミング: rigor-mangroveは精度/制御フローのプラグインであり、 型ソースプラグインではない。 ③が単独で最も強い正当化理由である(Enumのための Tapioca依存を取り除く)。①②はsig取り込みでは決して到達できないエンジン協調の勝ちである。

  • 配置 — Mangroveは実在のgemなので、examples/ではなくplugins/rigor-mangrove (プロダクション)。
  • スコープ — 並行する型ソースとしてではなく、rigor-sorbet上に重ねた ①②③の精度プラグインとして定義する。
  • まずリスクを下げる — プラグインコードにコミットする前に、小さな調査を実施する。 Mangroveを使うフィクスチャを取り、rigor-sorbet単体で検査し、それが Dynamic[top]に落ちる箇所を正確に記録する。その計測が、①②③のどれが実際に発火するか、 どの順で構築するかを決める。フィクスチャは 外部調査の慣例に従い~/repo/ruby/rigor-survey/の 配下に置く。
  • プロセスrigor-plugin-authorスキルで作成する(Phase 0/0.5の メンテナールーティング → 要件 → テンプレート → スキャフォールド → ウォーカー → 統合 仕様)。非メンテナーが主導する場合はADR-31の サードパーティパスが適用される。

推奨される順序: 調査(rigor-sorbet単体)→ プラグイン。精度を上に構築する前に、 「型ソースはすでにカバー済み」という仮説を検証すること。

契約適合チェック(2026-05-30、プラグイン作成中)

Section titled “契約適合チェック(2026-05-30、プラグイン作成中)”

実際のv0.1.xプラグイン契約(contract)(Macro::HeredocTemplaterigor-dry-structrigor-sorbet)を3つのスライスに照らして読むと、構築可能性の 見通しが大きく変わる:

#スライス契約適合判定
unwrap_in(ctx) / collectingの早期リターン今すぐ構築可能。手書きのウォーカー: flow_contribution_for:unwrap_in呼び出しを認識し、ハッピーパスではレシーバーのOk型を、Errでは例外的エッジを返す——rigor-sorbetT.absurd(スライス6)/ T.mustの認識器と構造的に同一。v0スライスとして出荷
is_a?(Result::Ok) / Some/Noneのナローイングエンジンの領分であり、プラグインのサーフェスではないsealed!な階層に対するナローイングはコアの制御フロー解析である。さらに悪いことに、Mangroveのsealed性はSorbetの注釈なので、エンジンはそれをrigor-sorbet経由でしか知れない。きれいなプラグインフックがない。先送り。エンジン + rigor-sorbetのsealed階層伝達が必要。
Enumのvariants do variant Const, Type endADR-16のTier Cには適合しない。Tier Cはsymbol_arg_positionでリテラルなSymbolを抽出し、呼び出し元クラスにメソッドを生成する。Mangroveのvariant定数を取り、#inner : <Type>を持つネストしたサブクラスMyEnum::IntVariant < MyEnum)を生成しなければならない。rigor-dry-structはまさにこれを明示的に先送りしている(「Address::Detailsを生成するネストブロック形式……はTier A + Tier Cの合成 + const_set生成が必要。先送り」)。ADR-16の契約改訂(ネストクラス/const_set生成のティア)が必要。v0.1.xのプラグインスコープ外。

正味: 現行契約の下で出荷可能なrigor-mangroveプラグイン = スライス①のみ。 ②はエンジンの作業、③はADR-16の改訂が必要。rigor-plugin-authorの 「回避策を発明するのではなく、立ち止まって尋ねる」ルールに従い、スコープの決定 (①単体で出荷するか、②/③のエンジン/ADR作業を始めるか)はメンテナーに差し戻す。

(注: 構築されたスライス①は、上の表でスケッチしたunwrap_inの制御フローウォーカーでは ない——作成中の計測がそれを、よりシンプルで契約内のキャリアジェネリックの インスタンス化にunwrap呼び出し箇所でリダイレクトした。エンジンは Result::Ok.new("x")からジェネリックを推論しない(生のNominalでtype_argsなし)が、 宣言された戻り値が適用済みジェネリックであるメソッド(-> Result[String, E])は type_args運ぶので、プラグインはtype_args[0]を読んでそれをunwrapの戻り値として 寄与させる。これはコミットf7b20275でplugins/rigor-mangroveとして出荷された。)

調査フィクスチャの知見(2026-05-30、ランディング後)

Section titled “調査フィクスチャの知見(2026-05-30、ランディング後)”

スライス①を本物のチェーン——Sorbetのsig経由で型付けされたMangrove(その実際の 型ソース)であり、プラグイン自身のデモが使う手書きのRBSではない——に対して検証した。 フィクスチャ: ~/repo/ruby/rigor-survey/_mangrove-probe/(+ アップストリームgemの 浅いクローン~/repo/ruby/rigor-survey/mangrove/。これは本物のインラインsigと sorbet/rbi/ツリーを持つ)。

プローブ(現実的な形状——戻り値がユーザー定義のジェネリックであるプロデューサー):

# typed: true
class Factory
extend T::Sig
sig { returns(Mangrove::Result::Ok[String, StandardError]) }
def self.make = Mangrove::Result::Ok.new("ok")
end
Factory.make.unwrap!.uppercaze # typo on the unwrapped value

rigor-sorbet + rigor-mangroveの両方を有効化して実行(両方ともrigor plugins[OK]を報告): 診断ゼロFactory.makeDynamic[top]に解決されるので、 unwrap!のレシーバーはキャリアNominalを持たず、rigor-mangroveは何もせず、タイポは 黙って出荷されてしまう。

根本原因(ソースで確認済み)rigor-sorbetTypeTranslator#translate_t_subscripttype_translator.rb:177-188)は、ジェネリックの 適用をNominal[name, type_args]へインスタンス化するのを、T::名前空間の定数T::Array[E]T::Hash[K,V]T::Class[T])に対してのみ行う。 Mangrove::Result::Ok[String, StandardError]のようなユーザー定義のジェネリックは T::名前空間ではないので、translate_calldegradeduntypedへ落ちる。レシーバーの型は rigor-mangroveが見る前に失われている。

帰結——動くチェーンと動かないチェーン:

キャリア + プロデューサー戻り値の型ソースunwrap時のレシーバーrigor-mangrove
RBS-> Mangrove::Result::Ok[String, E]Nominal[…, [String, E]](エンジンがネイティブにインスタンス化)発火する ✓(デモ + 6 specs)
Sorbet sigrigor-sorbet経由)Dynamic[top](ユーザージェネリックが劣化)何もしない ✗

したがってプラグインは正しく有用だが、RBSで型付けされたパスでのみそうである。 MangroveプロジェクトはSorbetファーストなので、現実的なパスは今日発火しない方である。 プラグインの価値は現在、それがめったに持たない型のソースの背後にゲートされている。

最もレバレッジの高いフォローアップ(この調査で浮かび上がった)rigor-sorbetTypeTranslatorを拡張し、T::名前空間のものだけでなく、ユーザー定義のジェネリックの 適用(Const[A, B]Nominal[Const, [A, B]])もインスタンス化させる。これは既存の 1つのプラグインに対する小さく、スコープの明確な変更(プラグイン作成者パイプラインではなく、 通常の編集)であり、現実のSorbet型付けチェーンでrigor-mangroveを解放する——加えて、sigで 表現されたあらゆるジェネリックなユーザー型(MyBox[T]Pagy::Result[T]、…)に利益を もたらす。より安価な応急処置——rigor-mangroveと一緒にキャリアRBSオーバーレイを signature_paths:で出荷する——は、消費者のプロデューサーメソッドもまたRBSで型付け されている場合にのみ役立つが、Sorbetプロジェクトのそれはそうではないので、それ単独では ギャップを埋めない。

推奨される順序: rigor-sorbetのユーザージェネリック変換 → このプローブを再実行 → (その後)rigor-mangroveの②/③。最初のものなしでは、②/③は実際には依然untypedである レシーバー型の上にさらに精度を重ねることになる。

更新——rigor-sorbetの修正がランディングした(2026-05-30)

Section titled “更新——rigor-sorbetの修正がランディングした(2026-05-30)”

rigor-sorbetTypeTranslatorはユーザー定義のジェネリックの適用を翻訳するようになった (translate_user_subscript): sig位置にあるT::をルートに持たない任意のConst[A, B]Nominal[name, type_args]にマップされる(引数を再帰的に翻訳する)。上のプローブを修正版で 再実行すると: chain.rb:12:22: error: undefined method 'uppercaze' for String——チェーンは端から端まで 解決するようになった(Factory.makeNominal["Mangrove::Result::Ok", [String, StandardError]] → rigor-mangroveがtype_args[0]を読む → String → タイポが 捕捉される)。したがってSorbetで型付けされたMangroveプロジェクトでは、rigor-sorbet + rigor-mangroveを一緒に有効化することで今や精度が得られる。RBS限定のゲートは解除された。

偽陽性チェック: 通常どおり定義された(Ruby/Sorbetの)キャリアに対する sig { returns(KnownClass[T]) }は、誤ったundefined method []診断を生成しない—— エンジンはそうしたクラスのsigブロック本体をコードとして解析しないからである。([]診断が 表面化した唯一の設定は、キャリアが手書きのジェネリックRBSでのみ宣言された人工的なもので、 これは本物のMangroveの形状ではない。)

②/③は依然として未解決の項目であり、その順序であるが、今やレシーバーはunwrap箇所で 実際にその型を運ぶようになった。

実プロジェクトでの偽陽性検証(2026-05-30)

Section titled “実プロジェクトでの偽陽性検証(2026-05-30)”

ユーザージェネリックの翻訳は、これまでuntypedだったsigの戻り値を型付きNominalに変え、 それはより多くのメソッド呼び出しが検査可能になることを意味する——これは精度の勝ちだが、 原理的には実コード上で偽陽性を導入しうる。入手可能な最もジェネリックを多用する実Sorbet プロジェクト——アップストリームのMangrove gem自身(~/repo/ruby/rigor-survey/mangrove/lib + specrigor-sorbet下)——に対する前後差分で検証した: translate_user_subscript ブランチを有効にしてrigor check --format jsonを実行し、次にそれをコメントアウトして 実行し、診断集合を差分する。

結果: 前860診断、後860診断——導入0、除去0。変更は確かに発火する(Mangroveのsigは Mangrove::Result[…] / Option[…]を返し、specチェーンはそれらの戻り値からキャリアメソッドを 連鎖させる)が、新たな診断を一切表面化せず、何も黙らせなかった。したがって実Sorbetコード上で この翻訳は偽陽性中立である——動いているコードを脅かすことなく型を鋭くする、プロジェクトの 最高水準のFP規律を尊重する。(ここでは精度診断も追加しなかった。Mangroveのspecは Result::Ok.new(...)——生のnominalで、コンストラクタからのジェネリック推論はない——経由で キャリアを構築しており、sigが返すジェネリックを消費しないからである。この変更が鋭くするパスは 実在するが、Mangroveのspecの支配的な使い方ではない。)

調査フィクスチャの知見——② is_a?のナローイング(2026-05-30)

Section titled “調査フィクスチャの知見——② is_a?のナローイング(2026-05-30)”

is_a?(Result::Ok) / Errのナローイングをエンジンに対して直接プローブした (~/repo/ruby/rigor-survey/_mangrove-probe/narrow/)。契約適合表の②行 (「エンジンの領分。ナローイングなし」)は悲観的すぎた——クリーンなキャッシュに対して計測すると、 コアのナローイングはすでに動作する。(そうでないと示唆した最初のプローブは、 古いキャッシュ/フォーマッタによる列ずれのアーティファクトだった。ナローイングのプローブの間は 常にrm -rf tmpせよ。)

適用済みジェネリックのバリアントのユニオン(union)として型付けされた値—— RBS/sigの戻り値がOk[String, Integer] | Err[String, Integer]であるプロデューサーから得られる 形状——に対して、is_a?のナローイングは正しく、かつ両方のエッジで型引数を保存する:

位置推論された型
if r.is_a?(Mangrove::Result::Ok)rMangrove::Result::Ok[String, Integer]
r.value(Ok#value → OkType)String
elserMangrove::Result::Err[String, Integer]
r.error(Err#error → ErrType)Integer

どちらの方向でも偽陽性はない(そしてユニオンのメソッドディスパッチは寛容なので、 ナローイングなしでもOk | Errに対してOk専用のメソッドを呼んでも誤発火しない)。 エンジンのパスはNarrowing#narrow_union_class → メンバーごとの narrow_nominal_to_classであり、Environment#class_orderingが、ドロップする兄弟に対して :disjointを返す。コアクラスのユニオン(Array[String] | Hash[…])も同一に振る舞う。既存の カバレッジはspec/rigor/inference/narrowing_spec.rb(「ユニオンを要素ごとにナローイングし、 互いに素なメンバーをドロップする」)にある。

唯一の実際の②ギャップ——ダウンキャストの型引数伝播。値がバリアントのユニオンではなく 親/インターフェイスのジェネリック(Mangrove::Result[String, E])として型付けされている とき——これはSorbetのsigが生成する形状である——is_a?(Result::Ok)のナローイングは部分型を 解決するが型引数を落とす: narrow_nominal_to_class:superclass分岐 (narrowing.rb:2033)が裸のType::Combinator.nominal_of(class_name)を返すので、 Res[String, Integer]は裸のResOkにナローイングされ、ResOk#valueuntypedに劣化する (計測済み)。引数を正しく運ぶには継承エッジを通したジェネリック置換が必要である (RBSのclass Child[..] < Parent[..]宣言に従い、親の型引数を子のパラメータにマップする)—— 位置による単純コピーは一般には健全でない(class Foo[T] < Bar[T, Integer])。これは本物の、 微妙なコアエンジンの作業であり、docs/type-specification/control-flow-analysis.mdの下で 規範的であり、偽陽性の圧力はない(精度を失うだけで、動いているコードを決して脅かさない)。 需要駆動。先送り。これはまた、rigor-mangroveのunwrapをis_a?ダウンキャストの後に (すでにユニオンで型付けされたレシーバーの上だけでなく)発火させられるものでもある——だが それも、再び、精度の向上であって正しさの修正ではない。

正味: ②は一般的なケースに対してナローイングロジックの変更を必要としない(動作する)。 残る作業はダウンキャストの型引数伝播の精度向上であり、これはコアエンジンのジェネリック置換であって、 Mangrove作業に束ねるのではなく、それ自身でスコープを定めて設計レビューすべきである。

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