コンテンツにスキップ

マクロ/DSL展開 — ライブラリ別調査

日付: 2026-05-15。2026-05-15にdry-rbトリオ(dry-types, dry-schema, dry-struct)を追加するためリビジョン。

ステータス: 調査メモであり、設計上のコミットメントではない

ROADMAPのO2作業項目(「マクロテンプレート+ヒアドキュメントRuby展開」)の前提条件であり、 ADR-16(マクロ展開基盤)の根拠となる証拠。

このドキュメントはライブラリ別の調査結果のみである。集約された評価(rigorに対する決定の形)はADR-16にあり、本メモはその決定の入力となる。

各セクションは、対象サブシステムについて以下の5つの問いに答える。

  1. ユーザー向けのDSL表面(短いスニペット1つ)。
  2. 実装メカニズム — どのRubyメタプログラミングプリミティブがそれを担うか。
  3. 呼び出し箇所で何が生成されるか(メソッド名、アクセサ、コールバック)。
  4. 静的展開可能性 — 静的解析器はソースのみから生成された表面を復元できるか?
  5. もっとも近い類似物 — Lispのdefmacro、PHPStanのトレイトインライン展開、ランタイム登録、それ以外のいずれか。

ソースコードは/tmp/<lib>-research/内の浅いクローンで読んだ。これらのクローンはコミットされておらず、サブモジュールとしても参照されていない。引用箇所はクローンのファイルパスを指しており、将来の読者は再クローンして検証できる。


ActiveSupport::Concern(Rails 8-0-stable)

Section titled “ActiveSupport::Concern(Rails 8-0-stable)”
  1. DSL

    module M
    extend ActiveSupport::Concern
    included { scope :active, -> { where(active: true) } }
    class_methods do
    def foo; end
    end
    end
    class Host; include M; end
  2. メカニズムConcernModule#append_features / Module#prepend_featuresをオーバーライドする(activesupport/lib/active_support/concern.rb:129-153)。 最初のinclude時に次を行う: (a)キューに入った依存関係を実行する、(b)superを呼ぶ(本物のinclude)、(c)定義されていればbase.extend const_get(:ClassMethods)を実行(:137)、(d)base.class_eval(&@_included_block)を実行(:138)。 class_methods(&blk)const_set+module_evalによってClassMethodsサブモジュールを遅延的に作成する(:209-215)。 included(&blk) / prepended(&blk)はブロックを保存するだけである(:158-187)。

  3. 生成されるもの — ホストはMのインスタンスメソッドを得て、ホストのシングルトンクラスはM::ClassMethodsを得て、included do … endブロックはselfをホストとして実行される(したがってその中のDSL形式の任意の呼び出しはホストに着地する)。 推移的: @_dependencies内のすべてのモジュールがsuperの前に再帰的にインクルードされる(:135, :148)。

  4. 静的展開可能性 — 部分的に展開可能:

    • 自明に展開可能ClassMethodsextendとインスタンスメソッドのincludeの半分。ウォーカーはinclude Mを「M::ClassMethodsextendMのdefをHostにミックスインする」と扱える。
    • 再帰的に展開可能@_dependenciesチェーンは純粋にレキシカルである(モジュール本体内のinclude OtherConcern)。
    • ブロック展開はブロック本体に依存するincluded do … end遅延class_evalである: ブロックASTを逐語的にインライン展開し、インクルードする側に再バインドする。他のrigorウォーカーがすでに理解しているDSL呼び出し(例: has_one_attachedhas_manyscope)は、インクルードする側でそれらのウォーカーを再トリガーする。
    • 困難なケース: included do内でのRails.env / defined?(…)による分岐、補間されたヒアドキュメントのclass_eval、ランタイムに具体的なbaseに対して実行される4引数のincluded(base)本体。
  5. もっとも近い類似物PHPStanのトレイトインライン展開。内容は静的なテキストでホストクラスに再バインドされる。Rustのderiveマクロと異なり、展開のターゲットはConcern自身ではなくインクルードする側である。展開はinclude箇所で発火し、concernの定義箇所では発火しない。


ActiveStorageのattachedマクロ(Rails 8-0-stable)

Section titled “ActiveStorageのattachedマクロ(Rails 8-0-stable)”
  1. DSL

    class User < ApplicationRecord
    has_one_attached :avatar, service: :s3, strict_loading: true
    end
    class Gallery < ApplicationRecord
    has_many_attached :photos
    end
  2. メカニズム — 両者は activestorage/lib/active_storage/attached/model.rbに存在し、ActiveSupport::Concernを拡張するモジュール(:10)内のclass_methods do … end:54)を通じて公開される。アクセサのペアはgenerated_association_methodsに対するヒアドキュメントclass_evalによって生成される(has_one_attached:111-126has_many_attached:213-230)。リフレクションの帳簿管理はadd_attachment_reflectionを通じて行われる(:146-154 / :250-258)。サポート関連付け(has_onehas_manyscopeafter_saveafter_commit)は追加のインラインDSL呼び出しである。

  3. 生成されるもの has_one_attached :avatarの場合(:photosの複数形サフィックス名についてはミラー):

    • def avatarActiveStorage::Attached::Oneを返す(:113-116)。
    • def avatar=(attachable):118-125)。
    • has_one :avatar_attachment(リーダー+ライター+ARリフレクション)。
    • has_one :avatar_blob, through: :avatar_attachment
    • scope :with_attached_avatar(クラスレベルのリレーション)。
    • after_saveafter_commitコールバック。
    • User.reflect_on_attachment(:avatar)はリフレクションを返す。
    • has_many_attachedの場合: 同じ5名前パターンが複数形化される。 主要アクセサはActiveStorage::Attached::Manyを返す。
  4. 静的展開可能性 — 高度に展開可能:

    • メソッド名はシンボルリテラル引数に対する純粋な文字列補間である:113, :115, :118, :128-131はすべて"#{name}"を使う。シンボルリテラルが可視ならば、生成されるすべての名前は計算可能である。既存のrigor-activestorageプラグインはすでにこれを活用している。
    • 戻り値の型は安定しており、レキシカルに決定される
    • 条件付き生成は浅いif ActiveStorage.track_variantswith_attached_*の本体のみを分岐させ、表面のシグネチャは分岐させない。
    • 困難なケースhas_one_attached(some_method)のように非リテラルな名前。現在のプラグインは諦める。AST展開器も同じ制限を継承する。
    • Concernとの合成 — ユーザー側のextend ActiveSupport::Concernincludedブロック内にhas_one_attachedが現れる場合、Concernウォーカーはまずそのブロックをインクルードする側に再ターゲットしなければならない。そうすればattachedマクロ展開器がそこで発火する。2つのパスはかみ合う。
  5. もっとも近い類似物PHPStanのトレイトインライン展開。リテラルなシンボル/文字列引数によってパラメータ化された固定的なテキストテンプレートで、使用箇所で1回展開される。Lispのdefmacroと異なり、テンプレートは不透明なテキスト(ヒアドキュメント)なので、展開器は汎用のマクロ評価器ではなくマクロごとのテンプレートテーブルを必要とする。


  1. DSL

    class Job
    include AASM
    aasm do
    state :sleeping, initial: true
    state :running, :cleaning
    event :run do
    transitions from: :sleeping, to: :running
    end
    end
    end

    複数マシンバリアント: aasm(:work_status) do … endlib/aasm/aasm.rb:28-37)。

  2. メカニズムinclude AASMAASM::ClassMethodsで拡張し、ステートマシンスロットを登録する(lib/aasm/aasm.rb:8-17)。 クラスレベルのaasm(*args, &block)state_machine_nameをキーとしてAASM::Baseを構築する(あるいは再利用する)。そして@aasm[state_machine_name].instance_eval(&block)によってブロックを実行する(:28-64)。 ブロック内ではstate / eventAASM::Baseのインスタンスメソッドであり、剥き出しのマクロではない。 state :pendingは状態を登録し、safely_define_methodによってホストクラスにpending?を注入する (lib/aasm/base.rb:90-108)。 event :submit do … endはイベントを登録し、may_submit?submit!submitsubmit_without_validation!を注入する (base.rb:111-143)。

  3. 生成されるものホストクラス上で:

    • state:fooごと(base.rb:99-106): foo?、定数STATE_FOO、そしてcreate_scopesと永続化アダプタがaasm_create_scopeに応答するならARスコープfooが生成される(persistence/base.rb:60-86)。
    • event:barごと(base.rb:120-141): may_bar?(*args)bar!(*args, &blk)bar(*args, &blk)bar_without_validation!namespace:を指定すると、追加のエイリアスmay_bar_NS?などが生成される。状態述語はNS_foo?となる。
    • クラスレベル: .aasm.aasm(:name)stateseventsstate_machinehuman_event_nameなどを公開するAASM::Baseを返す。
  4. 静的展開可能性 — おおむね扱える:

    • メソッド名はstate / eventに渡されるシンボルリテラルから決定論的に決まる。
    • クラスごとに複数のステートマシンがaasm(:column_name) do … endで存在しうる — それでもソース上は可視である。
    • namespace:オプションはオプション値を読む必要がある。値がリテラルのSymbol / Stringまたはtrueリテラル(aasm(:name) doからステートマシン名のシンボルを読む)の場合は復元できる。
    • state :a, :bのアリティオーバーロード: Hashでない先頭引数すべてを状態名として扱う。
    • 継承: 親クラスの定義はサブクラスより前にリプレイされる必要がある(aasm.rb:21-25)。
    • 真に動的かつまれなもの: 状態名が非リテラル、Proc値のinitial_state、ランタイムのrespond_to?(:aasm_create_scope)にゲートされたARスコープ生成。述語/イベントメソッド生成はアダプタチェックに依存しない。
  5. もっとも近い類似物PHPStanのトレイトインライン展開/Lispマクロスタイルの展開。plugins/rigor-statesman/がすでにstate_machine_class.state :fooを歩いているのと精神的に同一である。AASMはstatesmanと同じプラグインアプローチの射程内にある。


  1. DSL — 3つの箇所:

    • モデル: class User < ApplicationRecord; devise :database_authenticatable, :recoverable, …; endlib/devise/models.rb:79)。
    • ルーティング: Rails.application.routes.draw内のdevise_for :userslib/devise/rails/routes.rb:226)。
    • コントローラー(暗黙的): current_useruser_signed_in?authenticate_user!user_sessionDevise::Controllers::Helpers.define_helpersにより合成される (lib/devise/controllers/helpers.rb:113)。
  2. メカニズム — gemロード時のDevise.add_module(:database_authenticatable, …)lib/devise/modules.rb:9Devise.with_options model: trueでラップ)。 add_modulelib/devise.rb:397-440)はALLに追加し(:400)、 STRATEGIES/CONTROLLERS/ROUTES/URL_HELPERSを登録し、model: trueのときDevise::Models::DatabaseAuthenticatableをオートロードする(:436)。そしてDevise::Mapping.add_module module_nameを呼ぶ(:439)。これは述語def #{m}?; modules.include?(:#{m}); endclass_evalする (lib/devise/mapping.rb:113-119)。

    devise(*modules)lib/devise/models.rb:79-112)はシンボルをDevise::ALL.index(s)でソートする(:83)。各mについて mod = Devise::Models.const_get(m.to_s.classify)を実行する(:91)。 mod::ClassMethodsが存在すればextendする(:93-95)。マッチする available_configsセッターを適用する(:97-103)。include modを呼ぶ(:106)。

    devise_for :usersDevise.add_mapping(:users, options)を呼ぶ (lib/devise/rails/routes.rb:242)→Devise::Mapping.new(:users, …)@singular = :userで生成される(mapping.rb:56)→登録された各ヘルパーホストが define_helpers(mapping)を得る(lib/devise.rb:368)。define_helpers<<-METHODSヒアドキュメントをDevise::Controllers::Helpersに対してclass_evalする(helpers.rb:116-134)。

  3. 生成されるものUserに対するdevise :database_authenticatable, :recoverableの場合: include Devise::Models::Authenticatable(常に) + Devise::Models::DatabaseAuthenticatable + Devise::Models::Recoverable に加えて、各モジュールのincluded do本体(例: attr_reader :passwordafter_update :send_email_changed_notificationlib/devise/models/database_authenticatable.rb:34-40)、インスタンスメソッド(password=valid_password?、…)、ClassMethods(例: Recoverable.reset_password_by_token)。

    devise_for :usersの場合: すべてのコントローラーにmapping.nameによってパラメータ化された4つのメソッドが着地する: authenticate_user!user_signed_in?current_useruser_session

  4. 静的展開可能性 — 4つの正準な障害物すべてが現実である:

    • シンボル→String#classify経由の定数は機械的である(復元可能)。
    • class_eval <<-METHODS文字列は#{mapping} / #{m}を補間する — マッピング名がわかれば決定論的。
    • Devise.mappingsdevise_for :usersが実行されたときのみ追加される。ヘルパー名の集合を知るためにはconfig/routes.rbを嗅ぎ取る必要がある。
    • インクルード順序はDevise::ALL.index(s)でソートされる — 安定しており、lib/devise/modules.rbをミラーするプラグインテーブルに焼き込める。
    • ActiveSupport::Concern.included doブロックはターゲットクラスでリプレイされなければならない。extend ClassMethodsはクラスレベルメソッドを追加する。 send(:"#{config}=", value)はオプションからクラス状態をさらに変異させる。
  5. もっとも近い類似物PHPStanのトレイトインライン展開+同梱レジストリ。Lispマクロではない — 呼び出し箇所(devise :database_authenticatable, …)はテーブル駆動のincludeシーケンスであり、その入力(シンボルリスト、 Devise::ALLの順序)と出力(具体的なModule定数 +included doの副作用+ClassMethods拡張)はlib/devise/modules.rbをミラーするレジストリから静的に解決可能である。 rigor-deviseプラグインは以下を必要とする: (1)その同梱レジストリ、(2)devise :a, :b, …のモデル側ウォーカー、(3)mapping.singularによってパラメータ化された4つのメソッド定義を合成するdevise_for :fooのルート側ウォーカー、(4)ユニオンリソースヘルパーの場合のためのdevise_groupウォーカー。マクロ入力がリテラルなシンボルである場合、Ruby実行は不要。ユーザーのイニシャライザからのサードパーティのDevise.add_module呼び出しは同梱レジストリの外にある — イニシャライザウォーカーまたは手動の拡張APIが必要。


  1. DSL

    class Types::User < GraphQL::Schema::Object
    field :name, String, null: false
    field :display_name, String, null: false do
    argument :upcase, Boolean, required: false
    end
    def display_name(upcase: false); … end
    end
    class Types::Status < GraphQL::Schema::Enum
    value "ACTIVE"
    value "DISABLED", value: :off
    end
  2. メカニズムHasFields#field純粋なメタデータレコーダーである。Schema::Fieldを構築し(lib/graphql/schema/member/has_fields.rb:89)、own_fieldsに格納する(:124-135)。Field#initialize@resolver_method = (resolver_method || name_s).to_symを記録する (lib/graphql/schema/field.rb:270)。field.rbにはdefine_method / class_eval / module_eval呼び出しは1つもない

    解決は完全にランタイム動的である。Field#resolveは次のように振る舞う: if obj.respond_to?(resolver_method) … obj.public_send(resolver_method, **ruby_kwargs)field.rb:757-765)。これに該当しない場合はHashルックアップまたは@fallback_valueにフォールスルーする。

  3. 生成されるもの — graphql-rubyはTypes::Userdefine_method :display_nameしない。ユーザーが手動で定義する(ドキュメントはconflict_field_name_warninghas_fields.rb:318で警告する)。自動定義されるメソッドは次のみ:

    • HasFields#global_id_fielddefine_method(field_name)has_fields.rb:154)。
    • Enum.generate_value_methoddefine_singleton_methodenum.rb:259)、value_methods(true) / value_method:によってオプトイン。
    • InputObjectの引数リーダー(input_object.rb:304)。
    • BuildFromDefinition(SDL→クラス)→owner.define_methodbuild_from_definition.rb:538)。
  4. 静的展開可能性 — 重い障害:

    • 型表現は多態的なブラックボックスであるMember::BuildType.parse_typebuild_type.rb:12-97)はString(“User”、“[User]!”)、ArrayClassModuleLateBoundTypeNonNull / Listラッパー、そしてProcを受け付ける(:75-76はprocを呼ぶ)。procと文字列定数化はスキーマを実行しない限り静的解決を打ち破る。
    • インラインするためのメソッド発行はない。DSLは型にRubyメソッドを追加しない。追加するのはランタイムにしか存在しないGraphQLスキーマグラフである。
    • resolver: / mutation:は別のクラスにディスパッチをリルートする (has_fields.rb:62-65)。
    • コネクションラッピングは名前駆動の遅延バインディングである (field.rb:127:124)。
    • ブロック形式のフィールドFieldインスタンス上でinstance_execされる(field.rb:380-388)。
  5. もっとも近い類似物Lispマクロでもなく、PHPStanトレイトでもない。 展開すべきRubyメソッドが存在しない — DSLはスキーマグラフレコーダーである。 フィールドリーダー/呼び出し箇所に有用な型を与えるために、rigorはスキーマ解決パスを必要とする(Schema::Member走査を再実装するか、あるいは実際にスキーマをrequireしてSchema.typesを読む)。もっとも近い比較対象はGraphQL自身のスキーマロードフェーズである。純粋なASTレベルのマクロ展開は実行不能である。なぜならProcの遅延型とStringの定数化参照が第一級だからである。リゾルバメソッド自体は普通のRubyである。スキーマグラフが存在すれば、戻り値型のアサーションは@rbsオーバーライドとして表現できるが、どのメソッドがどの戻り値型を持つかの発見は完全なスキーマ評価であり、マクロ展開ではない。


  1. DSL

    FactoryBot.define do
    factory :user do
    name { "Alice" }
    sequence(:email) { |n| "user#{n}@example.com" }
    association :account
    trait :admin do
    role { "admin" }
    end
    end
    factory :admin_user, class: User, parent: :user do
    role { "admin" }
    end
    end
    FactoryBot.create(:user, :admin, name: "Bob") # => User
    FactoryBot.build(:user) # => User
    FactoryBot.build_stubbed(:user) # => User
    FactoryBot.attributes_for(:user) # => Hash
    FactoryBot.create_list(:user, 3) # => Array[User]
  2. メカニズムFactoryBot.define { … }lib/factory_bot/syntax/default.rb:6-8)はブロックをDSL.runに渡す。これは新しいDSL上でinstance_evalする(syntax/default.rb:36-38)。 factory(name, opts, &block):15-26)はFactory.new(name, options)を構築し (factory.rb:9-18)、それをDefinitionProxyでラップし、プロキシ上でブロックをinstance_evalしてからInternal.register_factory経由で登録する (internal.rb:79-84)。属性行(name { "Alice" })は DefinitionProxy#method_missingに到達し(definition_proxy.rb:91-104)、__declare_attribute__にルーティングされる(:247-254)。

    FactoryBot.create / build / build_stubbed / attributes_forSyntax::Methods内に静的に定義されていない — ロード時にdefine_methodによってインストールされる。Internal.register_default_strategiesinternal.rb:99-105)はStrategySyntaxMethodRegistrarを呼ぶ。これはFactoryBot::Syntax::Methodsに対してdefine_methodmodule_execする (strategy_syntax_method_registrar.rb:55-63)。_list / _pairバリアントは単数形をArray.new(amount) { … }でラップする(:35-52)。

  3. 生成されるもの — ユーザーのモデルクラスには何も生成されない。生成されるのは2つ:

    • シンボルでキー付けされたレジストリ内のファクトリー定義Factory#build_classclass_name.to_s.camelize.constantizeを介してターゲットクラスを遅延解決する。class:が指定されない場合はnameをデフォルトとする(factory.rb:24-32, 109-111)。
    • FactoryBot::Syntax::Methods上のトップレベルストラテジメソッド: buildcreateattributes_forbuild_stubbed、加えて _list / _pairバリアント — 合計12のデフォルトメソッド。戻り値の階梯:
      • Strategy::Build#result / Create#result / Stub#resultbuild_classのインスタンス。
      • Strategy::AttributesFor#resultHash
      • _listArray[T]_pair→サイズ2のArray[T]
  4. 静的展開可能性create(:user)に型を付けるには3つの材料で十分:

    1. ファクトリー名→モデルクラスのマップFactoryBot.define { factory :foo[, class: Bar][, parent: :baz] … }ブロックを歩く。戻り値型の問いに対してブロック本体の評価は不要。 class:はリテラル。指定がなければname.to_s.camelize.constantizeparent:はクラス継承を連鎖させる。
    2. ストラテジメソッド→戻り値の形のテーブル。ハードコード: build/create/build_stubbed →モデルクラス、attributes_forHash*_listArray[T]*_pair→サイズ2のArray[T]
    3. トレイト名は戻り値型に無関係。呼び出し箇所での属性カバレッジをゲートするだけで、クラスはゲートしない。

    ランタイムを必要とするもの: FactoryBot.modifydefineの外での動的登録、ユーザー登録のカスタムストラテジ、インスタンス化セマンティクスを変えるto_create / initialize_withブロック(factory.rb:140-146)。

  5. もっとも近い類似物PHPStanスタイルの「リテラルなシンボル引数から戻り値型が計算される汎用メソッド」。Railsのfind_by_*ファミリーやPHPStanのDynamicMethodReturnTypeExtensionと同じ形である。Lisp/PHPStanトレイトの意味でのマクロ展開ではない — factory_botはUser上にメソッドを生成しないので、ユーザークラスの呼び出し箇所でインラインするものがない。モデルは「ブート時に1回のレジストリウォーク+ストラテジメソッドごとに1つの戻り値型ルール(最初のシンボル引数をキーとする)」である。 rigorはすでにplugins/rigor-factorybot/に正しいフックを持っている。マクロ機構なしで到達可能な拡張には、*_list / *_pairのラッピング、parent:チェーンの解決、aliases:登録、トレイト名のバリデーションが含まれる。


  1. DSL

    # classic
    require 'sinatra'
    get '/hello' do
    "Hello #{params['name']}"
    end
    # modular
    class MyApp < Sinatra::Base
    get '/hello' do
    "Hello #{params['name']}"
    end
    end
  2. メカニズムgetputpostdeleteheadoptionspatchlinkunlinkSinatra::Baseのクラスメソッドである (lib/sinatra/base.rb:1531-1553)。それぞれ route(verb, path, options, &block):1776-1782)に転送する。これは compile!を呼ぶ(:1795-1813)。compile!の内部で、ブロックは generate_methodによって本物のメソッドに変換される(:1788-1793):

    def generate_method(method_name, &block)
    define_method(method_name, &block)
    method = instance_method(method_name)
    remove_method method_name
    method
    end

    合成名は"#{verb} #{path}"、例えば"GET /hello"である。メソッドは定義されてすぐ削除されるUnboundMethodのみが保持され、後でリクエストごとに再バインドされる: unbound_method.bind(a).call(...):1808-1810)。classicモードのトップレベルのgetSinatra::Delegatorであり、Sinatra::Applicationに転送する(:2101-2127lib/sinatra/main.rb:52, 55でミックスインされる)。

  3. 生成されるものget '/hello' do … endの場合: アプリクラスへの一時的なdefine_method("GET /hello", &block)が、UnboundMethodとしてキャプチャされ、その後remove_methodされる。unboundメソッドはラッパーprocの中に保存され、@routes['GET'][pattern, conditions, wrapper]として追加される。ブロックはリクエストごとに新しいアプリインスタンスにバインドされて実行される。paramsrequestresponseenvappSinatra::Baseインスタンスのattr_accessorである(:978)。ヘルパー (erbredirecthaltsession)は普通のSinatra::Baseのインスタンスメソッドである。

  4. 静的展開可能性 — 非常に扱いやすい。ブロック本体は手を加えていないRubyである。内部のselfはアプリクラスのインスタンスである。機械的な展開: class X < Sinatra::Base内の任意の<verb>(path, opts = {}, &block)を、ブロック本体をインライン化しselfXとして型付けした、X上の合成プライベートインスタンスメソッドとして扱う。Sinatra::Baseのインスタンスメソッド表面をスコープに注入する。classicモードのトップレベルでは、Sinatra::Delegatorを認識し、剥き出しのgetSinatra::Application.getとして扱う必要もある。

  5. もっとも近い類似物 — RubyのDSL動物園でもっともきれいなケース: ブロックがすでにメソッド本体である、バイトごとに。Lispマクロ(構文を書き換える)やPHPStanのトレイトインライン(ASTをコピーする)と異なり、Sinatraは書き換えを必要としない — generate_methodは文字通りdefine_method(name, &block)である。 アナライザーが「このブロックはSinatra::Baseのインスタンスメソッドとして実行される」と受け入れれば、展開は不要である。


  1. DSL

    class User < Sequel::Model # implicit table :users + DB schema lookup
    one_to_many :posts
    many_to_one :account
    many_to_many :groups
    end
    User.plugin :timestamps, update_on_create: true
    User.plugin :validation_helpers
    user = User[1]
    user.name # column accessor — synthesised from DB schema
    user.posts # association accessor
    user.add_post(p)
  2. メカニズム — ライブデータベース駆動のカラムアクセサSequel::Model.inheritedsubclass.set_dataset(subclass.implicit_table_name)をパース時に呼ぶ (lib/sequel/model/base.rb:987)。set_datasetはその後@db_schema = get_db_schemaを呼ぶ(:634)。 get_db_schemaは本物のスキーマパースクエリを発行し(:901、“if db.supports_schema_parsing?”)、結果のカラムを def_column_accessor(*schema_hash.keys)に供給する(:918)。 各カラムは overridable_methods_module.module_eval("def #{column}; self[:#{column}] end", …) によって発行される(:797-801)。識別子でないカラム名のための低速パスのブロックベースのフォールバックもある(def_bad_column_accessor:772-785)。

    ActiveRecordとの違い。ARはアクセサを最初のアクセス時にmethod_missing / define_attribute_methodsで遅延的に定義し、db/schema.rb(マイグレーション時に書かれるRubyソースファイル)にスキーマをキャッシュする。Sequelには素のSequelにディスクにコミットされる同等のスキーマアーティファクトはない — クラスロード時にDDLプローブを行い、def_column_accessorを同期的に実行する。

  3. 関連付けに対して生成されるものlib/sequel/model/associations.rb:2238-2285):

    • one_to_many :posts(またはmany_to_many): returns_array?がtrueなので、メソッド名テーブル(:2243-2249)はpostsposts_dataset_add_postadd_post_remove_postremove_post_remove_all_postsremove_all_postsを生成する。:2243でのsingularize(opts[:name])による単数形化。
    • many_to_one :account: 単数分岐(:2250-2253)は _account= / account= + accountaccount_datasetを追加する。writableでない限りadd/remove/clearはない。

    すべてのメソッドはassociation_module_defによってインストールされる。これは mod.send(:define_method, name, &block); mod.send(:alias_method, name, name)を呼ぶ (:2198-2202)。受け取るモジュールはデフォルトでoverridable_methods_moduleである。

  4. 静的展開可能性:

    • DBスキーマ依存。カラムアクセサはライブのget_db_schema_array呼び出しの後にのみ存在する。静的アナライザーはユーザー供給のDDL、データベース発行のDESCRIBE、あるいはマイグレーションから推論されたソースのいずれかを消費しなければならない。素のSequelには正準な静的スキーマソースはない。
    • プラグインのシーケンシングは順序依存かつ副作用的であるModel.pluginbase.rb:496-520)はapplyを1回実行し、その後extend ClassMethodsinclude InstanceMethodsdataset_extend(DatasetMethods)を行い、呼び出しごとにconfigureを実行する。プラグインはapply / configureの内部からさらにdefine_method / class_evalを追加することがある。静的展開には、単純な名前→メソッドテーブルではなく、プラグインリプレイエンジンが必要。
    • 関連付け名は動的でありうる。オプションがメソッドをリネームできる:methods_moduleはメソッドが着地する場所をオーバーライドする(:2192)。単数/複数形の名前はsingularizeから派生する。
    • 計算された本体を持つclass_eval文字列def_initialize_nil_instance_variablesbase.rb:858-865)、 def_model_dataset_method:825-828)、 Plugins.def_dataset_methodsplugins.rb:31-37)で使われている。これらを発行するにはSequelと同じロジックを実行する必要がある。
    • Plugins.def_sequel_methodは一意のメソッド名を作り出すplugins.rb:73-75, 92-138) — 展開時のカウンタ状態は観察不能だが、メソッドはプライベートな呼び出し先のみ。
  5. もっとも近い類似物ハイブリッド: 関連付けレイヤーはPHPStanのトレイトスタイル+カラムレイヤーはスキーマソース依存+プラグインリプレイエンジン

    • one_to_many / many_to_one / many_to_manyはトレイトインライン展開スタイル: 固定テーブルからの名前駆動メソッド合成、機械的に展開可能。
    • カラムアクセサは純粋なマクロではない — スキーマオラクルを必要とする(PHPStanのPDOリフレクション拡張、またはkphp.sql取り込みにもっとも近い)。ADR-10スタイルのオプトイン推論は役に立たない。カラムはRubyの外、no-RBS依存の中ではなく、Rubyの外に存在するからである。
    • プラグインシステムは「副作用的なapplyフックを持つトレイトインライン展開」にもっとも近い — モジュールは静的に既知だが、apply / configureブロックはアナライザーがシンボリックに実行するか、宣言的にモデル化する必要のあるランタイムデータを運ぶ。

    小さな「Sequel関連付け」プラグインは関連付けDSLをきれいに展開できる。 完全なSequelチェッカーには次が必要: (a)スキーマソースアダプタ — おそらく Sequel::Database#schemaダンプ、ユーザーが指定するschema.rb相当物、あるいはマイグレーション走査 — そして(b)プラグインリプレイサブシステム。


#箇所(path:line)パターン
Alib/redmine/views/labelled_form_builder.rb:25-33ヒアドキュメント+補間されるリテラルリスト(field_helpers - blocklist + %w(date_selec)
Blib/plugins/acts_as_event/lib/acts_as_event.rb:48-62ヒアドキュメント+リテラル%w(datetime title description author type)
Capp/models/setting.rb:333-350設定名でパラメータ化されたヒアドキュメント。名前はconfig/settings.ymlRedmine::Plugin.allから来る
Dapp/models/user.rb:30, 293-303eval('"' + f[:string] + '"')f[:string]USER_FORMATS内のリテラル値
Eapp/models/webhook_payload.rb:71instance_eval(File.read(Rails.root.join(path)), path, 1)
Flib/redmine/plugin.rb:69-77def_field — リテラルシンボルリストに対するブロック形式のclass_eval do … define_method
Glib/redmine/nested_set/traversing.rb:23-28includerのスコープを再オープンするブロック形式のclass_eval(文字列ヒアドキュメントなし)

代表的な例 — 箇所C:

def self.define_setting(name, options={})
available_settings[name.to_s] = options
src = <<~END_SRC
def self.#{name}
self[:#{name}]
end
def self.#{name}?
self[:#{name}].to_i > 0
end
def self.#{name}=(value)
self[:#{name}] = value
end
END_SRC
class_eval src, __FILE__, __LINE__
end
# … later …
load_available_settings # iterates YAML.load_file('config/settings.yml')
load_plugin_settings # iterates Redmine::Plugin.all
パターン箇所説明
(a)静的テキストヒアドキュメント、補間なしなし — Redmineのすべてのヒアドキュメントは少なくともメソッド名を補間する
(b)ソース可視リテラルを補間するヒアドキュメントA, B
(b′)ファイル駆動リテラルを補間するヒアドキュメントC
(b″)リテラルシンボルリストのブロック形式class_evaldefine_methodF
(b‴)ブロック形式class_eval(文字列なし)、includerを再オープンG
(c)外部ファイルからロードされたコードのevalE
(d)ランタイムデータと連結された文字列のeval厳密な意味では存在しない — Dのテンプレート文字列はソース可視リテラルであり、ランタイムキーによって選択されるだけ

Redmine::PluginLoaderlib/redmine/plugin_loader.rb:30-32)は <plugin>/init.rbloadでロードする。instance_evalではない — したがってプラグインのinit.rb内のselfmainであり、requireされたスクリプトと同じ。

instance_evalはもう1段階深いところ、Redmine::Plugin.registerの内部に存在する (lib/redmine/plugin.rb:93-95):

def self.register(id, &)
p = new(id)
p.instance_eval(&)
end

Redmine::Plugin.register :foo do … endの内部では、selfは新しい Redmine::Pluginインスタンスである。barewords(name 'X'author 'Y'settings :default => {…})はdef_field生成のアクセサに到達する(箇所F)。 :381-385にある2番目のinstance_evalproject_module name do … endの下にネストする — selfはプラグインインスタンスのまま、@project_moduleはブロック内のpermission呼び出しのためのサイドチャネルとしてセットされる。

  • A — 展開可能。ソース可視の%w(...)集合演算。ただし field_helpersActionView::Helpers::FormBuilder.field_helpers(上流の定数)から来る。 そのリスト(Railsバージョンごとに安定)を解決し、約12個の def text_field/email_field/…スタブを発行する。
  • B — 完全に展開可能。5つのリテラルシンボル→5つの def event_datetime / event_title / event_description / event_author / event_typeが同じ式を返す。
  • C — アナライザーがconfig/settings.yml(約70の設定キー)を読み、 Redmine::Plugin.register :id do settings :default => {...} endブロックを歩く場合にのみ展開可能。 各nameは3つのクラスメソッド(namename?name=)を生成する。 形は静的に既知だが、キーの集合はYAMLとプラグインのinit.rbファイルを読む必要がある。これはユーザーの 「activesupport-core-ext+プロジェクト側のmonkey-patch事前評価」メモが記述するケースである。
  • D見かけ上扱えないが、実際は(b′)である: USER_FORMATSはソース可視の定数で、その:string / :initials値は静的なテンプレート文字列である。マクロ展開器はeval('"' + f[:string] + '"')を9個のテンプレートに渡るStringの∨に変えられる。その特例なしでは、Dynamic[String]を発行する。
  • E — パターン(c)。設定されたパスでwebhook payloadの.rbテンプレートを読む必要がある。selfは呼び出し側によって@ivarが注入されたWebhookPayloadインスタンスである。アナライザーがpayloadテンプレートディレクトリを追加のソースルートとして扱う場合、扱える(PHPStanのスタブファイルパターン)。
  • F — リテラルシンボル引数でのブロック形式class_evaldefine_method。自明に展開可能: 各名前にgetter/setterのペアを発行する(セマンティクスはattr_accessorと異なる)。
  • G — includerのスコープを再オープンするブロック形式class_eval。文字列evalではない。標準のincludedフック。
  • A, B, F, G→ソース可視のリテラルリストに対するLispのdefmacro。展開器にとって無料の勝利。
  • C→PHPStanのクラスごとの生成スタブ(レジストリをスキャンしメソッド宣言を発行する拡張)。展開器コードLOCあたりの最大のペイオフ: 約70+N(プラグイン)のアクセサトリプレットをカバーする。
  • D→USER_FORMATSがマクロテーブルとして認識されれば、これもdefmacro風。
  • E→PHPStanのスタブファイルパターンinstance_evalセマンティクス下で呼び出し箇所にペーストされたかのように外部の.rbをパースする)。

Redmineのどの箇所も真に扱えないパターン(d)に達していない。すべての class_eval / eval箇所は、ソース可視リテラルか、1つの間接参照(YAML、プラグインレジストリ、兄弟定数)で到達可能な値を補間する。既存のメモにあるマクロテンプレート+ヒアドキュメントRubyの方向性を裏付ける: 「リテラルリスト反復ヒアドキュメント」+「リテラルキーHashテーブルeval」+「宣言されたselfコンテキストを持つ外部Rubyファイルeval」を扱うRedmine向け展開器は、コードベース内のメタプログラム化されたメソッドのほぼすべてを回収する。


  1. DSL

    module Types
    include Dry.Types() # imports Types::String, Types::Integer, ...
    end
    StrippedString = Dry::Types['string'].constructor { |s| s.strip }
    Email = Dry::Types['string'].constrained(format: /@/)
    NilableInt = Dry::Types['integer'].optional # Sum of nil | integer
  2. メカニズムDry.Types(...)は新しいDry::Types::Moduleインスタンスを返す(lib/dry/types.rb:252-254)。Dry::Types::Module < ::Moduleなので、includeは普通のRubyのincludeである。Module#initializelib/dry/types/module.rb:20-37)はDry::Types.containerフラットレジストリキー"strict.string""coercible.hash"、…)を registry_treeによってネストした定数ツリーに投影する (module.rb:74-84)。その後ツリーを歩いてdefine_constantsが各リーフに対してmod.const_set(name, value)を呼ぶ (module.rb:109-124)。レジストリはlib/dry/types/core.rbによってgemロード時に1回追加される: 4つのループが ALL_PRIMITIVES / KERNEL_COERCIBLE / METHOD_COERCIBLE / NON_NILcore.rb:6-46のリテラル凍結Hash)を反復し、 register("nominal.string", …)などを呼ぶ(core.rb:49-99)。

    Dry::Types[name]lib/dry/types.rb:114-141)は呼び出し箇所でパースされる1回の再帰を持つランタイムルックアップである: "array<string>"文字列はTYPE_SPEC_REGEXで分割され、 container[type_id].of(self[member_id])を介して再ルーティングされる。合成演算子はDry::Types::Builderに存在する(lib/dry/types/builder.rb): |Sum:28)、&Intersection:37)、>Implication:46)、.optionalnil | self:53-62)、 .constrainedConstrained.new:71-73)。メソッド呼び出しの強制変換関数自体は lib/dry/types/constructor/function.rb:63-75内のmodule_eval(<<~RUBY, …)で構築される — リテラルな強制変換メソッド名を補間するヒアドキュメント。

  3. 生成されるもの — デフォルトの:strictでのinclude Dry.Types()の場合:

    • includer上の定数: StringIntegerFloatDecimalArrayHashSymbolNilClassTrueFalseBoolDateDateTimeTimeRangeAny。 それぞれDry::Types::Constrainedキャリアである(core.rb:55-59)。
    • レジストリのドット区切りプレフィックスをミラーする名前空間サブモジュール: Strict::IntegerNominal::IntegerCoercible::IntegerOptional::Strict::IntegerParams::IntegerJSON::Integer、 など — container.keysの正確な投影(module.rb:74-84)。
    • BuilderMethodsがincluderにextendされる(module.rb:25 + builder_methods.rb:11-14): モジュールメソッドArray(type)Hash(type_map)Instance(klass)Strict(klass)Value(v)Constant(v)Constructor(klass, &)Nominal(klass)Map(k, v)Interface(*methods)
    • ユーザークラス上のインスタンスメソッドはない。すべては定数形状のデータである。
  4. 静的展開可能性 — 扱える:

    • 定数集合はレジストリのリテラルキーリストの純粋関数であり、レジストリの内容はcore.rbの凍結Hashからgemロード時に計算される。プラグインはそれらのHashをミラーする同梱レジストリを出荷できる(rigor-deviselib/devise/modules.rbをミラーするのと同じ形)。
    • 各定数は既知のキャリア形状にバインドされる (Types::Integerの場合はConstrained<Nominal<Integer>>など)。
    • Dry.Types(:strict, default: :coercible)のチェリーピッキングと Dry.Types(coercible: :Kernel)のエイリアスkwargsはAST可視である。
    • Dry::Types["integer"]動的戻り値型拡張にフィットする (ADR-2、factory_botの形) — 基盤ではない
    • Dry::Types[String](Class引数)とDry::Types["array<string>"] (正規表現パースされたネスト形式)は追加のリゾルバルールを必要とする。
    • エッジケース: .constructor { |x| … }は出力型が本体に依存するProcを受け付ける — フォールバックはDynamic[T]
    • .optional / | / & / > / .constrainedはキャリアを代数的に合成する — 静的評価器は格子代数を実装しなければならない(すでにrigorのロードマップ上)。
    • .define_builder(:or_nil)lib/dry/types.rb:196-200)は Builder.define_method(method, …)をランタイムに行う — ソース可視のリテラルシンボル、追跡可能。
  5. もっとも近い類似物Dry::Types[...]に対するPHPStanのDynamicMethodReturnTypeExtension(レジストリ+シンボル→型ルックアップ)、加えてDry.Types()定数のためのTier C風合成定数発行。後者はヒアドキュメントテンプレートではない(includer上でclass_eval文字列は実行されない — const_setが直接使われる)が、プラグイン作者の視点からは形は同一である: 名前の固定レジストリが固定キャリア形状にマップする。 (constant_name, namespace, primitive, carrier_shape)の行を列挙するTier-C-as-const_set宣言がそれを捉える。


  1. DSL

    UserSchema = Dry::Schema.Params do
    required(:email).filled(:string)
    required(:age).value(:integer, gt?: 0)
    optional(:tags).value(:array).each(:string)
    end
    UserSchema.(email: "a@b", age: 21) # => Dry::Schema::Result
  2. メカニズムDry::Schema.Params(&)define(processor_type: Params, &)である(lib/dry/schema.rb:86-89)。 define(...)は文字通りDSL.new(...).callである(lib/dry/schema.rb:67-69)。 DSL.newlib/dry/schema/dsl.rb:81-86)はsuperを呼び (Dry::Initializer)、その後dsl.instance_eval(&)を呼ぶ。ブロックはDry::Schema::DSLインスタンス上のインスタンスメソッドとして実行されるrequired / optional / key / before / after / arrayDSLの本物のインスタンスメソッドである(dsl.rb:144-188)。

    required(:email)key(:email, macro: Macros::Required)dsl.rb:144-146)はMacros::Requiredを構築し、set_type:emailのプレースホルダー型としてTypes::Anyを保存し (dsl.rb:176, 316-321)、マクロを@macrosにプッシュする (dsl.rb:186-187)。.filled(:string)Macros::DSL#filledlib/dry/schema/macros/dsl.rb:80-86)である。これは extract_type_spec(args)macros/dsl.rb:222-257)→ schema_dsl.resolve_type(:string)dsl.rb:339-346)→ type_registry[:string]TypeRegistry#[]type_registry.rb:37-41) →Dry::Types["strict.string"]を呼ぶ。解決された型は schema_dsl.set_type(name, resolved_type)で記録され、 Macros::Filledappend_macroで追加される (macros/dsl.rb:205-216)。

    dsl.calldsl.rb:195-207)は終端ビルダーである: parents.stepsを収集し、key_validatorkey_coercervalue_coercerrule_applierを構築し、 processor_type.new(schema_dsl: self, steps: result_steps)を生成する。返されるオブジェクトがスキーマである — #call(input)を持つDry::Schema::Processorインスタンス(processor.rb:77-80)。 Macros::Value#method_missingmacros/value.rb:115-121)は ?で終わる任意のシンボル(例: min_size?)をキャッチしてtraceに転送する — 述語はユーザーソース内の剥き出しのbareword symbolである。

  3. 生成されるものユーザーのクラス上にはdefine_methodされたものは何もないUserSchemaDry::Schema::Paramsインスタンスへのローカルバインディングである。最終的な成果物:

    • @steps@schema_dsl、内部key_mapkey_coercervalue_coercerrule_applierを保持するDry::Schema::Processorインスタンス(processor.rb:15)。schema.(input)として呼び出し可能で、Dry::Schema::Resultを返す。
    • @typesから派生した[:email, :age, :tags]を列挙するKeyMapdsl.rb:408-417)。
    • キーごとの型割り当てを運ぶDry::Types::Schemastrict_type_schemadsl.rb:295-297)。
    • クラス形式(class UserSchema < Dry::Schema::Params; define do … end; end)は@definitionをクラスに保存し(processor.rb:46-50)、UserSchema.newは構築されたプロセッサを返す(processor.rb:57-67)。
  4. 静的展開可能性 — 混合:

    • 述語名はリテラルなSymbolであるrequired(:email).filled(:string)は完全にリテラルである。:stringTypeRegistryDry::Types[…]を介して解決される。レジストリ名前空間(defineの場合は:strictParamsの場合は:paramsJSONの場合は:json)はDry::Schema上の呼び出し箇所のメソッド名によって決定される。すべてソース可視。
    • ブロックの戻り値はDry::Schema::Processorである(あるいはそのサブクラス)。内容に関わらず — 自明に型付け可能。
    • schema.(input)Dry::Schema::Resultを返す。その#to_hの形はキーマップである。キー集合は静的に required(:key) / optional(:key)の位置から抽出可能。キーごとの型は対応する .value(:type) / .filled(:type) / .maybe(:type)引数から。 これはrigor-factorybotが定義側で使っている「ASTレコーダー」と同じ形である。
    • ブロックはDSLインスタンス上でinstance_evalされるdsl.rb:83)。基盤のTier Aが適用される: self : Dry::Schema::DSLとbareword表面 (requiredoptionalkeybeforeafterarray)を宣言する。
    • .filled { … } / .value { array? | str? }Dry::Logic::Operatorsmethod_missingを使う (macros/value.rb:115-121)。ブロック本体は再度、新しいマクロCore上でinstance_execされる — Tier Aのネスト。
    • 真に動的なもの: required(:name).value(SomeCustomType)であって、SomeCustomTypeDry::Types::Typeインスタンスの場合(同じ resolve_typeパス)、array(:string).filled(min_size?: 2)の述語演算子チェーン、ブロック内のDry::Logic述語合成(array? | str?)は演算子オーバーロード追跡を必要とする。
  5. もっとも近い類似物ハイブリッド: ルールトレースレベルでのスキーマグラフレコーダー(graphql-ruby風)+Dry::Schema.Params { … }.call(input)に対するPHPStanの動的戻り値型拡張。graphql-rubyと異なり、ブロック内容自体はキー集合+キーごと型テーブルに静的に展開可能である。 rigor-dry-schemaプラグインは次を合成できる: 「スキーマプロセッサの #call(input)Result[T]を返し、その#to_hは形 {email: String, age: Integer, …}を持つ」。これはTier A(ブロックをDry::Schema::DSL上のinstance_evalとして宣言)プラス、ブロック内の required/optional/filled/value/maybe/each/arrayを歩いてキーマップを構築するカスタム呼び出しレコーダーを必要とする。ユーザーのクラスはどのtierもインライン展開しない — 価値はスキーマ呼び出しの戻り値の形を型付けすることにある。


  1. DSL

    class Address < Dry::Struct
    attribute :city, Types::String
    attribute :country, Types::String.optional
    attribute? :postcode, Types::String # omittable
    attribute :details do # nested struct
    attribute :building, Types::String
    end
    end
    a = Address.new(city: "Tokyo", country: "JP",
    details: { building: "X" })
    a.city # "Tokyo"
    a.details.building # "X"
    a[:city] # "Tokyo"
    a.to_h # {city: "Tokyo", country: "JP",
    # details: {building: "X"}}
  2. メカニズムDry::StructClassInterfaceを拡張する (lib/dry/struct.rb:87)。継承するクラスは attribute / attribute? / attributes / transform_types / transform_keysをクラスメソッドとして継承する。attribute(:city, Types::String)lib/dry/struct/class_interface.rb:86-88)は attributes(city: build_type(:city, Types::String))に委譲する。

    attributes(new_schema)class_interface.rb:173-189)は3つのことを行う: (a)スキーマ更新schema schema.schema(new_schema)は新しいキー/型ペアで補強された新しいスキーマでクラスレベルのschemaDry::Types::Hash::Schema)を置き換える(:177)、(b)アクセサ合成define_accessors(keys):179:452-464)、(c)継承の伝播 — 各サブクラスについて、再帰的に d.attributes(inherited_attrs)を呼ぶ(:183-186)。

    define_accessors(keys)はキーごとにgetterを発行する:

    class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
    def #{key} # def city
    @attributes[#{key.inspect}] # @attributes[:city]
    end
    RUBY

    識別子の形をしていない名前についてはdefine_method(key) { @attributes[key] }にフォールバックする(:468valid_method_name?正規表現チェック)。

    .new(city: "Tokyo", …)class_interface.rb:239-258)はクラスレベルのschema.call_unsafe(attributes)で強制変換し、load(attributes)allocateinitialize(attributes)を行う (:279-283)。ネストブロック形式(attribute :details do …)は StructBuilder#callを経由する (lib/dry/struct/struct_builder.rb:18-42)。これは Class.new(parent_struct)class_exec(&block)+親クラスへの const_set(:Details, new_type)を行う。

  3. 生成されるものclass Address < Dry::Struct; attribute :city, Types::String; endの場合:

    • クラスレベルの状態: Address.schemaDry::Types::Hash::Schemaであり、その:cityキーは Types::Stringにマップする。Address.attribute_names[:city]を返す (class_interface.rb:357-359)。
    • インスタンスメソッドAddress#city: class_evalヒアドキュメントによって合成される(class_interface.rb:455-459) — @attributes[:city]を返す。戻り値型は属性の Types::Stringキャリアに従う⇒String
    • ライターなしDry::Structはイミュータブルである。リーダーのみ存在する(コピー&オーバーライドのための#new(changeset):202-211にある)。
    • Address#[](key)Address#to_hDry::Structから継承される(lib/dry/struct.rb:147-178)。形はクラススキーマから派生した{city: String}である。
    • Address.new(city:)は型付きコンストラクタ (class_interface.rb:239)。
    • attribute? :postcode, …の場合: required: falseの同じパス。アクセサ名は依然postcode?:174で取り除かれる)。
    • ネストブロック形式: Address::Detailsconst_setされ (struct_builder.rb:33)、それ自身がDry::Structサブクラスである。親はAddress::Detailsを返すdetailsリーダーを得る。
  4. 静的展開可能性 — 3つの中でもっともきれい:

    • 属性名はリテラルなSymbolであるclass_interface.rb:455-459class_evalヒアドキュメントは#{key}#{key.inspect}を補間する。ADR-16のTier Cに直接フィットする。
    • 属性型Dry::Types::Typeインスタンス (例: Types::StringまたはString("integer")であり、後者はDry::Types[...]にルーティングされる(class_interface.rb:430-441)。String形式はdry-schemaの:stringと同じレジストリルックアップである。
    • 合成演算子Types::String.optionalTypes::Integer | Types::NilTypes::String.constrained(…))は dry-typesのBuilder代数に従う。attribute :x, Types::Integer.optionalに対するTier Cの発行は、リーダーがInteger | nilを返すことを知っている。
    • ネストブロック形式attribute :details do …は定数Address::Detailsを作り出し、その本体自体がさらにattribute呼び出しを含む — Tier Cは再帰しなければならない(あるいはTier Aのinstance-evalフレーミングをTier Cの発行と合成する)。定数名は Inflector.camelize(attr_name)に従う(struct_builder.rb:64-73)。Symbolから決定論的。
    • attributes_from(SomeStruct):114-123)は別のstructのスキーマからキーをコピーする — ソースstruct自体が歩ける場合は復元可能。
    • transform_keys(&:to_sym) / transform_types:204-223) — 表面のメソッド名を変えないメタレベルの変更。アクセサ合成のために安全に無視できる。
    • Dry.Struct(name: Types::String, age: Types::Integer)lib/dry/struct.rb:30-35) — proc形式コンストラクタ。キーワード引数Hashは通常の呼び出し形でソース可視リテラルである。継承形式のattribute :…と同じTier Cの到達範囲。
    • エッジケース: attribute :name, SomeCustomTypeであって SomeCustomTypeがプロジェクトローカル変数でTypeを保持する場合 — キャリアをバインドするためにフロー解析が必要(通常のrigor作業であり、基盤ではない)。
  5. もっとも近い類似物ADR-16のTier C(リテラルシンボルパラメータでのヒアドキュメントテンプレート展開)、教科書通りの形。形はActiveStorageのhas_one_attached :avatarと同一である: クラスレベルのDSL呼び出しがリテラルなSymbol引数を列挙する。フレームワークはそのSymbolを補間するヒアドキュメントをclass_evalする。発行テーブルは固定である。dry-structのattribute :city, Tに対する発行テーブルは:

    合成物戻り値
    def cityインスタンスメソッドT#primitive
    schemaキー:cityT
    to_hの行city: …T
    [:city]アクセスT
    .new(city: …)キーワード引数T(強制変換済み)

    ネストブロック形式はTier A+Tier Cの合成を追加する(ブロックは新しいStructBuilderバインドのClass.new(parent)上でinstance_evalスタイルで実行される。外側の呼び出しは親クラス上に新しい定数をconst_setする — 定数名はSymbolから計算可能)。


合成(dry-types⇒dry-schema / dry-struct)

Section titled “合成(dry-types⇒dry-schema / dry-struct)”

3つのgemは依存関係の順序dry-types→{dry-schema, dry-struct}で合成する。

  • dry-schemaはdry-typesを必要とするlib/dry/schema.rb:8require "dry/types")。ルール述語推論器は ::Dry::Types::PredicateInferrerをサブクラス化する (lib/dry/schema/predicate_inferrer.rb:6)。スキーマモジュール自身の Types名前空間はinclude ::Dry.Typesである (lib/dry/schema/types.rb:11)。TypeRegistryDry::Typesの直接的なラッパーである(lib/dry/schema/type_registry.rb:21-29)。required(:x).filled(:string)内のすべての :string / :integer / :arrayDry::Types["strict.string"](あるいはプロセッサ名前空間ごとに"params.string" / "json.string"type_registry.rb:37-41)を介して解決される。
  • dry-structはdry-typesを必要とするlib/dry/struct.rb:6)。 Dry::Struct::ClassInterfaceTypes::TypeTypes::Builderincludeする(class_interface.rb:11-12)。Dry::Struct.schema自体が Dry::Types::Hash::Schemaである(struct.rb:115-116)。 attribute :name, "integer"(String形式)は Dry::Types["integer"]を介してルーティングされる(class_interface.rb:432-433)。
  • dry-structはdry-schemaを必要としない。dry-schemaもdry-structを必要としない — dry-typesを共有する兄弟。

rigor-*プラグインのシーケンシングへの含意。最初に書かれるrigor-dry-typesプラグインは下流の両方のgemを部分的にアンロックするが、それらを包含しない:

  • rigor-dry-typesは次を出荷する: (a)core.rbのALL_PRIMITIVES / KERNEL_COERCIBLE / METHOD_COERCIBLE / NON_NIL Hashをミラーする同梱の名前レジストリ、(b)include Dry.Types(…)のためのTier C形式の定数発行、(c)Dry::Types[<literal>]のための動的戻り値型ルール、(d)|&>.optional.constrained.constructorのためのキャリア代数処理。これがあれば、Dry::Types::Typeキャリアを生成する任意の式は、dry-schema、dry-struct、あるいは独立したコードのいずれに現れても、型付けされる。
  • rigor-dry-schemaは依然自身のプラグインを必要とする: Dry::Schema.Params { … }内のスキーマグラフ記録ウォーク(Tier Aのスコープ注釈+required / optional / value / filled / maybe / each / arrayレコーダー)、加えて 記録されたキーマップからTが派生するProcessor#call(input) -> Result[T]の型付け。これはキーごとのキャリア解決のためにrigor-dry-types消費する:stringTypes["strict.string"])。
  • rigor-dry-structは依然自身のプラグインを必要とする: 各attribute :name, T呼び出しのためのTier Cヒアドキュメント発行、加えて Const_nameサブクラスを作るネストブロック合成。これは属性ごとのTキャリアのためにrigor-dry-types消費する

したがってrigor-dry-typesは他の2つの共有依存であり、gem依存グラフを1対1でミラーする。それなしでは、下流gem内の属性ごと/キーごとの型表現は Dynamic[T]に劣化する。それがあれば、下流プラグインは薄いASTレコーダーである。

dry-rbファミリーはマクロ基盤への強い適合である: dry-structはTier Cにきれいにスロットインする。dry-schemaはTier A+Tier A駆動のレコーダーにスロットインする。dry-typesは同梱定数レジストリ(Tier-C-as-method-definitionよりもTier-C-as-const_setに近い)+Dry::Types[…]のためのdynamic_return_type拡張としてもっともよくモデル化される。3つのどれにもGraphQL-Ruby形式の扱えなさや、Sequel-column-accessor形式のスキーマオラクル要件は現れない。


静的展開/リプレイの扱いやすさの降順でソート。「shape」列はexpanderが扱う必要のあるメタプログラミングプリミティブを命名する。「rigor today」列は現在のプラグイン/処理状態を記録する。

ライブラリ/サブシステム展開の形もっとも近い類似物rigor today
Sinatraルートdefine_method(&block)によりブロック=メソッドselfが型付けされれば展開不要」プラグインなし
ActiveStorage attachedリテラルシンボルでパラメータ化されたヒアドキュメントPHPStanトレイトインライン展開rigor-activestorageがすでに展開
dry-struct attribute :name, Tリテラルシンボルでパラメータ化されたヒアドキュメントPHPStanトレイトインライン展開(教科書通りのTier C)プラグインなし
AASMDSLブロック+リテラルシンボルstate / eventトレイトインライン展開/statesmanの形プラグインなし(statesmanが先例)
Redmine A/B/F/Gブロック形式class_eval+リテラルシンボルリストdefine_methodLispのdefmacro処理なし
Redmine CYAML/プラグインレジストリの名前でパラメータ化されたヒアドキュメントPHPStanのクラスごとスタブ(YAMLリーダーが必要)処理なし
dry-types include Dry.Types()同梱名前レジストリ+const_set発行トレイトインライン展開(Tier-C-as-const_set)+Dry::Types[…]の動的戻り値型プラグインなし
factory_botレジストリ+リテラルシンボル引数から計算される戻り値型PHPStanのDynamicMethodReturnTypeExtensionrigor-factorybot
Deviseモデル側同梱レジストリ駆動のtrait includeシーケンスPHPStanトレイトインライン展開+レジストリプラグインなし
Deviseルート/コントローラーmapping.singularでパラメータ化されたヒアドキュメントトレイトインライン展開(ルートウォーカーが必要)プラグインなし
Sequelの関連付けリテラルシンボルからの名前テーブル発行トレイトインライン展開(statesman風)プラグインなし
dry-schema Dry::Schema.Params do … endブロックinstance_evalrequired / optional / 型仕様のリテラルシンボルレコーダーTier A+ASTレコーダー+Processor#callの動的戻り値型プラグインなし
ActiveSupport::Concernブロックの遅延class_eval、ターゲット=includerトレイトインライン展開+DSL再ターゲット部分的(再ターゲット後に下流ウォーカーが発火)
Redmine Einstance_eval(File.read(path))PHPStanスタブファイルパターン処理なし
Redmine Deval('"' + literal_template + '"')defmacro+テンプレートテーブル処理なし
SequelのカラムアクセサDBスキーマオラクルが必要PHPStanのPDOリフレクション拡張処理なし
GraphQL-Rubyスキーマグラフレコーダー、メソッド発行なしスキーマ解決パス(マクロ展開ではない延期(需要なし)

持ち越される観察(まだ決定ではない)

Section titled “持ち越される観察(まだ決定ではない)”

これらは合成が浮かび上がらせる観察である。ユーザーはこれらをどうするか指示する。

  • 既存のプラグイン契約の範囲内にあるクリーンなPHPStanトレイトインライン展開のターゲットが2つすでにある: AASM(statesmanの形)とDeviseのモデル側(同梱レジストリ+モジュールinclude)。両方ともマクロ評価機構を導入せずに到達可能。

  • Sinatraは「ブロック→メソッド」展開器の最小実用ターゲットである。rigorが「このブロックはクラスX上のインスタンスメソッドとして実行される」という契約を受け入れれば、Sinatraは書き換えを必要としない。この形を共有する隣接DSL(RSpecのネストコンテキスト、factory_botのevaluator、ActiveRecordのクラスレベルマクロ)も同じフックから到達可能になる。

  • Redmineは「マクロテンプレート+ヒアドキュメントRuby」の方向性を検証する。 箇所A、B、F、Gはリテラルリストに対する純粋なdefmacroである — ランタイム評価なしのASTレベル展開器で扱える。箇所Cはペイオフが最大だが、config/settings.yml+プラグインのinit.rbを読む必要がある。「プロジェクト側のmonkey-patch事前評価」メモに合う。箇所Eは宣言されたselfコンテキストを持つinstance_eval(File.read(...))である — PHPStanスタブファイルの形。Redmineにはランタイムデータでの真のパターン(d)の文字列evalは存在しない。

  • GraphQL-Rubyはマクロフレームに合わない唯一のライブラリである。 これはスキーマグラフレコーダーである — 型クラスレベルで展開すべきRubyメソッドが存在しない。rigor-graphqlSchema::Member走査を再実装する(あるいはリプレイする)スキーマ解決パスを必要とする。これは具体的なユーザー需要が現れるまでrigor-graphqlを延期するrigorの既存の立場に合致する。

  • Sequelは2つのレイヤーに分かれる: 関連付けはトレイトインライン展開スタイル(扱えるプラグイン)、カラムアクセサはスキーマオラクルを必要とする(スキーマソースなしには扱えない)。プラグインレイヤーは「副作用的なapplyフックを持つトレイトインライン展開」である。

  • ActiveSupport::Concernのincluded do … endはほとんどのRails形式のDSLプラグインを接続する蝶番である。ブロックの内容をincluderのクラスに再ターゲットすることで、すべての下流DSLウォーカー(has_one_attachedhas_manyscope、Concern経由のAASM、Concern経由のDevise、…)が正しいコンテキストで発火できる。マクロ評価は不要。単なるウォーカー再ターゲット。

  • dry-rbトリオ(dry-types、dry-schema、dry-struct)は基盤への強い適合である。 dry-structは教科書通りのTier C(リテラルSymbolattribute :name, Tclass_evalヒアドキュメント発行)である。dry-typesは同梱レジストリの形(Tier-C-as-const_setDry::Types[…]のためのdyn-return-typeルール)である。dry-schemaはTier A(Dry::Schema::DSL上のinstance_eval)+ブロックを歩いてkey → typeマップを構築するレコーダーである。それらはgem依存を介して1対1で合成する: rigor-dry-typesは他の2つのプラグインの共有依存であるが、それらを包含しない。rigor-dry-typesなしでは、下流の属性ごと/キーごとの型はDynamic[T]に劣化する。それがあれば、下流プラグインは薄いASTレコーダーである。ADR-12(dry-rbパッケージ決定)は3つのプラグインがどう出荷されるかを統治するのであり、それらが適用されるかどうかではない。

マクロ評価設計議論に意図的に延期されている事項:

  • rigorが汎用のマクロ評価器(Lispスタイル)、パターンごとのテンプレートテーブル(PHPStanトレイトスタイル)、あるいはその両方を備えるべきか。
  • 展開がパース/推論/新しい「ASTリライト」フェーズのいずれで発火するか。
  • マクロ衛生がrigorのname_scopeリゾルバとどう相互作用するか。
  • Redmine E形式の外部Rubyファイルに対するinstance_evalバインドのselfname_scopeがどう相互作用するか。
  • 同梱レジストリ(Deviseモジュール、AS core_ext)がプラグイン契約のどこに住むか。
  • ADR-15 Ractor共有下でのマクロ展開ASTのキャッシュ挙動。

参照したクローン/tmp/配下、コミットなし、サブモジュールなし):

  • /tmp/rails-research/rails/rails8-0-stable
  • /tmp/aasm-research/
  • /tmp/devise-research/
  • /tmp/graphql-ruby-research/
  • /tmp/factory_bot-research/
  • /tmp/sinatra-research/
  • /tmp/sequel-research/
  • /tmp/redmine-research/
  • /tmp/dry-types-research/
  • /tmp/dry-schema-research/
  • /tmp/dry-struct-research/

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