コンテンツにスキップ

ADR-38 — Plugin-declared additional initializers

Status: Accepted, 2026-06-02. def形式のadditional_initializers:フィールドとScopeIndexerのnil健全性ゲートの配線が実装され、rigor-minitestが最初の宣言(Minitest::TestActiveSupport::TestCaseTest::Unit::TestCasesetup)を出荷しました。ブロック形式の変種(RSpecのbefore { }let { }。そのivar書き込みはDefNodeではなく呼び出しブロック内に存在します)は後続スライスへ延期されます——ivar書き込み収集器が宣言された呼び出しブロックへ降りていく必要があるためです。

プラグインのManifestフィールドadditional_initializers:を追加する決定を記録します。これは、制約付きクラス上のinitialize以外のどのメソッドもインスタンス変数の状態を確立する、とプラグインが宣言できるようにするものです — PHPStanのAdditionalConstructorsExtensionのRubyにおける対応物です。このフィールドは既存のエンジンゲート1つ(ScopeIndexerの書き込み前読み込みnil健全性ゲート)に供給されるので、フレームワークのライフサイクルメソッド(setupafter_initialize、依存性注入のセッター)で設定されるivarが、兄弟メソッドの本体でnilに拡幅されなくなります。

根拠となるレビュー: docs/design/20260601-plugin-mechanism-pre-1.0-review.md §7.2(PHPStanから採用すべき、最高ROIで最小の拡張型として選定)。

ScopeIndexer#build_class_ivar_indexはクラスごとのivar型を植え付け、contribute_read_before_write_nil!では、あるメソッド本体がivarを書き込む前に読み込むとき、そのivarをConstant[nil]で拡幅します。これが通常のコードで発火するのを防ぐ健全性ゲートはcollect_read_before_write_evidencelib/rigor/inference/scope_indexer.rb)にあります。

if def_node.name == :initialize
init_set = (init_writes[class_name] ||= Set.new)
seen_writes.each { |name| init_set << name }
return
end

initializeで書き込まれたivarは、他のどのメソッドが走るよりも前に設定されたものとして扱われます(RubyはClass.newを介してinitializeが最初に走ることを保証します)。そのため、兄弟メソッドでの書き込み前読み込みはランタイムnilのケースではなく、nil貢献は抑制されます。

このゲートは:initializeというリテラルなメソッド名にハードコードされています。しかしRubyフレームワークは、「本体」メソッドより前に走る他のライフサイクルメソッドでivarを初期化するのが常です。

  • Minitest/ActiveSupport::TestCasedef setup; @conn = …; endを、すべてのdef test_*で読み込む。
  • Railsモデル — 通常のdefとして定義されたafter_initializebefore_validationコールバックメソッド。
  • 依存性注入のセッター — コンテナが使用前に呼ぶdef inject(x); @x = x; end

これらすべてにおいてivarは読み込みメソッドが走る前に確実に設定されていますが、書き込みメソッドがリテラルにinitializeと名付けられていないため、エンジンはその読み込みを書き込み前読み込みとして扱いnilで拡幅します。下流ではそれが@conn.queryConn | nilに対する呼び出しに変え、nilレシーバー診断を表面化させます — これは動作しているコードに対する偽陽性であり、プロジェクトの偽陽性の規律が最悪の失敗モードと位置づけるものです。

PHPStanは対称的な問題(checkUninitializedPropertiessetUp()で初期化されたプロパティを報告すること)を2層で解決しました。Class::methodの宣言的なadditionalConstructors:設定リストと、動的なケース(「Xのすべてのサブクラス」)のためのAdditionalConstructorsExtensionインターフェースです。宣言的な層が一般的なケースをカバーし、拡張が残りをカバーします。Rigorにはすでに宣言的マニフェスト機構(ADR-2/ADR-37のエンジンゲート型の10フィールド)があります。このADRは同じ形状のフィールドをもう1つ追加します。

このフィールドはnil貢献を抑制することしかしません — アナライザーを厳密により寛容にするだけで、決して厳しくしません。マッチを取り損ねても単に助けにならないだけ(既存のnil拡幅がそのまま残る)であり、誤ったマッチは、正当だったかもしれないしそうでなかったかもしれないnil拡幅を取り除くだけです。したがって過剰マッチの欠点は、既存のinitializeゲートがすでに受け入れているのと同じトレードオフであり、しかもプラグイン/設定ごとのオプトインです。構成上、新しい偽陽性を導入することはありえません。これこそが、1.0より前に採用すべき最も低リスクな拡張型である理由です。

Rigor::Plugin::AdditionalInitializer値オブジェクトを運ぶManifestフィールドadditional_initializers:を追加します。

manifest(
id: "minitest",
version: "0.1.0",
additional_initializers: [
Rigor::Plugin::AdditionalInitializer.new(
receiver_constraint: "Minitest::Test",
methods: [:setup]
)
]
)
  • receiver_constraint — 完全修飾クラス名(String)。このエントリーはそのクラスとそのサブクラスに適用されます。
  • methods — マッチするクラス上で、書き込み前読み込み健全性ゲートにおいて初期化子として扱われるメソッド名のSymbol配列。

Plugin::Registry#additional_initializersは、ロード済みプラグインにまたがってエントリーを集約します(他のマニフェストフィールドが使うのと同じ平坦なplugins.flat_map { … }集約)。

ScopeIndexerは既存の単一ゲートでそれらを消費します。クラスマッチはEnvironment#class_orderingを再利用します — これはInference::MacroBlockSelfType(ADR-16ティアA)がSinatraアプリのクラスをSinatra::Baseに対してマッチさせるのに使うまさにその機構です — ので、推移的なサブクラス関係は同じクラスグラフを通じて解決され、解決の失敗はいずれも「マッチなし」に退化します(偽陽性安全)。環境はプリパスのdefault_scope.environmentを通じて、レジストリはenvironment.plugin_registryを通じて到達されます。

ゲートは次のようになります。

if def_node.name == :initialize ||
additional_initializer?(class_name, def_node.name, default_scope)
# … fold writes into init_writes, suppress nil contribution …
end

v1はdef形式のライフサイクルメソッド(def setupdef after_initialize、DIセッター)を扱います — collect_read_before_write_evidenceがすでに走査するメソッド(Prism::DefNode本体を下降します)です。

先送り: ブロック形式の確立イディオム — RSpecのbefore { @x = … }let(:x) { … }です。それらのivar書き込みは、DefNodeではなくメソッド呼び出しに渡されるブロックの内側に存在するので、ivar書き込みコレクター(ivar_write_collectorcollect_def_ivar_writes)は現状それらをまったく見ていません。これらをサポートするには、コレクターがまずblock_as_initializer宣言された呼び出しブロックを下降する必要があります。それは書き込み収集パスへのより大きな変更であり、切り出します。(後続スライスとして追跡。マニフェストフィールドの形状はすでにそれを見越しています — 将来のblock_methods:スロットやkind:判別子が同じ値オブジェクトを拡張できます。)

  1. このADR(def形式の最低ライン)。値オブジェクト+マニフェストフィールド+検証+レジストリ集約器+ScopeIndexerゲート拡張+テスト。最初の消費側としてrigor-minitestMinitest::TestActiveSupport::TestCasesetup)を接続する。
  2. ブロック形式(beforeletの確立をrigor-rspec向けに — 先送り、デマンド駆動。書き込みコレクターが宣言された呼び出しブロックを下降する必要がある。
  3. 動的ロジックフック(完全なAdditionalConstructorsExtensionの対応物、「クラスごとに初期化子集合を計算する」) — 先送り。宣言的フィールドが既知のフレームワークのケースをカバーし、動的なケースはまだ実証されていない。
  • ADR-2/ADR-37 — これはADR-37が良いモデルとして掲げる種類の、宣言的でエンジンゲート型のマニフェストフィールドをもう1つ追加するものです。命令型フックは不要です。
  • ADR-16MacroBlockSelfTypeが確立したEnvironment#class_orderingレシーバー制約マッチを再利用します。
  • ADR-5(ロバストネス)/偽陽性の規律 — 動機となる価値: このフィールドは、動作しているフレームワークコードを脅かすのを止めるために存在します。
CandidateStatusReason
プラグインフィールドの代わりに平坦なグローバル設定リスト(.rigor.yml内のadditional_initializers: ["Minitest::Test#setup"]Deferredプラグイン以外のケース向けにプロジェクトレベルの設定つまみを後で追加できる。プラグインフィールドはフレームワーク知識の正しい置き場(rigor-minitestに同梱され、フレームワークサポートとバージョン管理される)。
任意の読み込みより前にivarを書き込むすべてのメソッドを初期化子として扱うRejected不健全な一般化。ライフサイクルメソッドをはるかに超えて、真の書き込み前読み込みnilケースを抑制してしまう。宣言された(クラス,メソッド)対への制約が安全境界。
直接の親クラスのみでマッチ(class_orderingを飛ばす)Rejected一般的な推移ケース(FooTest < ApplicationTestCase < ActiveSupport::TestCase)を取り逃がす。class_orderingはすでにそれを解決し、安全に退化する。
v1でブロック形式(beforeletDeferredivar書き込み収集パスへの別個の変更が必要(ブロック本体は今日走査されない)。このスライスを小さく検証可能に保つために切り出す。

肯定的:

  • テストコードとRailsコードにおける一群の偽陽性(setup/コールバックで設定された@xT | nilとして読まれる)を除去します — プロジェクトの最上位の価値に直接寄与します。
  • 小さく加算的なサーフェス: 値オブジェクト1つ、マニフェストフィールド1つ、ゲート拡張1つ。新しい命令型フックも、エンジン走査の変更もありません。
  • ADR-37の「宣言的でエンジンゲート型のフィールド」モデルを新しいケイパビリティ上で実証し、rigor-minitestに即座の精度向上をもたらします。

否定的:

  • 誤ったあるいは広すぎる宣言は、正当だったかもしれないnil拡幅を黙って抑制します(オプトインで偽陽性安全な方向に限定されます)。
  • ブロック形式のギャップ(beforelet)は、RSpecユーザーがスライス2まで恩恵を受けないことを意味します。フィールドのドキュメントはdef形式の制限を明記しなければなりません。

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