コンテンツにスキップ

textbringerの型カバレッジ調査 — 不正な同梱`sig/`と、名前空間合成による修正

日付: 2026-06-01。 対象: textbringer v26(前田修吾によるターミナルテキストエディタ)。~/repo/ruby/rigor-survey/textbringer/にコミット3f6b0d2(2026-05-25)でシャロークローンした。 理由:カバレッジ(coverage)調査の対象。textbringerはコーパスの中でも珍しい——自前の手書きRBSを同梱する成熟したプレーンRubyアプリケーションである(sig/lib/textbringer/**.rbs、52ファイル)。そのため次の問いを立てる自然な対象となる: プロジェクト自身のsig/signature_paths:に配線すると、rigor coverageの精度(precision)の数値は上がるのか? 最初の答えは明快な「ノー」だった(§3)——しかしより深い調査(§4)が真の理由を突き止めた: textbringerのsig/不正なRBSであり(rbs validateが拒否する;名前空間宣言が欠落している)、あらゆるコンシューマーにとって不活性で、Rigorはそのビルドエラーを黙って飲み込んでいた。修正——欠落した名前空間を合成する(コミット1529b54d)——はこの「ノー」を+9.6ポイントのカバレッジ(40.3% → 49.9%)に変え、さらに3つのレバー(§5)が65.0%まで押し上げる。§3は調査の記録として残すが、その機構的な結論は§4によって置き換えられる。

以下の数値はすべてrigorリポジトリのFlakeバンドルから得たもので、cwd = 対象BUNDLE_GEMFILE=<rigor>/Gemfileで[[reference_survey_external_projects]]に従って実行した。


0. オンボーディング(rigor-project-init、acknowledgeモード)

Section titled “0. オンボーディング(rigor-project-init、acknowledgeモード)”

rigor-project-initワークフローを最後まで実行した:

  • フェーズ1(検出):プレーンRubyのgem(gemspec駆動のGemfile、Rails/Sinatra/dry-rb/Sorbetのマーカーなし)。test/配下にtest-unitスイート。Gemfile.lockなし、rbs_collection.lock.yamlなし。手書きのsig/を同梱する。
  • フェーズ2(モード):最初のrigor check45エラー(< 100)を報告した → acknowledgeモード、balanced(デフォルト)severity。
  • フェーズ3(プラグイン):なし——プレーンRuby;コア解析器がカバーする。
  • フェーズ4(設定): paths: [lib]target_ruby: "3.3"(gemspecの下限は3.2だが、Prism 1.8が3.2文法を削除した——3.3はサポートされる最古であり、パース上のスーパーセット)、そして同梱RBSを消費するためのsignature_paths: [sig]を持つ.rigor.dist.yml(Rigorはsig/を自動検出しない)。rigor pluginsloaded: 0 load-error: 0(プレーンRubyでは正しい)。
  • フェーズ6(トリアージ): §2を参照。
  • フェーズ7(ベースライン): rigor baseline generate → 29バケット / 74診断、baseline: .rigor-baseline.ymlを配線。再チェック → 診断なし(エンベロープが保たれる)。

記録に値するtarget_rubyの落とし穴: 設定レイヤーは緩い"3.2"形式を受け入れるが、それはPrism.parse(version:)にそのまま渡され、Prism 1.8.1はinvalid version: 3.2を送出する(3.3.0 / 3.4.0 / latestしか同梱していない)。rigor coverageはこれを診断ではなくハードクラッシュとして表面化する。


1. コールドカバレッジのベースライン

Section titled “1. コールドカバレッジのベースライン”

77ファイルにわたるrigor coverage lib、パースエラー0件:

指標
型付けされた式47,601
精密(precise)19,173(40.3%)
動的(不透明、opaque)28,428(59.7%)

ティア(tier)の内訳: 定数27.0%、名前的(nominal)8.1%、シェイプ付き3.7%、リファインメント0.1%、bot 1.5%、動的不透明59.7%。定数畳み込みだけで全精密式の約2/3を占める——リテラル駆動のプロファイルだ。ファイルごとのばらつき: 低い方は約20%(commands/rectangle.rb)、テーマは約50%に集中(定数のカラーテーブル)、重いファイルはbuffer.rb 31.8%、window.rb 26.8%、skk_input_method.rb 56.6%。


2. rigor check / rigor triage(コールド、ベースライン前)

Section titled “2. rigor check / rigor triage(コールド、ベースライン前)”

74診断(45エラー / 29警告)。分布: call.undefined-method 28、flow.always-truthy-condition 24、call.possible-nil-receiver 13、call.argument-type-mismatch 4、call.unresolved-toplevel 4、def.return-type-mismatch 1。

トリアージのヒント(設定ギャップのヒントなし——activesupport-core-ext / gem-without-rbsがないので、ベースラインは実際のセットに対して実行された):

  • systemic-file-cluster —— lsp/client.rb内の9件のcall.undefined-method@io / @wait_threadの読み取りが最悪ケースのnilとして型付けされた: alive?/read/read_nonblock/closeが「for nil」)。単一の構造的原因。
  • unresolved-toplevel(4件) —— window.rbが、解析器がトップレベル呼び出しから追えないリファインメント(using / refine / attrset)を使っている;追求するならpre_eval:(ADR-17)の候補。
  • genuine-bugs(5件) —— 局所化されたレビューの山:
    • floating_window.rb:158/183/194/215 —— Integerに対する< / >で、右辺がDynamic[top] | nil(×4)。比較箇所でnil型付けされた幅/高さのivar。
    • keymap.rb:2 —— define_keymap-> Textbringer::Keymapと宣言されているが、Dynamic[top] | nilと推論された(戻り値型の不一致)。
    • さらにtetris_mode.rbset_cell/render/start_timer「for nil」クラスタ(@gamegrid ivar)—— 使用前にコードが初期化するgamegridに対する最悪ケース健全(sound)なnil読み取り。

[[feedback_false_positive_discipline]]に従えば、ivar-nilのクラスタは動作するコードに対する最悪ケース健全な静的読み取りである → 強制修正ではなく、正直なベースライン素材だ。これらは今やエンベロープ内にある;プロジェクトがこれらを抑え込みたいならrigor-baseline-reduceが後続作業となる。


3. 目玉の発見 —— 同梱sig/rigor coverage0だけ押し上げる

Section titled “3. 目玉の発見 —— 同梱sig/はrigor coverageを0だけ押し上げる”

⚠️ §4(2026-06-01の解決パス)によって置き換え。以下の観測(sig配線がカバレッジを0だけ押し上げた)は本物だが、この節が推論する機構は誤っている。真の根本原因は「スキャナがself/ivar/paramをシードしない」ことではなかった——textbringerのRBSがそもそも一度もビルドされなかったこと(名前空間欠落のNoTypeFoundError)であり、そのためどのレシーバー上でもプロジェクトメソッドが1つも解決しなかった。それが修正されれば、selfレシーバーの呼び出しは実際に解決する。訂正された説明は§4を読むこと;§3は調査の記録として残す。

signature_paths: [sig]をアクティブにしてrigor coverage libを再実行すると、バイト単位で同一の数値が得られた(精密40.3%、すべてのティアのカウントが変化なし)。rigor checkは明らかにRBSを消費している——stderrはproject sig/: 47のロードを報告し、診断は宣言された型を参照する(define_keymap → 宣言されたTextbringer::Keymap)。したがってこれは「checkがRBSを無視している」のではない。

rigor coverageInference::PrecisionScannerを実行する。これはScopeIndexerが構築したスコープチェーンに対するノードごとの軽量なScope#type_ofスキャンであり、ファクトストアを伴う完全なAnalysis::Runnerのフローパスではない。2つの最小限のプローブが機構を特定する(どちらもsignature_paths: [sig]の有無で実行):

プローブA —— レシーバーが.newから名前的型を得る:

b = Textbringer::Buffer.new
n = b.point_min # RBS: () -> Integer
s = Textbringer::Buffer.new_buffer_name("foo") # RBS: (String) -> String

sigなしで6.7%精密 → sigありで40.0%(+5名前的)。カバレッジは実際にRBSを認識している。

プローブB —— レシーバーがself / @ivar / メソッドパラメータ:

class Textbringer::Buffer
def demo
a = point_min # implicit-self receiver
b = self.point_min # explicit self
c = goto_char(a) # self receiver
end
def demo_ivar; @buf.point_min; end # ivar receiver
def demo_param(buf); buf.point_min; end # param receiver
end

sigの有無で42.4%精密 —— 同一。押し上げゼロ。

rigor coverageがRBSのメソッド戻り値型をクレジットするのは、ノードごとのスキャン内で呼び出し箇所のレシーバーがすでに名前的型を持っているときだけである。精度スキャナは、慣用的なオブジェクト指向Rubyを支配する3種類のレシーバー——暗黙的/明示的なself、インスタンス変数、メソッドパラメータ——についてレシーバー型をシードしないので、同梱RBSはどれほど正確でも、実際のメソッド本体において付着すべき型付きレシーバーをほとんど持たない。textbringerのlib/はほぼ全面的にそのような呼び出し箇所であり、ゆえにバイト単位で同一の結果になる。

これはselfレシーバーの呼び出しさえ含むことに注意: ADR-24(暗黙的selfのメソッド解決)とADR-35の戻り値チェックは完全なチェッカーパスで適用されるが、coverageの精度スキャナは囲んでいるメソッドのクラスからselfを型付けしないので、Buffer#demo内で裸で呼ばれたpoint_minでさえ不透明なままになる。

  1. rigor coverageはオブジェクト指向コードベースにおいてRBSの価値を過小評価する。この指標は定数畳み込み+局所的に推論可能な精度の妥当な代理だが、プロジェクト自身の手書き/SIG生成RBSにはほぼ非感応である。なぜならRBSは、ノードごとのスキャンがめったに確立しない型付きレシーバーの呼び出し箇所で効果を発揮するからだ。40.3%という目玉の数値は、RBSへの評決ではなく、定数畳み込み支配のフロアとして読むのが最良だ。
  2. 「sig/は何も変えなかった」を「RBSは無価値だ」として提示してはならない。チェッカーはそれを使う;カバレッジの指標が見ていないだけだ。調査の数値を報告するときは2つのサーフェス(surface)を区別し続けること。
  3. 可能なエンジンの後続作業(未起票): coverageがRBS追加の影響を追跡することを意図しているなら、精度スキャナは囲んでいるクラスからselfを、スコープ内のRBSからivar/param型をシードできるだろう。そうすればプロジェクトのRBSカバレッジが増えたときに指標が動くようになる——現状では動かない。変更要求ではなく観測としてここに記録する;軽量スキャンに重い推論パスを与えるコストと天秤にかけること。

4. 解決パス(2026-06-01)—— 真の根本原因と修正

Section titled “4. 解決パス(2026-06-01)—— 真の根本原因と修正”

§3は1層浅いところで止まっていた。rigor type-ofと直接のReflection.instance_method_definitionクエリでプローブを押し下げると、実際の機構が明らかになった:

  • Buffer#demo内のself実際にTextbringer::Bufferとして型付けされ(ScopeIndexerself_typeを正しくシードする)、ディスパッチャーはNominalレシーバーを無条件にRbsDispatchルーティングする。したがって§3の「スキャナがselfをシードしない」は誤りだった。
  • それでもb.point_minは、bTextbringer::Bufferとして型付けされていてもDynamic[top]を返した——そして名前的型に対するstdlibのケースも、切り分けるまでは同様だった。弁別子はself対明示レシーバーではなかった;それはstdlib-RBS対プロジェクトRBSだった。
  • Reflection.instance_method_definition("Textbringer::Buffer", :point_min)nilを返した。原因: RBS::DefinitionBuilder#build_instanceRBS::NoTypeFoundError: Could not find ::Textbringerを送出し、それをローダーのフェイルソフトなrescue ::RBS::BaseErrornilに飲み込んでいた。textbringerのsig/class Textbringer::Buffer(および50の兄弟)を宣言するが、module Textbringerを一度も宣言しない——そのため名前空間がclass_declsに存在せず、あらゆる定義ビルドが失敗する。rbs validateも同一のエラーで同じファイルを拒否する: textbringerのコミット済みRBSはupstreamで不正であり、RigorだけでなくあらゆるRBSコンシューマー(Steepを含む)にとって不活性だ。

したがって§3のプローブはすべて「どのレシーバー上でもプロジェクトのRBSメソッドが解決しない」ことを測定していた。プローブAの+5名前的はBuffer.newNominal[Buffer]bの読み取り(シングルトン/.newの処理)から来たのであり、point_min/new_buffer_nameの戻り値からではない;プローブBが同一だったのも、やはり何も解決しなかったからだ。レシーバー種別というフレーミングはビルド失敗のアーティファクトだった。

RbsLoader.build_env_forは今やresolve_type_namesの前に宣言されていない各囲み名前空間に対して空のmoduleを合成する(ADR-5のロバストネス——入力に寛容;コミット1529b54d)。存在しない名前だけが追加されるので、整形式なsigセットに対してはノーオペである。これにより:

実行精密名前的ティア
コールド(sig不活性)40.3%8.1%
sigライブ(修正後)49.9%(+9.6pt)15.0%

self.point_min / 裸のpoint_minが今やIntegerに解決する。新しいrbs.coverage.synthesized-namespace:info診断が合成された名前空間(TextbringerTextbringer::LSP)を名指しするので、ユーザーはRBSが不正であることを学び、ソースでそれらを宣言できる。

  1. 40.3%というコールドの数値は、スキャナの制限ではなくビルド失敗によって押し下げられたフロアだった。プロジェクトRBSはビルドさえできればrigor coverage実際に流れ込む;指標は§3が結論づけたよりRBS感応的だ。
  2. self/ivar/paramのフレーミング、訂正版: selfレシーバーは解決する(シード+ディスパッチされる)。インスタンス変数はクラスivarインデックスが型付けしたときだけ解決する;メソッドパラメータは真に不透明なケースのまま(--params=observedやインラインアノテーションが型を与えない限りuntyped)——§3のその部分は生き残る。
  3. 長く残る教訓:サイレントなRBS::BaseErrorのrescueは、もっともらしく見える部分的な数値の背後に、プロジェクトRBS価値の全面的な喪失を隠しうる。signature_paths:のRBSが何もしていないように見えるときは、スキャナについて理論を立てる前にrbs validatebuild_instanceが送出するかを確認すること。

5. カバレッジ押し上げの軌跡(何が数値を上げたか、そしてなぜか)

Section titled “5. カバレッジ押し上げの軌跡(何が数値を上げたか、そしてなぜか)”

名前空間修正の後、「何がさらなるカバレッジを阻むのか?」は、残っている不透明ノードをすべてバケット分けすることで答えられた。順に着地した4つのレバー:

#レバーrigor coverage種別
0ベースライン(sig不活性)40.3%——
1名前空間合成(§4)49.9%エンジンのロバストネス
2非式ノードを指標から除外62.9%指標の訂正
3参照型のスタブ合成(DRb::DRbServer、…)64.3%エンジンのロバストネス
4ブロック→ブロックなしオーバーロードのフォールバック65.0%エンジンのバグ修正

レバー2は単独で最大の動きであり、推論の変更ではない: PrecisionScannerArgumentsNode / ParametersNode / StatementsNode / AssocNode / パラメータ宣言——ランタイム値を持たない構文——を「不透明」としてカウントしていて、それらが全不透明ノードの約49%を占めていた。それらを除外すると、比率は式の精度を測るようになり、それこそが主張している内容だ。

レバー3+4は合わせてTextbringer::Commandsのカスケードを解放した: 単一の利用不可能なDRb::DRbServer参照がモジュール全体のビルドを失敗させていて(レバー3がFP安全にそれをスタブする)、define_commandの186個のブロックを伴うself送信が、メソッドのRBSがブロックオーバーロードを宣言しないために劣化していた(レバー4——textbringer固有ではない一般的な修正)。

残りの約35%の不透明は、untypedなルートからのDynamicカスケード——メソッドパラメータ(--params=observedやRBSのparam型がなければ型なし)と型なしインスタンス変数——に加えて、RBSのないC拡張 / FFI gem依存(cursesfiddle)が支配している。それらが次のフロンティアだ;それらを閉じるにはパラメータ型推論(意図的に先送り——測定された偽陽性リスクがある)またはプロジェクト側のRBS著作が必要で、Rigorの指標/ロバストネスの修正ではない。

6. FPのひねり —— 不完全なRBSを持つattr_*アクセサ

Section titled “6. FPのひねり —— 不完全なRBSを持つattr_*アクセサ”

§5を越えて「より多くのカバレッジ」を追うと、まっすぐ偽陽性(false positive)の天井にぶつかった。Buffer#point / #file_name / #name / #mode / #keymapbuffer.rb内のattr_reader / attr_accessorで定義されているが、textbringerのbuffer.rbsはゲッターを省略している(name= / file_name=のセッターだけを宣言している)。ソース内のメソッドスキャナがdef / define_method / alias_methodを記録したがattr_*マクロは記録せず、かつ発見済みメソッドのテーブルがファイル単位だったため、すべてのbuffer.point呼び出しがcall.undefined-methodとして読まれた: textbringer自身の型に対する167件の誤エラー。したがって、より多くのレシーバーをBufferとして型付けするレバー(パラメータ推論、ivarシード)は、クリーンなカバレッジを生むのではなく、これらのFPを増やしていただろう。

修正(コミット1329cca7)はそれゆえカバレッジ修正ではなくFP修正である: attr_*アクセサを発見済みメソッドとして記録し、テーブルをプロジェクト全体に伝播する(プレーンなdefはファイル単位のままなので、ADR-17のモンキーパッチ診断は変わらない)。textbringer型のundefined-method167 → 30に下がり、正直なベースラインは328から187診断に縮んだ。カバレッジは65.0%のまま(アクセサ呼び出しは発見済みメソッドのティアを通じてDynamic[Top]に解決する——精度を追加するのではなくFPを抑制する)であり、これは正しいトレードだ: 偽陽性の規律disciplineはカバレッジの数値に勝る。コーパスへの教訓: いったんプロジェクト自身の型が型付けされると、そのカバレッジの天井はRBSの完全性によって決まり、それを越えて押すと、本物の精度ではなくRBSギャップのFPが表面化する。


Terminal window
cd ~/repo/ruby/rigor-survey/textbringer # commit 3f6b0d2
BUNDLE_GEMFILE=~/repo/ruby/rigor/Gemfile \
bundle exec ~/repo/ruby/rigor/exe/rigor coverage lib --config .rigor.dist.yml

(rigorのFlakeシェル内で)。§3のプローブファイルは上記のスニペットから再構成できる;捨て設定のsignature_paths:をtextbringerのsig/に向けてRBSをトグルすればよい。

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