Roundhouse

User guide · Rails coverage

Roundhouse does not implement Rails. It recognizes the Rails an app uses — the conventions, the DSL, the helpers — and lowers each recognized form to target-neutral IR that every emitter consumes. So "is X supported?" has two parts: does the analyzer recognize X, and does each target have a lowering and a runtime for it. This page is the feature-level answer as of this snapshot; the per-app answer is the tools:

Those are authoritative and current; this page is the map.

Two tiers

Coverage is proven by apps, not by feature lists. Two apps define the tiers.

The blog (fixtures/real-blog, the Rails 8 scaffold with articles, comments, nested routes, validations, Turbo Streams, Action Cable, Tailwind, JSON endpoints) is the shared DOM-equivalence fixture for every server target in scheduled full validation. Ordinary PRs run the Ruby floor plus selected target lanes; pushes to canonical main run Ruby plus Spinel. The extra-language matrix waits for the three-hour schedule or ci:full. See CI coverage. A passing target's comparison proves the blog's features on that target.

Campfire (Basecamp's chat product — file attachments with image variants, rich text, web push, bots and webhooks, full-text search, signed and encrypted cookies and sessions, Current, fragment caching, rate limiting, ~70 routes) is what the Ruby shape — running on CRuby, and compiled by Spinel — passes the same gate against, along with Campfire's own test suite and its cable broadcasts. What Campfire uses beyond the blog is supported on those two lanes, and reaches the others as their emitters and runtimes catch up.

Structural pattern matching

Ruby 3 case/in, predicate matches (value in pattern) and required matches (value => pattern) are ingested separately from case/when. The Ruby-family emitter preserves native patterns, guards, pins, captures, array/find/hash destructuring and rest bindings, including bare ** and **nil. CRuby emit-and-run tests cover dispatch, escaping bindings, partial bindings after failed guards, and mismatch exceptions; expression tests also check syntax round-trips and once-only pin evaluation.

Other language targets reject these constructs before emitting a project, including apparently simple literal/nil/binding patterns. Their existing case/when renderers do not consistently preserve Ruby ===, bindings, or NoMatchingPatternError; no portable subset is claimed yet. The Ruby-shaped Spinel output uses native syntax, not a separately implemented matching runtime. Compile/runtime support still depends on the pinned Spinel compiler. Deconstructed element types remain gradual where the analyzer cannot determine the protocol's result shape.

Local method visibility

Model and library/concern ingest preserve statically known public, protected, and private on local definitions, including singleton blocks and inline private def forms. Named changes must follow an unambiguous local definition; forward references, inherited-only names, dynamic names/conditional declarations, and visibility-sensitive redefinitions remain unsupported. Concern class-method carriers have their own lexical scope, distinct from the concern's own singletons. private_class_method/public_class_method inside singleton blocks or concern class-method carriers address a further singleton level and remain unsupported, rather than changing the flattened methods.

The retained singleton form of module_function is public even when the source instance method is private; extend self instead retains the source visibility. Bare instance-visibility markers end the module_function mode but not extend self. This does not add the separate private instance copy or copy/redefinition semantics to the existing one-method-per-name lowering. module_function inside singleton blocks or concern class-method carriers remains unsupported: its public copy belongs to the carrier, not the includer, so it must not make the includer's method public.

The common Ruby/Spinel emitter writes named visibility immediately after each definition, not sticky sections. A plain private does not affect def self.x; constructors remain implicitly private unless explicitly made public. CRuby emit-and-run tests prove wrapper calls, send, public_send, and respond_to?(name, include_private) for model, library, and concern methods. This is not a strict-target visibility/reflective dispatch compatibility claim, nor a Spinel runtime verification. Nonpublic model accessor macros that have not become local MethodDefs are still diagnosed rather than silently emitted as public. Existing model lowering's synthesized-name precedence is unchanged.

Thin builder-yielding form wrappers retain owner-local data: computations through generated callable bridges when the expression is frame-independent and defaults are literal. The bridge has required typed parameters, while the original private helper methods remain private. Model/URL/namespace and id/class syntax is not hidden behind bridges; executable defaults, captures and shared wrapper-local frames are outside this correction. CRuby regression tests cover helper-name shadowing, private dispatch and single evaluation; this is not a general wrapper-inlining or compiled Spinel compatibility claim.

Static concern method macros

Concern-provided class methods such as Writebook's positioned_within can specialize parameterless define_method blocks into ordinary model methods before inference. Required positional and required/optional keyword arguments must bind immutable Symbols. Named visibility applies only to methods defined in that invocation. Each includer gets its own bindings; the supplying include must precede the call.

This is not general metaprogramming support. Ambiguous providers, repeated/nested invocations, synthesized/inherited method collisions, overridden macro primitives, mutable captures, splats/destructuring, block parameters, constant references with unproven lexical binding, control flow, and effects outside definitions remain unsupported. Recognized but unrepresentable macros fail strict ingestion; survey mode records the gap and retains the original model body without partial expansion. Literal reflection is grounded only on public generated association/scope APIs without app-owned dispatcher/target/reader overrides. The shared Ruby/Spinel emission path then threads these calls through Relations.

CRuby emit-and-run tests prove the parent binding, filtering, ordering, self-exclusion and reflective privacy of the generated helpers, not the whole Positionable concern (locking/rebalancing/callbacks), strict-target execution or compiled Writebook compatibility. Writebook remains a diagnostic corpus until its independent framework and Spinel gaps close.

Active Record

Blog tier (all targets)Campfire tier (ruby, spinel)
AttributesFrom db/schema.rb (migrations as fallback), typed per column; id, timestamps, defaults, nullability+ enum, serialize/has_json columns, has_secure_token, has_secure_password (real bcrypt)
Associationsbelongs_to, has_many (with dependent:), has_one; association readers, builders, <assoc>_ids+ has_many :through, polymorphic, touch:, has_one_attached/has_many_attached, has_rich_text
Validationspresence, absence, length (min/max), numericality (bounds, only_integer), format, inclusion; errors, full_messages, valid?+ custom validate methods, conditional if:/unless:, uniqueness (scope:, case_sensitive:, allow_nil:/allow_blank:; a reported gap on the strict targets)
Callbacksafter_create_commit and the Turbo broadcasts_to family+ before_save/after_save, before_destroy, after_touch, STI-aware callback inheritance
Querieswhere (hash and string), order, limit, find, find_by, first/last, count, exists?, includes, pluck, named scopes — folded to SQL by the Arel builder+ joins, left_joins, group/count, select, distinct, or, none, find_or_create_by, insert_all, update_all, in_batches, sum; a live Relation for chains the builder can't fold
Persistencecreate, save, update, destroy, new/build+ destroy!, increment!, update_columns, transactions, dirty tracking predicates
STI—type column dispatch, subclass scopes, is_a? on records

SQL identifier boundary

Compiler-generated SQLite DDL, indexes, seeds and Arel/model CRUD quote physical identifiers without renaming them, including keywords, spaces, hyphens and embedded quotes. Emitted models keep table_name raw and provide the SQL spelling separately to Relation; ordinary filter/order chains on those models use it too. A dot in one physical name stays part of that name, not a new schema-qualified ingest feature. Quoting metadata does not widen the Ruby table-name declarations ingest can interpret.

This is not complete quoting of every SQL path. Association JOIN strings, nested dynamic qualifiers/column names and native adapter fallbacks remain gaps: accepted source can still generate invalid SQL there, without a new unsupported diagnostic. The compiler SQL dialect is SQLite, not PostgreSQL/MySQL. tests/sql_identifiers.rs executes synthetic SQLite and emitted CRuby cases; its other language checks prove DDL string preservation, including literal interpolation markers, not whole-project execution on every target. Native hand-written models still need SQL-ready identifiers; the Base metadata fallback does not dynamically quote arbitrary names.

The runtime typing ledger records one additional unresolved self-send in the isolated RBS probe and three additional gradual reads for raw RecordNotFound messages through Relation's existing untyped model handle. The strict fully-typed runtime gate and diagnostics are unchanged; this does not add generic class-object/Relation support to strict targets.

Action Controller

Blog tierCampfire tier
ActionsThe seven RESTful actions and any other; implicit render+ Rails 8.1.4 head(status, options = nil) on Ruby + Spinel (see below), send_file, rescue_from, rate_limit (to:/within:/by:/with:/only:/except:, counted in the app's cache as in Rails), invisible_captcha (only:/except:/prepend: → honeypot spam? on subtitle only; rotated/custom names unmodeled), pretender impersonates :scope when the controller defines a local current_<scope> (wraps it as true_<scope>, adds impersonate_ / stop_impersonating_; inherited-only Devise helpers stay unsupported)
Filtersbefore_action with only:/except:, ivar flow into views+ around_action, after_action, if:/unless: guards (symbol and lambda), skip_before_action, filters from concerns
Paramsparams.expect, params.require(...).permit(...), params[:id]; typed by the schema they're assigned to+ nested permits, arrays, params.merge, indifferent access; a missing resource answers 400; JSON bodies wrapped under the model name (wrap_parameters, on by default since load_defaults 7.0)
Responsesrender (template, partial, json:, status:), redirect_to (record, path, status:), respond_to with format.html/format.json, flash and flash.now+ expires_in, stale?/fresh_when (answered as always fresh — a deliberate divergence), cookies and cookies.signed/.permanent, session, helper_method, layout
Concernsincluded modules with included do filter blocks+ class_methods, concern-defined actions and helpers
Auth and request—Current attributes, authenticate_by, signed/global ids, has_secure_password sessions; request.headers ([], fetch, key?, case-insensitive HTTP and Rack names), request.authorization (including Rack and legacy authorization env keys), route-only request.path_parameters, and HTTP Token/Bearer and Basic auth helpers

The Spinel dispatcher forwards incoming request headers into the request's Rack env (HTTP_*, plus unprefixed CONTENT_TYPE and CONTENT_LENGTH). Token and Bearer schemes are matched case-insensitively. This covers the request/authentication surface, not application-specific authorization policy or unsupported route behavior.

ActionController::Head#head takes a positional status and optional options hash. Roundhouse supports registered status symbols, integer statuses, and nil (defaulting to 200); a Hash passed as the status and unknown status symbols raise. The special symbol keys :location and :content_type are removed from the options. Content-type symbols resolve through Roundhouse's built-in MIME registry (:json → application/json); string content types retain their media type with charset removed. Without an explicit type, negotiated formats also resolve through that registry (.xml → application/xml). The registry is fixed; app-specific Mime.register extensions are not supported. Other String/Symbol header names are normalized and their values stringified. A path-string location is resolved through url_for and stripped of header-injection controls, while a record in location: is lowered through its conventional singular route helper (@article → article_path(@article.id)); custom polymorphic routing, nested record routes, and custom to_param are not implied by this support claim. The response body is empty, and the runtime omits Content-Type for 1xx, 204, 205 and 304 statuses. Direct runtime tests cover 100, 199, 204, 205 and 304; emitted Rack and native HTTP probes verify the wire headers and empty body for 204, 205 and 304. Calling head after a render or redirect raises AbstractController::DoubleRenderError; the initial empty body alone does not count as a performed response.

These claims are verified for the Ruby output on CRuby and for the Spinel output by running the emitted app over native HTTP. They do not claim ActionController::Head parity on the other emit targets. Rails 8.1.4's implementation returns true; that is source behavior, not a return-value promise in the API prose.

Action View

Blog tierCampfire tier
TemplatesERB; partials (render "form", render @articles, render partial: with locals and collections); layouts; content_for/yield+ HAML is read by the analyzer; emit is ERB-only today
Helperslink_to, button_to, form_with and the form builder (label, text_field, text_area, submit, hidden_field), dom_id, pluralize, truncate, l/t with the app's locale files, csrf_meta_tags, stylesheet_link_tag, javascript_importmap_tags, turbo_stream_from, cache+ image_tag/asset_path with digests, tag.* builders, content_tag, sanitize (safe-list sanitizer port), turbo_frame_tag, the rest of the form builder (select, check_box, radio_button, button, url_field, email_field, password_field, file_field, rich_text_area, fields_for), hidden_field_tag, time_ago_in_words, number_to_human, url_for
JSONjbuilder views (json.extract!, json.array!, partials)+ as_json/to_json shapes on records and plain objects
TurboTurbo Streams broadcast on model commit, turbo_stream responses, frames+ custom stream targets and broadcast_*_later

The rest of the framework

ComponentStatus
Action CableEvery server target: /cable, turbo_stream_from subscriptions, model broadcasts. Campfire tier adds application channels with subscribed/unsubscribed, stream_for, and presence, and client actions (subscription.perform(action, data)) with Rails' rules for which methods are actions and which receive data.
Active JobCampfire tier: perform_later runs on an in-process queue in the app; ActiveJob::TestHelper assertions in the tests. On the Ruby-family lanes a job is queued as a serialized ActiveJob payload (GlobalID records, primitives, Symbols, Times and Arrays of them), honoring queue_as, discard_on ActiveJob::DeserializationError and enqueue_after_transaction_commit; a job with other argument types keeps a closure, with a warning. No external queue adapter.
Active StorageCampfire tier: blobs and attachments, the disk service, the engine's routes (redirect and representation), variants via libvips on Spinel. No cloud services.
Action TextCampfire tier: has_rich_text, the safe-list sanitizer, attachment rendering.
Action MailerRuby tier: mailer classes, mail(...), deliver_now/deliver_later — delivery appends to ActionMailer::Base.deliveries (Rails' :test method, which the emitted tests assert against). No SMTP.
Routingresources/resource (nested, only:/except:, member/collection), namespace/scope, root, get/post/…, constraints, format suffixes, Active Storage's mounted engine, Devise's devise_for (static default four: session/registration/password/confirmation + controllers: overrides for those mappings only — not model-module-driven), devise_scope as a path-transparent wrapper, and with_options (its options deep-merged into each block-free route call inside, the call's own keys winning; a child with its own block, a block parameter, or two hash values under one key is declined). Not: Devise authenticate/authenticated/unauthenticated route guards (unsupported because Warden constraints are not enforced; strict ingest fails, while survey mode records a gap and omits their route blocks), concern, direct (a custom URL helper with an arbitrary body — dropped), mount of any other engine, Doorkeeper's DSL, Devise OmniAuth callback routes, Devise skip:/only:/path: options, model-driven module narrowing.
Configurationconfig.x.*, initializers that define constants or mix modules into models, Rails.application.config reads, the app's inflections. Not: Rails.application.credentials.
CachingFragment caching (cache in views, keyed by record) and Rails.cache.fetch, in-process.
GemsThe census names what is modeled. Modeled today: bcrypt, devise (route helpers + static devise_for; no Warden route guards), image_processing/ruby-vips (Spinel), inertia_rails (render inertia: with a literal component and props, inertia_share, optional and deferred props written inline as InertiaRails.optional { } or InertiaRails.defer { }, partial reloads, the version check, inertia: { errors: } redirects; no SSR, no merge/scroll/once props, no dot-path partial keys, no optional or deferred wrapper returned from a helper method), invisible_captcha, pretender, representable (Representable::Decorator with include Representable::JSON: property / collection, rendered as JSON; Roar is not), rqrcode, useragent, web-push, net-http-persistent, concurrent-ruby's thread pool, importmap-rails, turbo-rails, stimulus-rails, tailwindcss-rails, jbuilder, propshaft. Everything else in a Gemfile is either infrastructure (never enters the analysis) or unknown.

What is not lowered, anywhere

The analyzer is whole-program and static. Whatever it cannot see through at compile time it cannot type, and whatever it cannot type it does not emit:

Each of these is reported by name and location in the survey report, so the answer for a given app is a list, not a guess. That includes a class-body macro in a controller that roundhouse does not recognize (Lobsters' caches_page, say): it is listed as a survey gap rather than dropped in silence, because its effect — a guard, a filter, a header — would otherwise vanish from the output with no trace.

Deliberate divergences

Some Rails behaviors are reproduced differently on purpose, because the Rails behavior depends on a runtime facility the targets don't have or because it is an accident of implementation. Each is a decision, recorded with its reasoning in the architecture docs: docs/pipeline/runtime.md §Deliberate divergences from Rails. The ones a user is most likely to meet: an unsaved record's id is 0, not nil; attribute writers do not type-cast (the column type is enforced at the boundary instead); conditional GET always answers fresh; a Turbo stream name is signed only on the Ruby-family targets (elsewhere it carries an --unsigned placeholder); the query cache replays small results only; increment! is a read-modify-write.

Anything not in that section that differs from Rails is a bug, and the compare oracle is how to demonstrate it.

Security posture

CSRF is verified on the ruby family — the CRuby and Spinel lanes, which is where Campfire deploys. Rails' load_defaults 5.2+ implicit protect_from_forgery with: :exception heads every ActionController::Base chain; a written macro re-registers the same filter (with its only: / except: / if: / unless:); skip_forgery_protection removes it. A non-GET request must carry the session's masked token in the authenticity_token param or the X-CSRF-Token header, and a present Origin must name the request's own host; otherwise the answer is Rails' 422. The emitted test harness turns the check off, as a generated config/environments/test.rb does.

What differs from Rails, and why:

The strict targets issue no token (form_authenticity_token is empty there) and check none: an app emitted for them is not protected against cross-site forgery. Put an unprotected app behind something you trust.

This page is docs/guide/rails-coverage.md in the repository; edits are welcome there.