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:
- User-facing DSL surface (one short snippet).
- Implementation mechanism — what Ruby metaprogramming primitive carries it.
- What gets generated at the call site (method names, accessors, callbacks).
- Static-expandability — can a static analyzer recover the generated surface from source alone?
- 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.
ActiveSupport::Concern (Rails 8-0-stable)
Section titled “ActiveSupport::Concern (Rails 8-0-stable)”-
DSL
module Mextend ActiveSupport::Concernincluded { scope :active, -> { where(active: true) } }class_methods dodef foo; endendendclass Host; include M; end -
Mechanism —
ConcernoverridesModule#append_features/Module#prepend_features(activesupport/lib/active_support/concern.rb:129-153). On firstincludeit: (a) runs queued dependencies, (b) callssuper(the realinclude), (c)base.extend const_get(:ClassMethods)if defined (:137), (d)base.class_eval(&@_included_block)(:138).class_methods(&blk)lazily creates aClassMethodssubmodule viaconst_set+module_eval(:209-215).included(&blk)/prepended(&blk)only stash the block (:158-187). -
Generated — host gains
M’s instance methods, host’s singleton class gainsM::ClassMethods, theincluded do … endblock runs with the host asself(so anything DSL-shaped inside it lands on the host). Transitive: every module in@_dependenciesis recursively included beforesuper(:135, :148). -
Static-expandability — Partially expandable:
- Trivially expandable — the
ClassMethodsextend+ instance-methodincludehalf. Walker can treatinclude Mas “extendM::ClassMethods+ mixM’s defs into Host.” - Recursively expandable —
@_dependencieschain is purely lexical (include OtherConcerninside the module body). - Block expansion is conditional on block body.
included do … endis deferredclass_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?(…)insideincluded do;class_evalof an interpolated heredoc; a four-argincluded(base)body that runs at runtime against a concrete base.
- Trivially expandable — the
-
Closest analogue — PHPStan 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)”-
DSL
class User < ApplicationRecordhas_one_attached :avatar, service: :s3, strict_loading: trueendclass Gallery < ApplicationRecordhas_many_attached :photosend -
Mechanism — Both live in
activestorage/lib/active_storage/attached/model.rb, exposed throughclass_methods do … end(:54) inside a module extendingActiveSupport::Concern(:10). Accessor pair is generated by heredocclass_evalagainstgenerated_association_methods(:111-126forhas_one_attached,:213-230forhas_many_attached). Reflection bookkeeping runs throughadd_attachment_reflection(:146-154/:250-258); supporting associations (has_one,has_many,scope,after_save,after_commit) are additional inline DSL calls. -
Generated for
has_one_attached :avatar(mirror for:photoswith plural-suffixed names):def avatarreturningActiveStorage::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_commitcallbacks.User.reflect_on_attachment(:avatar)returns the reflection.- For
has_many_attached: same five-name pattern pluralised; primary accessor returnsActiveStorage::Attached::Many.
-
Static-expandability — Highly expandable:
- Method names are pure string interpolation on the symbol literal
argument.
:113, :115, :118, :128-131all use"#{name}". With the symbol literal visible, every generated name is computable. The existingrigor-activestorageplugin already exploits this. - Return types are stable and lexically determined.
- Conditional generation is shallow —
if ActiveStorage.track_variantsonly branches thewith_attached_*body, not the surface signature. - Hostile case —
has_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_attachedappears inside a user-sideextend ActiveSupport::Concernincludedblock, the Concern walker must re-target the block to the includer first; the attached-macro expander then fires there. The two passes interlock.
- Method names are pure string interpolation on the symbol literal
argument.
-
Closest analogue — PHPStan 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.
-
DSL
class Jobinclude AASMaasm dostate :sleeping, initial: truestate :running, :cleaningevent :run dotransitions from: :sleeping, to: :runningendendendMulti-machine variant:
aasm(:work_status) do … end(lib/aasm/aasm.rb:28-37). -
Mechanism —
include AASMextends withAASM::ClassMethodsand registers a state-machine slot (lib/aasm/aasm.rb:8-17). The class-levelaasm(*args, &block)builds (or reuses) anAASM::Basekeyed bystate_machine_nameand runs the block via@aasm[state_machine_name].instance_eval(&block)(:28-64). Inside the block,state/eventare instance methods onAASM::Base, not bare macros.state :pendingregisters the state and injectspending?on the host class viasafely_define_method(lib/aasm/base.rb:90-108).event :submit do … endregisters the event and injectsmay_submit?,submit!,submit,submit_without_validation!(base.rb:111-143). -
Generated on the host class:
- Per state
:foo(base.rb:99-106):foo?, constantSTATE_FOO, and an AR scopefooifcreate_scopesand the persistence adapter responds toaasm_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!. Withnamespace:, additional aliasesmay_bar_NS?etc.; state predicate becomesNS_foo?. - Class-level:
.aasm,.aasm(:name)returningAASM::Baseexposingstates,events,state_machine,human_event_name, etc.
- Per state
-
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 literalSymbol/Stringor the literaltrue(read the state-machine-name symbol fromaasm(:name) do).state :a, :barity 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-valuedinitial_state, AR scope generation gated on runtimerespond_to?(:aasm_create_scope). Predicate / event-method generation does NOT depend on adapter checks.
- Method names are deterministic from the symbol literal passed to
-
Closest analogue — PHPStan trait inlining / Lisp-macro-style expansion. Identical in spirit to how
plugins/rigor-statesman/already walksstate_machine_class.state :foo. AASM is in reach of the same plugin approach as statesman.
Devise
Section titled “Devise”-
DSL — three sites:
- Model:
class User < ApplicationRecord; devise :database_authenticatable, :recoverable, …; end(lib/devise/models.rb:79). - Routes:
devise_for :usersinsideRails.application.routes.draw(lib/devise/rails/routes.rb:226). - Controllers (implicit):
current_user,user_signed_in?,authenticate_user!,user_sessionsynthesised byDevise::Controllers::Helpers.define_helpers(lib/devise/controllers/helpers.rb:113).
- Model:
-
Mechanism —
Devise.add_module(:database_authenticatable, …)at gem load (lib/devise/modules.rb:9, wrapped byDevise.with_options model: true).add_module(lib/devise.rb:397-440) appends toALL(:400), registersSTRATEGIES/CONTROLLERS/ROUTES/URL_HELPERS, autoloadsDevise::Models::DatabaseAuthenticatablewhenmodel: true(:436), and callsDevise::Mapping.add_module module_name(:439) whichclass_evals a predicatedef #{m}?; modules.include?(:#{m}); end(lib/devise/mapping.rb:113-119).devise(*modules)(lib/devise/models.rb:79-112) sorts symbols byDevise::ALL.index(s)(:83); for eachmdoesmod = Devise::Models.const_get(m.to_s.classify)(:91); extendsmod::ClassMethodsif present (:93-95); applies any matchingavailable_configssetter (:97-103); callsinclude mod(:106).devise_for :userscallsDevise.add_mapping(:users, options)(lib/devise/rails/routes.rb:242) →Devise::Mapping.new(:users, …)with@singular = :user(mapping.rb:56) → every registered helper-host getsdefine_helpers(mapping)(lib/devise.rb:368).define_helpersclass_evals a<<-METHODSheredoc againstDevise::Controllers::Helpers(helpers.rb:116-134). -
Generated — for
devise :database_authenticatable, :recoverableonUser:include Devise::Models::Authenticatable(always)+ Devise::Models::DatabaseAuthenticatable + Devise::Models::Recoverableplus each module’sincluded dobody (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?, …) plusClassMethods(e.g.Recoverable.reset_password_by_token).For
devise_for :users: four methods land on every controller, parameterised bymapping.name:authenticate_user!,user_signed_in?,current_user,user_session. -
Static-expandability — All four canonical obstacles are real:
- Symbol → constant via
String#classifyis mechanical (recoverable). class_eval <<-METHODSstrings interpolate#{mapping}/#{m}— deterministic once the mapping name is known.Devise.mappingsis populated only whendevise_for :usersruns; sniffingconfig/routes.rbis 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 mirroringlib/devise/modules.rb. ActiveSupport::Concern.included doblocks must be replayed in the target class.extend ClassMethodsadds class-level methods.send(:"#{config}=", value)further mutates class state from options.
- Symbol → constant via
-
Closest analogue — PHPStan 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::ALLorder) and outputs (concreteModuleconstantsincluded doside-effects +ClassMethodsextension) are statically resolvable from a registry that mirrorslib/devise/modules.rb. Arigor-deviseplugin needs (1) that bundled registry, (2) a model-side walker fordevise :a, :b, …, (3) a routes-side walker fordevise_for :foosynthesising four method-defs parameterised bymapping.singular, (4) adevise_groupwalker for the union-resource helper case. No Ruby execution required when the macro inputs are literal symbols. Third-partyDevise.add_modulecalls from user initializers fall outside the bundled registry — needs either an initializer walker or a manual extension API.
GraphQL-Ruby
Section titled “GraphQL-Ruby”-
DSL
class Types::User < GraphQL::Schema::Objectfield :name, String, null: falsefield :display_name, String, null: false doargument :upcase, Boolean, required: falseenddef display_name(upcase: false); … endendclass Types::Status < GraphQL::Schema::Enumvalue "ACTIVE"value "DISABLED", value: :offend -
Mechanism —
HasFields#fieldis a pure metadata recorder. It constructs aSchema::Field(lib/graphql/schema/member/has_fields.rb:89) and stores it inown_fields(:124-135).Field#initializerecords@resolver_method = (resolver_method || name_s).to_sym(lib/graphql/schema/field.rb:270). Field.rb contains zerodefine_method/class_eval/module_evalcalls.Resolution is fully runtime-dynamic.
Field#resolvedoesif obj.respond_to?(resolver_method) … obj.public_send(resolver_method, **ruby_kwargs)(field.rb:757-765), falling through to Hash lookup or@fallback_value. -
Generated — graphql-ruby does not
define_method :display_nameonTypes::User. The user defines it manually (the docs warn viaconflict_field_name_warning,has_fields.rb:318). The only auto-defined methods are:HasFields#global_id_field→define_method(field_name)(has_fields.rb:154).Enum.generate_value_method→define_singleton_method(enum.rb:259), opt-in viavalue_methods(true)/value_method:.InputObjectargument readers (input_object.rb:304).BuildFromDefinition(SDL → class) →owner.define_method(build_from_definition.rb:538).
-
Static-expandability — Heavy blockers:
- Type expression is a polymorphic black box.
Member::BuildType.parse_type(build_type.rb:12-97) acceptsString(“User”, “[User]!”),Array,Class,Module,LateBoundType,NonNull/Listwrappers, andProc(:75-76calls 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 theFieldinstance (field.rb:380-388).
- Type expression is a polymorphic black box.
-
Closest analogue — Neither 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::Membertraversal (or actuallyrequires the schema and readsSchema.types). The closest comparison is GraphQL’s own schema-loading phase. Pure AST-level macro expansion is infeasible becauseProclazy-type andStringconstantize references are first-class. Resolver methods themselves are plain Ruby; once a schema graph exists, return-type assertions can be expressed as@rbsoverrides — but the discovery of which method has which return type is full schema evaluation, not macro expansion.
factory_bot
Section titled “factory_bot”-
DSL
FactoryBot.define dofactory :user doname { "Alice" }sequence(:email) { |n| "user#{n}@example.com" }association :accounttrait :admin dorole { "admin" }endendfactory :admin_user, class: User, parent: :user dorole { "admin" }endendFactoryBot.create(:user, :admin, name: "Bob") # => UserFactoryBot.build(:user) # => UserFactoryBot.build_stubbed(:user) # => UserFactoryBot.attributes_for(:user) # => HashFactoryBot.create_list(:user, 3) # => Array[User] -
Mechanism —
FactoryBot.define { … }(lib/factory_bot/syntax/default.rb:6-8) hands the block toDSL.run, whichinstance_evals on a freshDSL(syntax/default.rb:36-38).factory(name, opts, &block)(:15-26) constructsFactory.new(name, options)(factory.rb:9-18), wraps it in aDefinitionProxy,instance_evals the block on the proxy, then registers viaInternal.register_factory(internal.rb:79-84). Attribute lines (name { "Alice" }) hitDefinitionProxy#method_missing(definition_proxy.rb:91-104), which routes to__declare_attribute__(:247-254).FactoryBot.create / build / build_stubbed / attributes_forare NOT statically defined inSyntax::Methods— they aredefine_method-installed at load time.Internal.register_default_strategies(internal.rb:99-105) calls aStrategySyntaxMethodRegistrarthatmodule_execsdefine_methodintoFactoryBot::Syntax::Methods(strategy_syntax_method_registrar.rb:55-63)._list/_pairvariants wrap the singular form inArray.new(amount) { … }(:35-52). -
Generated — nothing is generated on user model classes. Two things are generated:
- Factory definitions in a registry keyed by symbol.
Factory#build_classresolves the target class lazily viaclass_name.to_s.camelize.constantize, defaulting tonamewhenclass: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/_pairvariants — 12 default methods. Return ladder:Strategy::Build#result/Create#result/Stub#result→ instance ofbuild_class.Strategy::AttributesFor#result→Hash._list→Array[T];_pair→Array[T]size 2.
- Factory definitions in a registry keyed by symbol.
-
Static-expandability — Three ingredients suffice to type
create(:user):- 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’sname.to_s.camelize.constantize.parent:chains class inheritance. - Strategy-method → return-shape table. Hardcoded:
build/create/build_stubbed→ model class;attributes_for→Hash;*_list→Array[T];*_pair→Array[T]size 2. - Trait names are return-type-irrelevant. They gate attribute coverage at call sites, not class.
Requires runtime:
FactoryBot.modify, dynamic registration outsidedefine, custom user-registered strategies,to_create/initialize_withblocks that change instantiation semantics (factory.rb:140-146). - Factory-name → model-class map. Walk
-
Closest analogue — PHPStan-style “generic method whose return type is computed from a literal symbol argument.” Same shape as Rails’
find_by_*family or PHPStan’sDynamicMethodReturnTypeExtension. NOT macro expansion in the Lisp/PHPStan-trait sense — factory_bot does not generate methods onUser, 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 inplugins/rigor-factorybot/; reachable extensions without macro machinery include*_list/*_pairwrapping,parent:chain resolution,aliases:registration, trait-name validation.
Sinatra
Section titled “Sinatra”-
DSL
# classicrequire 'sinatra'get '/hello' do"Hello #{params['name']}"end# modularclass MyApp < Sinatra::Baseget '/hello' do"Hello #{params['name']}"endend -
Mechanism —
get,put,post,delete,head,options,patch,link,unlinkare class methods onSinatra::Base(lib/sinatra/base.rb:1531-1553). Each forwards toroute(verb, path, options, &block)(:1776-1782), which callscompile!(:1795-1813). Insidecompile!the block is converted to a real method viagenerate_method(:1788-1793):def generate_method(method_name, &block)define_method(method_name, &block)method = instance_method(method_name)remove_method method_namemethodendSynthetic name is
"#{verb} #{path}", e.g."GET /hello". The method is defined then immediately removed, retaining only theUnboundMethod, later rebound per request:unbound_method.bind(a).call(...)(:1808-1810). Classic-mode top-levelgetisSinatra::Delegatorforwarding toSinatra::Application(:2101-2127, mixed in bylib/sinatra/main.rb:52, 55). -
Generated — for
get '/hello' do … end: a transientdefine_method("GET /hello", &block)on the app class, captured asUnboundMethod, thenremove_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,appareattr_accessoronSinatra::Baseinstances (:978). Helpers (erb,redirect,halt,session) are plain instance methods ofSinatra::Base. -
Static-expandability — Highly amenable. The block body is unmodified Ruby;
selfinside is the app-class instance. Mechanical expansion: treat any<verb>(path, opts = {}, &block)insideclass X < Sinatra::Baseas a synthetic private instance method onXwith the block body inlined andselftyped asX. Inject theSinatra::Baseinstance-method surface into scope. Classic-mode top-level requires also recognisingSinatra::Delegatorand treating baregetasSinatra::Application.get. -
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_methodis literallydefine_method(name, &block). Once the analyzer accepts “this block runs as an instance method onSinatra::Base”, no expansion is needed.
Sequel
Section titled “Sequel”-
DSL
class User < Sequel::Model # implicit table :users + DB schema lookupone_to_many :postsmany_to_one :accountmany_to_many :groupsendUser.plugin :timestamps, update_on_create: trueUser.plugin :validation_helpersuser = User[1]user.name # column accessor — synthesised from DB schemauser.posts # association accessoruser.add_post(p) -
Mechanism — column accessors driven by the live database.
Sequel::Model.inheritedcallssubclass.set_dataset(subclass.implicit_table_name)(lib/sequel/model/base.rb:987) at parse time.set_datasetthen calls@db_schema = get_db_schema(:634).get_db_schemaissues a real schema-parsing query (:901, “if db.supports_schema_parsing?”) and feeds the resulting columns intodef_column_accessor(*schema_hash.keys)(:918). Each column is emitted viaoverridable_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_methodson first access and caches schema viadb/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 anddef_column_accessorsynchronously. -
Generated for associations (
lib/sequel/model/associations.rb:2238-2285):one_to_many :posts(ormany_to_many):returns_array?is true, so the method-name table (:2243-2249) producesposts,posts_dataset,_add_post,add_post,_remove_post,remove_post,_remove_all_posts,remove_all_posts. Singularisation viasingularize(opts[:name])at:2243.many_to_one :account: singular branch (:2250-2253) adds_account=/account=+accountandaccount_dataset. No add/remove/clear unless writable.
All methods installed by
association_module_def, which callsmod.send(:define_method, name, &block); mod.send(:alias_method, name, name)(:2198-2202). The receiving module defaults tooverridable_methods_module. -
Static-expandability:
- DB-schema dependency. Column accessors only exist after a live
get_db_schema_arraycall. A static analyser must consume either user-supplied DDL, a database-issuedDESCRIBE, 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) runsapplyonce, thenextend ClassMethods,include InstanceMethods,dataset_extend(DatasetMethods), thenconfigureevery call. Plugins may add furtherdefine_method/class_evalfrom insideapply/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_moduleoverrides where methods land (:2192); singular / plural names derive fromsingularize. class_evalstrings with computed bodies are used indef_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_methodmints unique method names (plugins.rb:73-75, 92-138) — counter state at expansion time is unobservable, but the methods are private call-targets only.
- DB-schema dependency. Column accessors only exist after a live
-
Closest analogue — Hybrid: 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_manyare 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.sqlingestion). 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/configureblocks 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#schemadump, a user-pointedschema.rb-equivalent, or migration walking — and (b) a plugin-replay subsystem.
Redmine (ERB-built Ruby)
Section titled “Redmine (ERB-built Ruby)”1. Concrete sites
Section titled “1. Concrete sites”| # | Site (path:line) | Pattern |
|---|---|---|
| A | lib/redmine/views/labelled_form_builder.rb:25-33 | heredoc + interpolated literal list (field_helpers - blocklist + %w(date_selec)) |
| B | lib/plugins/acts_as_event/lib/acts_as_event.rb:48-62 | heredoc + literal %w(datetime title description author type) |
| C | app/models/setting.rb:333-350 | heredoc parameterised by setting name; names come from config/settings.yml + Redmine::Plugin.all |
| D | app/models/user.rb:30, 293-303 | eval('"' + f[:string] + '"') where f[:string] is a literal value in USER_FORMATS |
| E | app/models/webhook_payload.rb:71 | instance_eval(File.read(Rails.root.join(path)), path, 1) |
| F | lib/redmine/plugin.rb:69-77 | def_field — block-form class_eval do … define_method over a literal symbol list |
| G | lib/redmine/nested_set/traversing.rb:23-28 | block-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.all2. Pattern taxonomy
Section titled “2. Pattern taxonomy”| Pattern | Sites | Description |
|---|---|---|
| (a) static-text heredoc, no interpolation | none — every Redmine heredoc interpolates at least the method name | |
| (b) heredoc interpolating source-visible literals | A, B | |
| (b′) heredoc interpolating file-driven literals | C | |
(b″) literal-symbol-list block-form class_eval + define_method | F | |
(b‴) block-form class_eval (no string), reopening includer | G | |
| (c) eval of code loaded from an external file | E | |
| (d) eval of string concatenated with runtime data | none in the strict sense — D’s template strings are source-visible literals, just selected by runtime key |
3. Plugin loader
Section titled “3. Plugin loader”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(&) …endInside 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 … end — self stays the plugin instance,
@project_module is set as a side-channel for permission calls inside
the block.
4. Static-expandability per site
Section titled “4. Static-expandability per site”- A — expandable. Source-visible
%w(...)set arithmetic, except forfield_helperswhich comes fromActionView::Helpers::FormBuilder.field_helpers(an upstream constant). Resolve that list (stable per Rails version) and emit ~12def text_field/email_field/…stubs. - B — fully expandable. Five literal symbols → five
def event_datetime / event_title / event_description / event_author / event_typereturning the same expression. - C — expandable only if the analyzer reads
config/settings.yml(~70 setting keys) and walksRedmine::Plugin.register :id do settings :default => {...} endblocks. Eachnameproduces 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’sactivesupport-core-ext + project-side monkey-patch pre-evaluationmemory note describes. - D — appears intractable but is actually (b′):
USER_FORMATSis a source-visible constant whose:string/:initialsvalues are static template strings. A macro-expander could turneval('"' + f[:string] + '"')intoString∨ over the nine templates. Without that special case, emitDynamic[String]. - E — Pattern (c). Requires reading webhook payload
.rbtemplates at the configured path;selfis aWebhookPayloadinstance 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_methodwith literal symbol args. Trivially expandable: emit getter/setter pairs for each name (semantics differ fromattr_accessor). - G — block-form
class_evalreopening the includer’s scope. Not string-eval; standardincludedhook.
5. Closest analogue
Section titled “5. Closest analogue”- A, B, F, G → Lisp
defmacroover 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 onceUSER_FORMATSis recognised as the macro table. - E → PHPStan stub-file pattern (parse an external
.rbas if pasted at the call site underinstance_evalsemantics).
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.
dry-types
Section titled “dry-types”-
DSL
module Typesinclude Dry.Types() # imports Types::String, Types::Integer, ...endStrippedString = Dry::Types['string'].constructor { |s| s.strip }Email = Dry::Types['string'].constrained(format: /@/)NilableInt = Dry::Types['integer'].optional # Sum of nil | integer -
Mechanism —
Dry.Types(...)returns a freshDry::Types::Moduleinstance (lib/dry/types.rb:252-254);includeis the normal Ruby include becauseDry::Types::Module < ::Module.Module#initialize(lib/dry/types/module.rb:20-37) projects the flat registry keys ofDry::Types.container("strict.string","coercible.hash", …) into a nested constant tree viaregistry_tree(module.rb:74-84), then walks the tree withdefine_constantscallingmod.const_set(name, value)for each leaf (module.rb:109-124). The registry is populated once at gem load bylib/dry/types/core.rb: four loops iterateALL_PRIMITIVES/KERNEL_COERCIBLE/METHOD_COERCIBLE/NON_NIL(literal frozen Hashes atcore.rb:6-46) andregister("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 byTYPE_SPEC_REGEXand re-routed throughcontainer[type_id].of(self[member_id]). Composition operators live inDry::Types::Builder(lib/dry/types/builder.rb):|→Sum(:28),&→Intersection(:37),>→Implication(:46),.optional→nil | self(:53-62),.constrained→Constrained.new(:71-73). Method-call coercion functions are themselves built viamodule_eval(<<~RUBY, …)inlib/dry/types/constructor/function.rb:63-75— a heredoc interpolating the literal coercion method name. -
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 aDry::Types::Constrainedcarrier (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 ofcontainer.keys(module.rb:74-84). BuilderMethodsextended into the includer (module.rb:25+builder_methods.rb:11-14): module methodsArray(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.
- Constants on the includer:
-
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.rbat gem-load time. A plugin can ship a bundled registry mirroring those Hashes (same shape asrigor-devise’s mirror oflib/devise/modules.rb). - Each constant binds to a known carrier shape
(
Constrained<Nominal<Integer>>forTypes::Integer, etc.). Dry.Types(:strict, default: :coercible)cherry-picking andDry.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) andDry::Types["array<string>"](regex-parsed nested form) need extra resolver rules.- Edge cases:
.constructor { |x| … }accepts aProcwhose output type depends on the body — fallbackDynamic[T]. .optional/|/&/>/.constrainedcompose 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.
- 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
-
Closest analogue — PHPStan
DynamicMethodReturnTypeExtension(registry + symbol → type lookup) forDry::Types[...], plus a Tier-C-style synthetic-constant emit forDry.Types()constants. The latter is not a heredoc template (noclass_evalstring runs on the includer —const_setis used directly) but the shape is identical from a plugin author’s perspective: a fixed registry of names mapping to a fixed carrier shape. ATier-C-as-const_setdeclaration listing(constant_name, namespace, primitive, carrier_shape)rows captures it.
dry-schema
Section titled “dry-schema”-
DSL
UserSchema = Dry::Schema.Params dorequired(:email).filled(:string)required(:age).value(:integer, gt?: 0)optional(:tags).value(:array).each(:string)endUserSchema.(email: "a@b", age: 21) # => Dry::Schema::Result -
Mechanism —
Dry::Schema.Params(&)isdefine(processor_type: Params, &)(lib/dry/schema.rb:86-89);define(...)is literallyDSL.new(...).call(lib/dry/schema.rb:67-69).DSL.new(lib/dry/schema/dsl.rb:81-86) callssuper(Dry::Initializer) thendsl.instance_eval(&). The block runs as instance methods on theDry::Schema::DSLinstance —required/optional/key/before/after/arrayare real instance methods ofDSL(dsl.rb:144-188).required(:email)→key(:email, macro: Macros::Required)(dsl.rb:144-146) builds aMacros::Required, storesTypes::Anyas the placeholder type for:emailviaset_type(dsl.rb:176, 316-321), and pushes the macro onto@macros(dsl.rb:186-187)..filled(:string)isMacros::DSL#filled(lib/dry/schema/macros/dsl.rb:80-86); it callsextract_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 withschema_dsl.set_type(name, resolved_type)and aMacros::Filledis appended viaappend_macro(macros/dsl.rb:205-216).dsl.call(dsl.rb:195-207) is the terminal builder: it collectsparents.steps, buildskey_validator,key_coercer,value_coercer,rule_applier, thenprocessor_type.new(schema_dsl: self, steps: result_steps). The returned object IS the schema — aDry::Schema::Processorinstance 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 totrace— predicates are bare bareword symbols in user source. -
Generated — nothing is
define_method’d on the user’s class.UserSchemais a local binding to aDry::Schema::Paramsinstance. End products:- A
Dry::Schema::Processorinstance (processor.rb:15) holding@steps,@schema_dsl, an internalkey_map,key_coercer,value_coercer,rule_applier. Callable asschema.(input)returningDry::Schema::Result. - A
KeyMapenumerating[: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@definitionon the class (processor.rb:46-50) andUserSchema.newreturns the built processor (processor.rb:57-67).
- A
-
Static-expandability — mixed:
- Predicate names are literal Symbols.
required(:email).filled(:string)is fully literal;:stringresolves throughTypeRegistry→Dry::Types[…]. Registry namespace (:strictfordefine,:paramsforParams,:jsonforJSON) is determined by the call-site method name onDry::Schema. All source-visible. - The block’s return value is
Dry::Schema::Processor(or subclass) regardless of contents — trivially typeable. schema.(input)returnsDry::Schema::Resultwhose#to_hshape is the key map. The key set is statically extractable fromrequired(:key)/optional(:key)positions; per-key types from the corresponding.value(:type)/.filled(:type)/.maybe(:type)arguments. This is the same “AST recorder” shaperigor-factorybotuses at the definition side.- The block runs under
instance_evalon theDSLinstance (dsl.rb:83). The substrate’s Tier A applies: declareself : Dry::Schema::DSLand the bareword surface (required,optional,key,before,after,array). .filled { … }/.value { array? | str? }useDry::Logic::Operatorsandmethod_missing(macros/value.rb:115-121); block bodies are againinstance_exec’d on a fresh macroCore— Tier A nested.- Genuinely dynamic:
required(:name).value(SomeCustomType)whereSomeCustomTypeis aDry::Types::Typeinstance (sameresolve_typepath);array(:string).filled(min_size?: 2)predicate operator chains;Dry::Logicpredicate composition in blocks (array? | str?) needs operator-overload tracking.
- Predicate names are literal Symbols.
-
Closest analogue — Hybrid: 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. Arigor-dry-schemaplugin can synthesise: “the schema processor’s#call(input)returnsResult[T]whose#to_hhas shape{email: String, age: Integer, …}.” This needs Tier A (declare the block asinstance_evalonDry::Schema::DSL) plus a custom call-recorder walkingrequired/optional/filled/value/maybe/each/arrayinside 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.
dry-struct
Section titled “dry-struct”-
DSL
class Address < Dry::Structattribute :city, Types::Stringattribute :country, Types::String.optionalattribute? :postcode, Types::String # omittableattribute :details do # nested structattribute :building, Types::Stringendenda = 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"}} -
Mechanism —
Dry::StructextendsClassInterface(lib/dry/struct.rb:87); the inheriting class inheritsattribute/attribute?/attributes/transform_types/transform_keysas class methods.attribute(:city, Types::String)(lib/dry/struct/class_interface.rb:86-88) delegates toattributes(city: build_type(:city, Types::String)).attributes(new_schema)(class_interface.rb:173-189) does three things: (a) schema update —schema schema.schema(new_schema)replaces the class-levelschema(aDry::Types::Hash::Schema) with a new one augmented by the new key/type pairs (:177); (b) accessor synthesis —define_accessors(keys)(:179→:452-464); (c) inheritance propagation — for each subclass, recursively calld.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]endRUBYFalls back to
define_method(key) { @attributes[key] }for non-identifier-shaped names (thevalid_method_name?regex check at:468)..new(city: "Tokyo", …)(class_interface.rb:239-258) coerces via the class-levelschema.call_unsafe(attributes)thenload(attributes)→allocate+initialize(attributes)(:279-283). Nested-block form (attribute :details do …) routes throughStructBuilder#call(lib/dry/struct/struct_builder.rb:18-42), whichClass.new(parent_struct)+class_exec(&block)+const_set(:Details, new_type)on the parent class. -
Generated — for
class Address < Dry::Struct; attribute :city, Types::String; end:- Class-level state:
Address.schemais aDry::Types::Hash::Schemawhose:citykey maps toTypes::String.Address.attribute_namesreturns[:city](class_interface.rb:357-359). - Instance method
Address#city: synthesised by theclass_evalheredoc (class_interface.rb:455-459) — returns@attributes[:city]. Return type follows the attribute’sTypes::Stringcarrier ⇒String. - No writer —
Dry::Structis immutable; only readers exist (and#new(changeset)for copy-with-overrides at:202-211). Address#[](key)andAddress#to_hinherited fromDry::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 withrequired: false; accessor name is stillpostcode(?stripped at:174). - Nested-block form:
Address::Detailsisconst_set(struct_builder.rb:33), itself aDry::Structsubclass; the parent gets adetailsreader returningAddress::Details.
- Class-level state:
-
Static-expandability — the cleanest of the three:
- Attribute names are literal Symbols. The
class_evalheredoc atclass_interface.rb:455-459interpolates#{key}and#{key.inspect}. Direct fit for ADR-16 Tier C. - Attribute types are either
Dry::Types::Typeinstances (e.g.Types::String) or a String ("integer") routed throughDry::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 forattribute :x, Types::Integer.optionalknows the reader returnsInteger | nil. - Nested-block form
attribute :details do …mints a constantAddress::Detailswhose body itself contains moreattributecalls — Tier C must recurse (or compose Tier A’s instance-eval framing with Tier C’s emit). Const name followsInflector.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-formattribute :….- Edge cases:
attribute :name, SomeCustomTypewhereSomeCustomTypeis a project-local variable holding aType— needs flow-analysis to bind the carrier (regular rigor work, not substrate).
- Attribute names are literal Symbols. The
-
Closest analogue — ADR-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 frameworkclass_evals a heredoc interpolating that Symbol; the emit table is fixed. For dry-struct the emit table forattribute :city, Tis:Synthetic Return def cityinstance methodT#primitiveschemakey:cityTto_hrowcity: …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 freshStructBuilder-boundClass.new(parent); the outer callconst_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:8—require "dry/types"); the rule predicate inferrer subclasses::Dry::Types::PredicateInferrer(lib/dry/schema/predicate_inferrer.rb:6); the schema module’s ownTypesnamespace isinclude ::Dry.Types(lib/dry/schema/types.rb:11);TypeRegistryis a direct wrapper aroundDry::Types(lib/dry/schema/type_registry.rb:21-29). Every:string/:integer/:arrayinrequired(:x).filled(:string)resolves throughDry::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::ClassInterfaceincludesTypes::TypeandTypes::Builder(class_interface.rb:11-12);Dry::Struct.schemais itself aDry::Types::Hash::Schema(struct.rb:115-116);attribute :name, "integer"(String form) routes throughDry::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-typeswould ship: (a) the bundled name registry mirroringcore.rb’s ALL_PRIMITIVES / KERNEL_COERCIBLE / METHOD_COERCIBLE / NON_NIL Hashes; (b) a Tier-C-shaped constant emit forinclude Dry.Types(…); (c) a dynamic-return-type rule forDry::Types[<literal>]; (d) carrier-algebra handling for|,&,>,.optional,.constrained,.constructor. After this, any expression producing aDry::Types::Typecarrier is typed, regardless of whether it appears in dry-schema, dry-struct, or free-standing code.rigor-dry-schemastill needs its own plugin: the schema-graph recording walk insideDry::Schema.Params { … }(Tier A scope annotation + arequired/optional/value/filled/maybe/each/arrayrecorder), plus theProcessor#call(input) -> Result[T]typing whoseTderives from the recorded key map. It consumesrigor-dry-typesfor the per-key carrier resolution (:string→Types["strict.string"]).rigor-dry-structstill needs its own plugin: the Tier C heredoc emit perattribute :name, Tcall plus the nested-block composition that mints aConst_namesubclass. It consumesrigor-dry-typesfor the per-attributeTcarrier.
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.
Cross-library summary
Section titled “Cross-library summary”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 / subsystem | Expansion shape | Closest analogue | rigor today |
|---|---|---|---|
| Sinatra routes | block-IS-method via define_method(&block) | ”no expansion needed once self is typed” | no plugin |
| ActiveStorage attached | heredoc parameterised by literal symbol | PHPStan trait inlining | rigor-activestorage already expands |
dry-struct attribute :name, T | heredoc parameterised by literal symbol | PHPStan trait inlining (textbook Tier C) | no plugin |
| AASM | DSL block + literal-symbol state / event | trait inlining / statesman shape | no plugin (statesman is the precedent) |
| Redmine A/B/F/G | block-form class_eval + literal-symbol-list define_method | Lisp defmacro | no handling |
| Redmine C | heredoc parameterised by YAML/plugin-registry name | PHPStan per-class stubs (needs YAML reader) | no handling |
dry-types include Dry.Types() | bundled name registry + const_set emit | trait inlining (Tier-C-as-const_set) + dyn-return-type for Dry::Types[…] | no plugin |
| factory_bot | registry + return-type computed from literal symbol arg | PHPStan DynamicMethodReturnTypeExtension | rigor-factorybot |
| Devise model side | trait include sequence driven by bundled registry | PHPStan trait inlining + registry | no plugin |
| Devise routes / controllers | heredoc parameterised by mapping.singular | trait inlining (needs routes walker) | no plugin |
| Sequel associations | name-table emission from literal symbol | trait inlining (statesman-like) | no plugin |
dry-schema Dry::Schema.Params do … end | block instance_eval + literal-symbol recorder of required / optional / type spec | Tier A + AST recorder + dyn-return-type on Processor#call | no plugin |
| ActiveSupport::Concern | deferred class_eval of a block, target = includer | trait inlining + DSL re-targeting | partial (downstream walkers fire after re-targeting) |
| Redmine E | instance_eval(File.read(path)) | PHPStan stub-file pattern | no handling |
| Redmine D | eval('"' + literal_template + '"') | defmacro + template table | no handling |
| Sequel column accessors | needs DB schema oracle | PHPStan PDO-reflection extensions | no handling |
| GraphQL-Ruby | schema-graph recorder; no method emission | schema-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
defmacroover a literal list — handled by an AST-level expander with no runtime evaluation. Site C is the largest payoff but requires readingconfig/settings.yml+ plugininit.rbs; fits the “project-side monkey-patch pre-evaluation” memory note. Site E isinstance_eval(File.read(...))with a declaredself-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-graphqlwould need a schema-resolution pass that re-implements (or replays)Schema::Membertraversal. This matches rigor’s existing position of deferringrigor-graphqluntil 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 … endis 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, T→class_evalheredoc emit). dry-types is a bundled-registry shape (Tier-C-as-const_setplus a dyn-return-type rule forDry::Types[…]). dry-schema is Tier A (instance_evalonDry::Schema::DSL) plus a recorder walking the block to build akey → typemap. They compose via gem dependencies one-to-one:rigor-dry-typesis a shared dependency for the other two plugins but does not subsume them. Withoutrigor-dry-types, downstream per-attribute / per-key types degrade toDynamic[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.
What is NOT in this note
Section titled “What is NOT in this note”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_scoperesolver. - How
name_scopeinteracts with theinstance_eval-boundselffor 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/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.