Skip to content

Macro / DSL Expansion — Per-Library Survey

Date: 2026-05-15. Revised 2026-05-15 to add the dry-rb trio (dry-types, dry-schema, dry-struct).

Status: research note, no design commitments.

Prerequisite for the ROADMAP O2 work item (“macro-template + heredoc-Ruby expansion”) and the grounding evidence for ADR-16 (macro expansion substrate).

This document is per-library findings only. The aggregated assessment (decision shape for rigor) is in ADR-16; this note feeds that decision.

Each section answers five questions for the target subsystem:

  1. User-facing DSL surface (one short snippet).
  2. Implementation mechanism — what Ruby metaprogramming primitive carries it.
  3. What gets generated at the call site (method names, accessors, callbacks).
  4. Static-expandability — can a static analyzer recover the generated surface from source alone?
  5. Closest analogue — Lisp defmacro, PHPStan trait inlining, runtime registration, or something else.

Source code was read via shallow clones in /tmp/<lib>-research/; the clones are not committed and are not referenced as submodules. Citations point at the clones’ file paths so a future reader can re-clone to verify.


  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. MechanismConcern overrides Module#append_features / Module#prepend_features (activesupport/lib/active_support/concern.rb:129-153). On first include it: (a) runs queued dependencies, (b) calls super (the real include), (c) base.extend const_get(:ClassMethods) if defined (:137), (d) base.class_eval(&@_included_block) (:138). class_methods(&blk) lazily creates a ClassMethods submodule via const_set + module_eval (:209-215). included(&blk) / prepended(&blk) only stash the block (:158-187).

  3. Generated — host gains M’s instance methods, host’s singleton class gains M::ClassMethods, the included do … end block runs with the host as self (so anything DSL-shaped inside it lands on the host). Transitive: every module in @_dependencies is recursively included before super (:135, :148).

  4. Static-expandability — Partially expandable:

    • Trivially expandable — the ClassMethods extend + instance-method include half. Walker can treat include M as “extend M::ClassMethods + mix M’s defs into Host.”
    • Recursively expandable@_dependencies chain is purely lexical (include OtherConcern inside the module body).
    • Block expansion is conditional on block body. included do … end is deferred class_eval: inline the block AST verbatim, rebound to the includer. Any DSL call already understood by other rigor walkers (e.g. has_one_attached, has_many, scope) will re-trigger those walkers on the includer.
    • Hostile cases: branching on Rails.env / defined?(…) inside included do; class_eval of an interpolated heredoc; a four-arg included(base) body that runs at runtime against a concrete base.
  5. Closest analoguePHPStan trait inlining. The contents are static text re-bound to the host class. Unlike a Rust derive macro, the expansion target is the includer, not the concern itself; expansion fires at include-site, not at concern-definition-site.


ActiveStorage attached macros (Rails 8-0-stable)

Section titled “ActiveStorage attached macros (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. Mechanism — Both live in activestorage/lib/active_storage/attached/model.rb, exposed through class_methods do … end (:54) inside a module extending ActiveSupport::Concern (:10). Accessor pair is generated by heredoc class_eval against generated_association_methods (:111-126 for has_one_attached, :213-230 for has_many_attached). Reflection bookkeeping runs through add_attachment_reflection (:146-154 / :250-258); supporting associations (has_one, has_many, scope, after_save, after_commit) are additional inline DSL calls.

  3. Generated for has_one_attached :avatar (mirror for :photos with plural-suffixed names):

    • def avatar returning ActiveStorage::Attached::One (:113-116).
    • def avatar=(attachable) (:118-125).
    • has_one :avatar_attachment (reader + writer + AR reflection).
    • has_one :avatar_blob, through: :avatar_attachment.
    • scope :with_attached_avatar (class-level relation).
    • after_save + after_commit callbacks.
    • User.reflect_on_attachment(:avatar) returns the reflection.
    • For has_many_attached: same five-name pattern pluralised; primary accessor returns ActiveStorage::Attached::Many.
  4. Static-expandability — Highly expandable:

    • Method names are pure string interpolation on the symbol literal argument. :113, :115, :118, :128-131 all use "#{name}". With the symbol literal visible, every generated name is computable. The existing rigor-activestorage plugin already exploits this.
    • Return types are stable and lexically determined.
    • Conditional generation is shallowif ActiveStorage.track_variants only branches the with_attached_* body, not the surface signature.
    • Hostile casehas_one_attached(some_method) with a non-literal name. Current plugin bails; an AST-expander inherits the same limitation.
    • Composition with Concern — when has_one_attached appears inside a user-side extend ActiveSupport::Concern included block, the Concern walker must re-target the block to the includer first; the attached-macro expander then fires there. The two passes interlock.
  5. Closest analoguePHPStan trait inlining. A fixed textual template parameterised by literal symbol/string arguments, expanded once at use-site. Unlike Lisp defmacro, the template is opaque text (a heredoc), so the expander needs a per-macro template table rather than a general macro-evaluator.


  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

    Multi-machine variant: aasm(:work_status) do … end (lib/aasm/aasm.rb:28-37).

  2. Mechanisminclude AASM extends with AASM::ClassMethods and registers a state-machine slot (lib/aasm/aasm.rb:8-17). The class-level aasm(*args, &block) builds (or reuses) an AASM::Base keyed by state_machine_name and runs the block via @aasm[state_machine_name].instance_eval(&block) (:28-64). Inside the block, state / event are instance methods on AASM::Base, not bare macros. state :pending registers the state and injects pending? on the host class via safely_define_method (lib/aasm/base.rb:90-108). event :submit do … end registers the event and injects may_submit?, submit!, submit, submit_without_validation! (base.rb:111-143).

  3. Generated on the host class:

    • Per state :foo (base.rb:99-106): foo?, constant STATE_FOO, and an AR scope foo if create_scopes and the persistence adapter responds to aasm_create_scope (persistence/base.rb:60-86).
    • Per event :bar (base.rb:120-141): may_bar?(*args), bar!(*args, &blk), bar(*args, &blk), bar_without_validation!. With namespace:, additional aliases may_bar_NS? etc.; state predicate becomes NS_foo?.
    • Class-level: .aasm, .aasm(:name) returning AASM::Base exposing states, events, state_machine, human_event_name, etc.
  4. Static-expandability — Mostly tractable:

    • Method names are deterministic from the symbol literal passed to state / event.
    • Multiple state machines per class via aasm(:column_name) do … end — still source-visible.
    • namespace: option requires reading the option value. Recoverable if the value is a literal Symbol / String or the literal true (read the state-machine-name symbol from aasm(:name) do).
    • state :a, :b arity overload: treat every leading non-Hash arg as a state name.
    • Inheritance: parent-class definitions must be replayed before the subclass (aasm.rb:21-25).
    • Genuinely dynamic and rare: state name as non-literal, Proc-valued initial_state, AR scope generation gated on runtime respond_to?(:aasm_create_scope). Predicate / event-method generation does NOT depend on adapter checks.
  5. Closest analoguePHPStan trait inlining / Lisp-macro-style expansion. Identical in spirit to how plugins/rigor-statesman/ already walks state_machine_class.state :foo. AASM is in reach of the same plugin approach as statesman.


  1. DSL — three sites:

    • Model: class User < ApplicationRecord; devise :database_authenticatable, :recoverable, …; end (lib/devise/models.rb:79).
    • Routes: devise_for :users inside Rails.application.routes.draw (lib/devise/rails/routes.rb:226).
    • Controllers (implicit): current_user, user_signed_in?, authenticate_user!, user_session synthesised by Devise::Controllers::Helpers.define_helpers (lib/devise/controllers/helpers.rb:113).
  2. MechanismDevise.add_module(:database_authenticatable, …) at gem load (lib/devise/modules.rb:9, wrapped by Devise.with_options model: true). add_module (lib/devise.rb:397-440) appends to ALL (:400), registers STRATEGIES/CONTROLLERS/ROUTES/URL_HELPERS, autoloads Devise::Models::DatabaseAuthenticatable when model: true (:436), and calls Devise::Mapping.add_module module_name (:439) which class_evals a predicate def #{m}?; modules.include?(:#{m}); end (lib/devise/mapping.rb:113-119).

    devise(*modules) (lib/devise/models.rb:79-112) sorts symbols by Devise::ALL.index(s) (:83); for each m does mod = Devise::Models.const_get(m.to_s.classify) (:91); extends mod::ClassMethods if present (:93-95); applies any matching available_configs setter (:97-103); calls include mod (:106).

    devise_for :users calls Devise.add_mapping(:users, options) (lib/devise/rails/routes.rb:242) → Devise::Mapping.new(:users, …) with @singular = :user (mapping.rb:56) → every registered helper-host gets define_helpers(mapping) (lib/devise.rb:368). define_helpers class_evals a <<-METHODS heredoc against Devise::Controllers::Helpers (helpers.rb:116-134).

  3. Generated — for devise :database_authenticatable, :recoverable on User: include Devise::Models::Authenticatable (always) + Devise::Models::DatabaseAuthenticatable + Devise::Models::Recoverable plus each module’s included do body (e.g. attr_reader :password, after_update :send_email_changed_notification, lib/devise/models/database_authenticatable.rb:34-40) plus instance methods (password=, valid_password?, …) plus ClassMethods (e.g. Recoverable.reset_password_by_token).

    For devise_for :users: four methods land on every controller, parameterised by mapping.name: authenticate_user!, user_signed_in?, current_user, user_session.

  4. Static-expandability — All four canonical obstacles are real:

    • Symbol → constant via String#classify is mechanical (recoverable).
    • class_eval <<-METHODS strings interpolate #{mapping} / #{m} — deterministic once the mapping name is known.
    • Devise.mappings is populated only when devise_for :users runs; sniffing config/routes.rb is required to know the set of helper names.
    • Inclusion order is sorted by Devise::ALL.index(s) — stable, can be baked into a plugin table mirroring lib/devise/modules.rb.
    • ActiveSupport::Concern.included do blocks must be replayed in the target class. extend ClassMethods adds class-level methods. send(:"#{config}=", value) further mutates class state from options.
  5. Closest analoguePHPStan trait inlining + bundled registry. Not a Lisp macro — the call site (devise :database_authenticatable, …) is a table-driven include sequence whose inputs (symbol list, Devise::ALL order) and outputs (concrete Module constants

    • included do side-effects + ClassMethods extension) are statically resolvable from a registry that mirrors lib/devise/modules.rb. A rigor-devise plugin needs (1) that bundled registry, (2) a model-side walker for devise :a, :b, …, (3) a routes-side walker for devise_for :foo synthesising four method-defs parameterised by mapping.singular, (4) a devise_group walker for the union-resource helper case. No Ruby execution required when the macro inputs are literal symbols. Third-party Devise.add_module calls from user initializers fall outside the bundled registry — needs either an initializer walker or a manual extension 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. MechanismHasFields#field is a pure metadata recorder. It constructs a Schema::Field (lib/graphql/schema/member/has_fields.rb:89) and stores it in own_fields (:124-135). Field#initialize records @resolver_method = (resolver_method || name_s).to_sym (lib/graphql/schema/field.rb:270). Field.rb contains zero define_method / class_eval / module_eval calls.

    Resolution is fully runtime-dynamic. Field#resolve does if obj.respond_to?(resolver_method) … obj.public_send(resolver_method, **ruby_kwargs) (field.rb:757-765), falling through to Hash lookup or @fallback_value.

  3. Generated — graphql-ruby does not define_method :display_name on Types::User. The user defines it manually (the docs warn via conflict_field_name_warning, has_fields.rb:318). The only auto-defined methods are:

    • HasFields#global_id_fielddefine_method(field_name) (has_fields.rb:154).
    • Enum.generate_value_methoddefine_singleton_method (enum.rb:259), opt-in via value_methods(true) / value_method:.
    • InputObject argument readers (input_object.rb:304).
    • BuildFromDefinition (SDL → class) → owner.define_method (build_from_definition.rb:538).
  4. Static-expandability — Heavy blockers:

    • Type expression is a polymorphic black box. Member::BuildType.parse_type (build_type.rb:12-97) accepts String (“User”, “[User]!”), Array, Class, Module, LateBoundType, NonNull / List wrappers, and Proc (:75-76 calls the proc). Procs and string-constantize defeat static resolution without running the schema.
    • No method emission to inline. The DSL adds no Ruby methods on the type; what it adds is a GraphQL schema graph that lives only at runtime.
    • resolver: / mutation: reroute dispatch to another class (has_fields.rb:62-65).
    • Connection wrapping is name-driven late binding (field.rb:127, :124).
    • Block-form fields are instance_exec’d on the Field instance (field.rb:380-388).
  5. Closest analogueNeither Lisp-macro nor PHPStan-trait fits. There is no Ruby method to expand — the DSL is a schema-graph recorder. To give field reader/call-site useful types, rigor would need a schema-resolution pass that re-implements Schema::Member traversal (or actually requires the schema and reads Schema.types). The closest comparison is GraphQL’s own schema-loading phase. Pure AST-level macro expansion is infeasible because Proc lazy-type and String constantize references are first-class. Resolver methods themselves are plain Ruby; once a schema graph exists, return-type assertions can be expressed as @rbs overrides — but the discovery of which method has which return type is full schema evaluation, not macro expansion.


  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. MechanismFactoryBot.define { … } (lib/factory_bot/syntax/default.rb:6-8) hands the block to DSL.run, which instance_evals on a fresh DSL (syntax/default.rb:36-38). factory(name, opts, &block) (:15-26) constructs Factory.new(name, options) (factory.rb:9-18), wraps it in a DefinitionProxy, instance_evals the block on the proxy, then registers via Internal.register_factory (internal.rb:79-84). Attribute lines (name { "Alice" }) hit DefinitionProxy#method_missing (definition_proxy.rb:91-104), which routes to __declare_attribute__ (:247-254).

    FactoryBot.create / build / build_stubbed / attributes_for are NOT statically defined in Syntax::Methods — they are define_method-installed at load time. Internal.register_default_strategies (internal.rb:99-105) calls a StrategySyntaxMethodRegistrar that module_execs define_method into FactoryBot::Syntax::Methods (strategy_syntax_method_registrar.rb:55-63). _list / _pair variants wrap the singular form in Array.new(amount) { … } (:35-52).

  3. Generatednothing is generated on user model classes. Two things are generated:

    • Factory definitions in a registry keyed by symbol. Factory#build_class resolves the target class lazily via class_name.to_s.camelize.constantize, defaulting to name when class: is not supplied (factory.rb:24-32, 109-111).
    • Top-level strategy methods on FactoryBot::Syntax::Methods: build, create, attributes_for, build_stubbed, plus _list / _pair variants — 12 default methods. Return ladder:
      • Strategy::Build#result / Create#result / Stub#result → instance of build_class.
      • Strategy::AttributesFor#resultHash.
      • _listArray[T]; _pairArray[T] size 2.
  4. Static-expandability — Three ingredients suffice to type create(:user):

    1. Factory-name → model-class map. Walk FactoryBot.define { factory :foo[, class: Bar][, parent: :baz] … } blocks. Block bodies don’t need evaluation for the return-type question. class: is literal; absent, it’s name.to_s.camelize.constantize. parent: chains class inheritance.
    2. Strategy-method → return-shape table. Hardcoded: build/create/build_stubbed → model class; attributes_forHash; *_listArray[T]; *_pairArray[T] size 2.
    3. Trait names are return-type-irrelevant. They gate attribute coverage at call sites, not class.

    Requires runtime: FactoryBot.modify, dynamic registration outside define, custom user-registered strategies, to_create / initialize_with blocks that change instantiation semantics (factory.rb:140-146).

  5. Closest analoguePHPStan-style “generic method whose return type is computed from a literal symbol argument.” Same shape as Rails’ find_by_* family or PHPStan’s DynamicMethodReturnTypeExtension. NOT macro expansion in the Lisp/PHPStan-trait sense — factory_bot does not generate methods on User, so there is nothing to inline at user-class call sites. The model is: one registry walk at boot + one return-type rule per strategy method, keyed on the first symbol argument. rigor already has the right hook in plugins/rigor-factorybot/; reachable extensions without macro machinery include *_list / *_pair wrapping, parent: chain resolution, aliases: registration, trait-name validation.


  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. Mechanismget, put, post, delete, head, options, patch, link, unlink are class methods on Sinatra::Base (lib/sinatra/base.rb:1531-1553). Each forwards to route(verb, path, options, &block) (:1776-1782), which calls compile! (:1795-1813). Inside compile! the block is converted to a real method via 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

    Synthetic name is "#{verb} #{path}", e.g. "GET /hello". The method is defined then immediately removed, retaining only the UnboundMethod, later rebound per request: unbound_method.bind(a).call(...) (:1808-1810). Classic-mode top-level get is Sinatra::Delegator forwarding to Sinatra::Application (:2101-2127, mixed in by lib/sinatra/main.rb:52, 55).

  3. Generated — for get '/hello' do … end: a transient define_method("GET /hello", &block) on the app class, captured as UnboundMethod, then remove_method. The unbound method is stored inside a wrapper proc and appended to @routes['GET'] as [pattern, conditions, wrapper]. The block executes bound to a fresh app instance per request. params, request, response, env, app are attr_accessor on Sinatra::Base instances (:978). Helpers (erb, redirect, halt, session) are plain instance methods of Sinatra::Base.

  4. Static-expandability — Highly amenable. The block body is unmodified Ruby; self inside is the app-class instance. Mechanical expansion: treat any <verb>(path, opts = {}, &block) inside class X < Sinatra::Base as a synthetic private instance method on X with the block body inlined and self typed as X. Inject the Sinatra::Base instance-method surface into scope. Classic-mode top-level requires also recognising Sinatra::Delegator and treating bare get as Sinatra::Application.get.

  5. Closest analogue — The cleanest case in Ruby’s DSL zoo: the block already is the method body, byte-for-byte. Unlike a Lisp macro (which rewrites syntax) or PHPStan trait inlining (which copies AST), Sinatra requires no rewriting — generate_method is literally define_method(name, &block). Once the analyzer accepts “this block runs as an instance method on Sinatra::Base”, no expansion is needed.


  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. Mechanism — column accessors driven by the live database. Sequel::Model.inherited calls subclass.set_dataset(subclass.implicit_table_name) (lib/sequel/model/base.rb:987) at parse time. set_dataset then calls @db_schema = get_db_schema (:634). get_db_schema issues a real schema-parsing query (:901, “if db.supports_schema_parsing?”) and feeds the resulting columns into def_column_accessor(*schema_hash.keys) (:918). Each column is emitted via overridable_methods_module.module_eval("def #{column}; self[:#{column}] end", …) (:797-801), with a slow-path block-based fallback for non-identifier column names (def_bad_column_accessor, :772-785).

    Difference from ActiveRecord. AR lazily defines accessors via method_missing / define_attribute_methods on first access and caches schema via db/schema.rb (a Ruby source file written at migration time). Sequel has no equivalent committed-to-disk schema artefact in stock Sequel — it does the DDL probe at class load and def_column_accessor synchronously.

  3. Generated for associations (lib/sequel/model/associations.rb:2238-2285):

    • one_to_many :posts (or many_to_many): returns_array? is true, so the method-name table (:2243-2249) produces posts, posts_dataset, _add_post, add_post, _remove_post, remove_post, _remove_all_posts, remove_all_posts. Singularisation via singularize(opts[:name]) at :2243.
    • many_to_one :account: singular branch (:2250-2253) adds _account= / account= + account and account_dataset. No add/remove/clear unless writable.

    All methods installed by association_module_def, which calls mod.send(:define_method, name, &block); mod.send(:alias_method, name, name) (:2198-2202). The receiving module defaults to overridable_methods_module.

  4. Static-expandability:

    • DB-schema dependency. Column accessors only exist after a live get_db_schema_array call. A static analyser must consume either user-supplied DDL, a database-issued DESCRIBE, or an inferred-from-migrations source. There is no canonical static schema source in stock Sequel.
    • Plugin sequencing is order-dependent and side-effecting. Model.plugin (base.rb:496-520) runs apply once, then extend ClassMethods, include InstanceMethods, dataset_extend(DatasetMethods), then configure every call. Plugins may add further define_method / class_eval from inside apply / configure. Static expansion needs a plugin replay engine, not just a name-to-method table.
    • Association names can be dynamic, options can rename methods. :methods_module overrides where methods land (:2192); singular / plural names derive from singularize.
    • class_eval strings with computed bodies are used in def_initialize_nil_instance_variables (base.rb:858-865), def_model_dataset_method (:825-828), Plugins.def_dataset_methods (plugins.rb:31-37). Emitting them requires running the same logic Sequel does.
    • Plugins.def_sequel_method mints unique method names (plugins.rb:73-75, 92-138) — counter state at expansion time is unobservable, but the methods are private call-targets only.
  5. Closest analogueHybrid: PHPStan-trait-style on the association layer + schema-source dependency on the column layer + plugin replay engine.

    • one_to_many / many_to_one / many_to_many are trait-inlining-style: name-driven method synthesis from a fixed table, mechanically expandable.
    • Column accessors are NOT pure macros — they need a schema oracle (closest to PHPStan’s PDO reflection extensions, or kphp’s .sql ingestion). ADR-10-style opt-in inference does not help because the columns live outside Ruby, not in a no-RBS dependency.
    • The plugin system is closest to “trait inlining with side-effecting apply hooks” — modules are statically known, but apply / configure blocks carry runtime data the analyser must symbolically execute or model declaratively.

    A small “Sequel associations” plugin can expand the association DSL cleanly. A complete Sequel checker needs (a) a schema-source adapter — likely Sequel::Database#schema dump, a user-pointed schema.rb-equivalent, or migration walking — and (b) a plugin-replay subsystem.


#Site (path:line)Pattern
Alib/redmine/views/labelled_form_builder.rb:25-33heredoc + interpolated literal list (field_helpers - blocklist + %w(date_selec))
Blib/plugins/acts_as_event/lib/acts_as_event.rb:48-62heredoc + literal %w(datetime title description author type)
Capp/models/setting.rb:333-350heredoc parameterised by setting name; names come from config/settings.yml + Redmine::Plugin.all
Dapp/models/user.rb:30, 293-303eval('"' + f[:string] + '"') where f[:string] is a literal value in USER_FORMATS
Eapp/models/webhook_payload.rb:71instance_eval(File.read(Rails.root.join(path)), path, 1)
Flib/redmine/plugin.rb:69-77def_field — block-form class_eval do … define_method over a literal symbol list
Glib/redmine/nested_set/traversing.rb:23-28block-form class_eval reopening the includer’s scope (no string heredoc)

Representative example — Site 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
PatternSitesDescription
(a) static-text heredoc, no interpolationnone — every Redmine heredoc interpolates at least the method name
(b) heredoc interpolating source-visible literalsA, B
(b′) heredoc interpolating file-driven literalsC
(b″) literal-symbol-list block-form class_eval + define_methodF
(b‴) block-form class_eval (no string), reopening includerG
(c) eval of code loaded from an external fileE
(d) eval of string concatenated with runtime datanone in the strict sense — D’s template strings are source-visible literals, just selected by runtime key

Redmine::PluginLoader (lib/redmine/plugin_loader.rb:30-32) loads <plugin>/init.rb with load, not instance_eval — so self inside a plugin’s init.rb is main, like a require’d script.

The instance_eval lives one level deeper, inside Redmine::Plugin.register (lib/redmine/plugin.rb:93-95):

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

Inside Redmine::Plugin.register :foo do … end, self is a fresh Redmine::Plugin instance; barewords (name 'X', author 'Y', settings :default => {…}) hit the def_field-generated accessors (Site F). A second instance_eval at :381-385 nests under project_module name do … endself stays the plugin instance, @project_module is set as a side-channel for permission calls inside the block.

  • A — expandable. Source-visible %w(...) set arithmetic, except for field_helpers which comes from ActionView::Helpers::FormBuilder.field_helpers (an upstream constant). Resolve that list (stable per Rails version) and emit ~12 def text_field/email_field/… stubs.
  • B — fully expandable. Five literal symbols → five def event_datetime / event_title / event_description / event_author / event_type returning the same expression.
  • C — expandable only if the analyzer reads config/settings.yml (~70 setting keys) and walks Redmine::Plugin.register :id do settings :default => {...} end blocks. Each name produces three class methods (name, name?, name=). Shape is statically known; the set of keys requires reading the YAML and the plugin init.rb files. This is the case the user’s activesupport-core-ext + project-side monkey-patch pre-evaluation memory note describes.
  • Dappears intractable but is actually (b′): USER_FORMATS is a source-visible constant whose :string / :initials values are static template strings. A macro-expander could turn eval('"' + f[:string] + '"') into String ∨ over the nine templates. Without that special case, emit Dynamic[String].
  • E — Pattern (c). Requires reading webhook payload .rb templates at the configured path; self is a WebhookPayload instance with @-ivars injected by the caller. Tractable if the analyzer treats the payload-template directory as additional source roots (PHPStan stub-file pattern).
  • F — block-form class_eval + define_method with literal symbol args. Trivially expandable: emit getter/setter pairs for each name (semantics differ from attr_accessor).
  • G — block-form class_eval reopening the includer’s scope. Not string-eval; standard included hook.
  • A, B, F, G → Lisp defmacro over a source-visible literal list. Free wins for an expander.
  • C → PHPStan per-class generated stubs (extension scanning a registry
    • emitting method declarations). Largest payoff per LOC of expander code: covers ~70 + N(plugins) accessor triples.
  • D → also defmacro-like once USER_FORMATS is recognised as the macro table.
  • E → PHPStan stub-file pattern (parse an external .rb as if pasted at the call site under instance_eval semantics).

No site in Redmine reaches genuinely intractable Pattern (d). Every class_eval / eval site interpolates either source-visible literals or values reachable via one indirection (YAML, plugin registry, sibling constants). Validates the macro-template + heredoc-Ruby direction in the existing memory note: a Redmine-focused expander that handles “literal-list iteration heredoc” + “literal-key Hash table eval” + “external Ruby file eval with declared self-context” reclaims essentially every metaprogrammed method in the codebase.


  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. MechanismDry.Types(...) returns a fresh Dry::Types::Module instance (lib/dry/types.rb:252-254); include is the normal Ruby include because Dry::Types::Module < ::Module. Module#initialize (lib/dry/types/module.rb:20-37) projects the flat registry keys of Dry::Types.container ("strict.string", "coercible.hash", …) into a nested constant tree via registry_tree (module.rb:74-84), then walks the tree with define_constants calling mod.const_set(name, value) for each leaf (module.rb:109-124). The registry is populated once at gem load by lib/dry/types/core.rb: four loops iterate ALL_PRIMITIVES / KERNEL_COERCIBLE / METHOD_COERCIBLE / NON_NIL (literal frozen Hashes at core.rb:6-46) and register("nominal.string", …) etc. (core.rb:49-99).

    Dry::Types[name] (lib/dry/types.rb:114-141) is a runtime lookup with one parsed-at-call-site recursion: a "array<string>" string is split by TYPE_SPEC_REGEX and re-routed through container[type_id].of(self[member_id]). Composition operators live in Dry::Types::Builder (lib/dry/types/builder.rb): |Sum (:28), &Intersection (:37), >Implication (:46), .optionalnil | self (:53-62), .constrainedConstrained.new (:71-73). Method-call coercion functions are themselves built via module_eval(<<~RUBY, …) in lib/dry/types/constructor/function.rb:63-75 — a heredoc interpolating the literal coercion method name.

  3. Generated — for include Dry.Types() with default :strict:

    • Constants on the includer: String, Integer, Float, Decimal, Array, Hash, Symbol, Nil, Class, True, False, Bool, Date, DateTime, Time, Range, Any. Each is a Dry::Types::Constrained carrier (core.rb:55-59).
    • Namespace submodules mirroring the registry’s dotted prefixes: Strict::Integer, Nominal::Integer, Coercible::Integer, Optional::Strict::Integer, Params::Integer, JSON::Integer, etc. — exact projection of container.keys (module.rb:74-84).
    • BuilderMethods extended into the includer (module.rb:25 + builder_methods.rb:11-14): module methods Array(type), Hash(type_map), Instance(klass), Strict(klass), Value(v), Constant(v), Constructor(klass, &), Nominal(klass), Map(k, v), Interface(*methods).
    • No instance methods on any user class; everything is constant-shaped data.
  4. Static-expandability — tractable:

    • The constant set is a pure function of the registry’s literal key list, and the registry’s contents are computed from frozen Hashes in core.rb at gem-load time. A plugin can ship a bundled registry mirroring those Hashes (same shape as rigor-devise’s mirror of lib/devise/modules.rb).
    • Each constant binds to a known carrier shape (Constrained<Nominal<Integer>> for Types::Integer, etc.).
    • Dry.Types(:strict, default: :coercible) cherry-picking and Dry.Types(coercible: :Kernel) aliasing kwargs are AST-visible.
    • Dry::Types["integer"] fits a dynamic-return-type extension (ADR-2, factory_bot shape) — not the substrate.
    • Dry::Types[String] (Class arg) and Dry::Types["array<string>"] (regex-parsed nested form) need extra resolver rules.
    • Edge cases: .constructor { |x| … } accepts a Proc whose output type depends on the body — fallback Dynamic[T].
    • .optional / | / & / > / .constrained compose carriers algebraically — a static evaluator must implement the lattice algebra (already on rigor’s roadmap).
    • .define_builder(:or_nil) (lib/dry/types.rb:196-200) Builder.define_method(method, …) at runtime — source-visible literal symbol, trackable.
  5. Closest analoguePHPStan DynamicMethodReturnTypeExtension (registry + symbol → type lookup) for Dry::Types[...], plus a Tier-C-style synthetic-constant emit for Dry.Types() constants. The latter is not a heredoc template (no class_eval string runs on the includer — const_set is used directly) but the shape is identical from a plugin author’s perspective: a fixed registry of names mapping to a fixed carrier shape. A Tier-C-as-const_set declaration listing (constant_name, namespace, primitive, carrier_shape) rows captures it.


  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. MechanismDry::Schema.Params(&) is define(processor_type: Params, &) (lib/dry/schema.rb:86-89); define(...) is literally DSL.new(...).call (lib/dry/schema.rb:67-69). DSL.new (lib/dry/schema/dsl.rb:81-86) calls super (Dry::Initializer) then dsl.instance_eval(&). The block runs as instance methods on the Dry::Schema::DSL instancerequired / optional / key / before / after / array are real instance methods of DSL (dsl.rb:144-188).

    required(:email)key(:email, macro: Macros::Required) (dsl.rb:144-146) builds a Macros::Required, stores Types::Any as the placeholder type for :email via set_type (dsl.rb:176, 316-321), and pushes the macro onto @macros (dsl.rb:186-187). .filled(:string) is Macros::DSL#filled (lib/dry/schema/macros/dsl.rb:80-86); it calls 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"]. The resolved type is recorded with schema_dsl.set_type(name, resolved_type) and a Macros::Filled is appended via append_macro (macros/dsl.rb:205-216).

    dsl.call (dsl.rb:195-207) is the terminal builder: it collects parents.steps, builds key_validator, key_coercer, value_coercer, rule_applier, then processor_type.new(schema_dsl: self, steps: result_steps). The returned object IS the schema — a Dry::Schema::Processor instance with #call(input) (processor.rb:77-80). Macros::Value#method_missing (macros/value.rb:115-121) catches any symbol ending in ? (e.g. min_size?) and forwards to trace — predicates are bare bareword symbols in user source.

  3. Generatednothing is define_method’d on the user’s class. UserSchema is a local binding to a Dry::Schema::Params instance. End products:

    • A Dry::Schema::Processor instance (processor.rb:15) holding @steps, @schema_dsl, an internal key_map, key_coercer, value_coercer, rule_applier. Callable as schema.(input) returning Dry::Schema::Result.
    • A KeyMap enumerating [:email, :age, :tags] derived from @types (dsl.rb:408-417).
    • A Dry::Types::Schema (the strict_type_schema, dsl.rb:295-297) carrying the per-key type assignments.
    • Class form (class UserSchema < Dry::Schema::Params; define do … end; end) stores @definition on the class (processor.rb:46-50) and UserSchema.new returns the built processor (processor.rb:57-67).
  4. Static-expandability — mixed:

    • Predicate names are literal Symbols. required(:email).filled(:string) is fully literal; :string resolves through TypeRegistryDry::Types[…]. Registry namespace (:strict for define, :params for Params, :json for JSON) is determined by the call-site method name on Dry::Schema. All source-visible.
    • The block’s return value is Dry::Schema::Processor (or subclass) regardless of contents — trivially typeable.
    • schema.(input) returns Dry::Schema::Result whose #to_h shape is the key map. The key set is statically extractable from required(:key) / optional(:key) positions; per-key types from the corresponding .value(:type) / .filled(:type) / .maybe(:type) arguments. This is the same “AST recorder” shape rigor-factorybot uses at the definition side.
    • The block runs under instance_eval on the DSL instance (dsl.rb:83). The substrate’s Tier A applies: declare self : Dry::Schema::DSL and the bareword surface (required, optional, key, before, after, array).
    • .filled { … } / .value { array? | str? } use Dry::Logic::Operators and method_missing (macros/value.rb:115-121); block bodies are again instance_exec’d on a fresh macro Core — Tier A nested.
    • Genuinely dynamic: required(:name).value(SomeCustomType) where SomeCustomType is a Dry::Types::Type instance (same resolve_type path); array(:string).filled(min_size?: 2) predicate operator chains; Dry::Logic predicate composition in blocks (array? | str?) needs operator-overload tracking.
  5. Closest analogueHybrid: schema-graph recorder (graphql-ruby-style) at the rule-trace level + PHPStan dynamic-return-type extension on Dry::Schema.Params { … }.call(input). Unlike graphql-ruby, the block contents are themselves statically expandable into a key-set + per-key-type table. A rigor-dry-schema plugin can synthesise: “the schema processor’s #call(input) returns Result[T] whose #to_h has shape {email: String, age: Integer, …}.” This needs Tier A (declare the block as instance_eval on Dry::Schema::DSL) plus a custom call-recorder walking required/optional/filled/value/maybe/each/array inside the block to build the key map. No tier inlines the user’s class — the value is in typing the return shape of the schema invocation.


  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. MechanismDry::Struct extends ClassInterface (lib/dry/struct.rb:87); the inheriting class inherits attribute / attribute? / attributes / transform_types / transform_keys as class methods. attribute(:city, Types::String) (lib/dry/struct/class_interface.rb:86-88) delegates to attributes(city: build_type(:city, Types::String)).

    attributes(new_schema) (class_interface.rb:173-189) does three things: (a) schema updateschema schema.schema(new_schema) replaces the class-level schema (a Dry::Types::Hash::Schema) with a new one augmented by the new key/type pairs (:177); (b) accessor synthesisdefine_accessors(keys) (:179:452-464); (c) inheritance propagation — for each subclass, recursively call d.attributes(inherited_attrs) (:183-186).

    define_accessors(keys) emits a getter per key:

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

    Falls back to define_method(key) { @attributes[key] } for non-identifier-shaped names (the valid_method_name? regex check at :468).

    .new(city: "Tokyo", …) (class_interface.rb:239-258) coerces via the class-level schema.call_unsafe(attributes) then load(attributes)allocate + initialize(attributes) (:279-283). Nested-block form (attribute :details do …) routes through StructBuilder#call (lib/dry/struct/struct_builder.rb:18-42), which Class.new(parent_struct) + class_exec(&block) + const_set(:Details, new_type) on the parent class.

  3. Generated — for class Address < Dry::Struct; attribute :city, Types::String; end:

    • Class-level state: Address.schema is a Dry::Types::Hash::Schema whose :city key maps to Types::String. Address.attribute_names returns [:city] (class_interface.rb:357-359).
    • Instance method Address#city: synthesised by the class_eval heredoc (class_interface.rb:455-459) — returns @attributes[:city]. Return type follows the attribute’s Types::String carrier ⇒ String.
    • No writerDry::Struct is immutable; only readers exist (and #new(changeset) for copy-with-overrides at :202-211).
    • Address#[](key) and Address#to_h inherited from Dry::Struct (lib/dry/struct.rb:147-178); shape is {city: String} derived from the class schema.
    • Address.new(city:) is the typed constructor (class_interface.rb:239).
    • For attribute? :postcode, …: same path with required: false; accessor name is still postcode (? stripped at :174).
    • Nested-block form: Address::Details is const_set (struct_builder.rb:33), itself a Dry::Struct subclass; the parent gets a details reader returning Address::Details.
  4. Static-expandability — the cleanest of the three:

    • Attribute names are literal Symbols. The class_eval heredoc at class_interface.rb:455-459 interpolates #{key} and #{key.inspect}. Direct fit for ADR-16 Tier C.
    • Attribute types are either Dry::Types::Type instances (e.g. Types::String) or a String ("integer") routed through Dry::Types[...] (class_interface.rb:430-441). String case is the same registry lookup as dry-schema’s :string.
    • Composition operators (Types::String.optional, Types::Integer | Types::Nil, Types::String.constrained(…)) follow the dry-types Builder algebra. A Tier-C emit for attribute :x, Types::Integer.optional knows the reader returns Integer | nil.
    • Nested-block form attribute :details do … mints a constant Address::Details whose body itself contains more attribute calls — Tier C must recurse (or compose Tier A’s instance-eval framing with Tier C’s emit). Const name follows Inflector.camelize(attr_name) (struct_builder.rb:64-73); deterministic from the Symbol.
    • attributes_from(SomeStruct) (:114-123) copies keys from another struct’s schema — recoverable if the source struct is itself walkable.
    • transform_keys(&:to_sym) / transform_types (:204-223) — meta-level changes that don’t alter the surface method names; safely ignorable for accessor synthesis.
    • Dry.Struct(name: Types::String, age: Types::Integer) (lib/dry/struct.rb:30-35) — proc-form constructor; the keyword-argument Hash is source-visible literal in the usual call shape; same Tier C reach as the inheritance-form attribute :….
    • Edge cases: attribute :name, SomeCustomType where SomeCustomType is a project-local variable holding a Type — needs flow-analysis to bind the carrier (regular rigor work, not substrate).
  5. Closest analogueADR-16 Tier C (heredoc-template expansion with literal-symbol parameters), in textbook form. Shape is identical to ActiveStorage’s has_one_attached :avatar: a class-level DSL call enumerates a literal Symbol argument; the framework class_evals a heredoc interpolating that Symbol; the emit table is fixed. For dry-struct the emit table for attribute :city, T is:

    SyntheticReturn
    def city instance methodT#primitive
    schema key :cityT
    to_h row city: …T
    [:city] accessT
    .new(city: …) keyword argT (coerced)

    Nested-block form adds a Tier A + Tier C composition (the block runs instance_eval-style on a fresh StructBuilder-bound Class.new(parent); the outer call const_sets a new constant on the parent — the constant name is computable from the Symbol).


Composition (dry-types ⇒ dry-schema / dry-struct)

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

The three gems compose in dependency order dry-types → {dry-schema, dry-struct}.

  • dry-schema requires dry-types (lib/dry/schema.rb:8require "dry/types"); the rule predicate inferrer subclasses ::Dry::Types::PredicateInferrer (lib/dry/schema/predicate_inferrer.rb:6); the schema module’s own Types namespace is include ::Dry.Types (lib/dry/schema/types.rb:11); TypeRegistry is a direct wrapper around Dry::Types (lib/dry/schema/type_registry.rb:21-29). Every :string / :integer / :array in required(:x).filled(:string) resolves through Dry::Types["strict.string"] (or "params.string" / "json.string" per processor namespace — type_registry.rb:37-41).
  • dry-struct requires dry-types (lib/dry/struct.rb:6); Dry::Struct::ClassInterface includes Types::Type and Types::Builder (class_interface.rb:11-12); Dry::Struct.schema is itself a Dry::Types::Hash::Schema (struct.rb:115-116); attribute :name, "integer" (String form) routes through Dry::Types["integer"] (class_interface.rb:432-433).
  • dry-struct does NOT require dry-schema and dry-schema does NOT require dry-struct — siblings sharing dry-types.

Implication for rigor-* plugin sequencing. A rigor-dry-types plugin authored first partially unlocks both downstream gems but does not subsume them:

  • rigor-dry-types would ship: (a) the bundled name registry mirroring core.rb’s ALL_PRIMITIVES / KERNEL_COERCIBLE / METHOD_COERCIBLE / NON_NIL Hashes; (b) a Tier-C-shaped constant emit for include Dry.Types(…); (c) a dynamic-return-type rule for Dry::Types[<literal>]; (d) carrier-algebra handling for |, &, >, .optional, .constrained, .constructor. After this, any expression producing a Dry::Types::Type carrier is typed, regardless of whether it appears in dry-schema, dry-struct, or free-standing code.
  • rigor-dry-schema still needs its own plugin: the schema-graph recording walk inside Dry::Schema.Params { … } (Tier A scope annotation + a required / optional / value / filled / maybe / each / array recorder), plus the Processor#call(input) -> Result[T] typing whose T derives from the recorded key map. It consumes rigor-dry-types for the per-key carrier resolution (:stringTypes["strict.string"]).
  • rigor-dry-struct still needs its own plugin: the Tier C heredoc emit per attribute :name, T call plus the nested-block composition that mints a Const_name subclass. It consumes rigor-dry-types for the per-attribute T carrier.

So rigor-dry-types is a shared dependency for the other two, mirroring the gem dependency graph one-to-one. Without it, per-attribute / per-key type expressions in the downstream gems degrade to Dynamic[T]. With it, the downstream plugins are thin AST recorders.

The dry-rb family is a strong fit for the macro substrate: dry-struct slots cleanly into Tier C; dry-schema slots into Tier A plus a Tier-A-driven recorder; dry-types is best modelled as a bundled constant registry (closer to Tier-C-as-const_set than Tier-C-as-method-definition) plus a dynamic_return_type extension for Dry::Types[…]. No GraphQL-Ruby-shaped intractability and no Sequel-column-accessor-shaped schema-oracle requirement appears in any of the three.


Sorted by descending tractability for static expansion / replay. The “shape” column names the metaprogramming primitive an expander would need to handle; the “rigor today” column records the current plugin / handling state.

Library / subsystemExpansion shapeClosest analoguerigor today
Sinatra routesblock-IS-method via define_method(&block)”no expansion needed once self is typed”no plugin
ActiveStorage attachedheredoc parameterised by literal symbolPHPStan trait inliningrigor-activestorage already expands
dry-struct attribute :name, Theredoc parameterised by literal symbolPHPStan trait inlining (textbook Tier C)no plugin
AASMDSL block + literal-symbol state / eventtrait inlining / statesman shapeno plugin (statesman is the precedent)
Redmine A/B/F/Gblock-form class_eval + literal-symbol-list define_methodLisp defmacrono handling
Redmine Cheredoc parameterised by YAML/plugin-registry namePHPStan per-class stubs (needs YAML reader)no handling
dry-types include Dry.Types()bundled name registry + const_set emittrait inlining (Tier-C-as-const_set) + dyn-return-type for Dry::Types[…]no plugin
factory_botregistry + return-type computed from literal symbol argPHPStan DynamicMethodReturnTypeExtensionrigor-factorybot
Devise model sidetrait include sequence driven by bundled registryPHPStan trait inlining + registryno plugin
Devise routes / controllersheredoc parameterised by mapping.singulartrait inlining (needs routes walker)no plugin
Sequel associationsname-table emission from literal symboltrait inlining (statesman-like)no plugin
dry-schema Dry::Schema.Params do … endblock instance_eval + literal-symbol recorder of required / optional / type specTier A + AST recorder + dyn-return-type on Processor#callno plugin
ActiveSupport::Concerndeferred class_eval of a block, target = includertrait inlining + DSL re-targetingpartial (downstream walkers fire after re-targeting)
Redmine Einstance_eval(File.read(path))PHPStan stub-file patternno handling
Redmine Deval('"' + literal_template + '"')defmacro + template tableno handling
Sequel column accessorsneeds DB schema oraclePHPStan PDO-reflection extensionsno handling
GraphQL-Rubyschema-graph recorder; no method emissionschema-resolution pass (NOT macro expansion)deferred (no demand)

Observations carried forward (not yet decisions)

Section titled “Observations carried forward (not yet decisions)”

These are observations the synthesis turns up; the user will direct what to do with them.

  • Two clean PHPStan-trait-inlining targets are already in scope of the existing plugin contract: AASM (statesman-shape) and the Devise model side (bundled-registry + module include). Both are reachable without introducing macro-evaluation machinery.

  • Sinatra is the minimum viable “block → method” expander target. Once rigor accepts the contract “this block runs as an instance method on class X”, Sinatra needs no rewriting. Adjacent DSLs that share this shape (RSpec nested contexts, factory_bot evaluator, ActiveRecord class-level macros) become reachable from the same hook.

  • Redmine validates the “macro-template + heredoc-Ruby” direction. Sites A, B, F, G are pure defmacro over a literal list — handled by an AST-level expander with no runtime evaluation. Site C is the largest payoff but requires reading config/settings.yml + plugin init.rbs; fits the “project-side monkey-patch pre-evaluation” memory note. Site E is instance_eval(File.read(...)) with a declared self-context — PHPStan-stub-file shape. Genuine pattern-(d) string-eval with runtime data does not occur in Redmine.

  • GraphQL-Ruby is the lone library that does not fit the macro frame. It is a schema-graph recorder — there are no Ruby methods to expand at the type-class level. rigor-graphql would need a schema-resolution pass that re-implements (or replays) Schema::Member traversal. This matches rigor’s existing position of deferring rigor-graphql until concrete user demand surfaces.

  • Sequel splits into two layers: associations are trait-inlining-style (tractable plugin), column accessors need a schema oracle (intractable without a schema source). The plugin layer is “trait inlining with side-effecting apply hooks.”

  • ActiveSupport::Concern’s included do … end is the hinge that connects most Rails-shaped DSL plugins. Re-targeting the block’s contents to the includer’s class lets all downstream DSL walkers (has_one_attached, has_many, scope, AASM-via-Concern, Devise-via-Concern, …) fire in the right context. No macro evaluation needed; just walker re-targeting.

  • The dry-rb trio (dry-types, dry-schema, dry-struct) is a strong fit for the substrate. dry-struct is textbook Tier C (literal Symbol attribute :name, Tclass_eval heredoc emit). dry-types is a bundled-registry shape (Tier-C-as-const_set plus a dyn-return-type rule for Dry::Types[…]). dry-schema is Tier A (instance_eval on Dry::Schema::DSL) plus a recorder walking the block to build a key → type map. They compose via gem dependencies one-to-one: rigor-dry-types is a shared dependency for the other two plugins but does not subsume them. Without rigor-dry-types, downstream per-attribute / per-key types degrade to Dynamic[T]. With it, the downstream plugins are thin AST recorders. ADR-12 (dry-rb packaging decision) governs how the three plugins ship, not whether they apply.

Deliberately deferred to the macro-evaluation design discussion:

  • Whether rigor should grow a general macro-evaluator (Lisp-style), a per-pattern template table (PHPStan-trait-style), or both.
  • Whether expansion fires at parse / inference / a new “AST-rewrite” phase.
  • How macro hygiene interacts with rigor’s name_scope resolver.
  • How name_scope interacts with the instance_eval-bound self for Redmine-E-style external Ruby files.
  • Where the bundled registries (Devise modules, AS core_ext) live in the plugin contract.
  • Caching behaviour for macro-expanded AST under ADR-15 Ractor sharing.

Clones referenced (under /tmp/, not committed, not submodules):

  • /tmp/rails-research/ (rails/rails 8-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.