Rigor and Tapioca — Comparison and Strategy
Status: notes, 2026-05-09. Captures the design comparison
surfaced during the rigor-sorbet (ADR-11) authoring work
and proposes RBI-emit mode as Rigor’s next ecosystem-
facing surface — augmenting Tapioca’s coverage rather
than replacing it.
This document is informational. The binding source for the plugin contract is ADR-2; the binding source for the Sorbet-input plugin is ADR-11.
- Rigor and Tapioca share the same problem domain —
Sorbet’s blind spots (DSL-generated methods, gem
internals, metaprogramming-derived APIs) — but solve it
from opposite ends:
- Tapioca: load the app at runtime, reflect, emit RBI
- Rigor: parse statically, analyse, emit diagnostics
- Rigor does NOT plan to replace Tapioca. The two tools are complementary; a project using both gets the union of their coverage.
- Strategic opportunity: extend Rigor with an
RBI-emit mode so Rigor’s static inferences can flow into
the same
sorbet/rbi/tree Tapioca populates, augmenting rather than competing with Tapioca’s runtime-introspection output. - Where Rigor’s RBI emit is uniquely valuable:
- Sandboxed / unloadable codebases where
Bundler.requirefails or is undesirable. - DSL surfaces Rigor’s plugins already understand
statically (
rigor-activerecordreadsdb/schema.rb,rigor-routesreadsconfig/routes.rb, etc.). RBS::Extendedprecision (refinements, narrowed types, flow facts) that Tapioca’s runtime-reflection path cannot capture.
- Sandboxed / unloadable codebases where
- Where Tapioca remains the better fit: broad DSL
coverage today (39 built-in compilers), runtime-only
facts (
define_method-generated methods that don’t appear in source), large-scale Rails monorepos with established Tapioca pipelines.
Background
Section titled “Background”Both tools target the same gap: a static type checker (Sorbet, Rigor) cannot see methods generated by metaprogramming, runtime-loaded constants, gem internals without source available, or DSL macros that synthesise classes / methods at load time. The mainstream solutions:
- Tapioca (Shopify, since 2021) — generates
sorbet/rbi/{gems,annotations,dsl,shims}/by loading the application at runtime and reflecting on every defined class / method. - Rigor (since 2026) — provides a plugin extension API
(ADR-2) that lets per-DSL plugins (
rigor-activerecord,rigor-routes, …) read the project’s source and contribute method signatures + flow facts to the analyser, without ever executing application code.
The two arrived at the same problem space from different ecosystem assumptions: Tapioca assumes the application is already runnable (Rails); Rigor assumes a static-only posture (per ADR-2 § “Plugin Trust and I/O Policy”).
Common ground
Section titled “Common ground”| Dimension | Both Rigor and Tapioca |
|---|---|
| Target domain | Sorbet’s blind spots — DSL-generated methods, gem internals, metaprogramming |
| Plugin / compiler architecture | Per-DSL extension; not hard-coded into core |
| Rails awareness | First-class ActiveRecord / ActionPack / ActiveJob support |
| User extensibility | Custom compilers / plugins for non-standard DSLs |
| RBI as a meeting point | Tapioca writes; Rigor reads (rigor-sorbet slice 4) |
| Sorbet ecosystem alignment | Both designed to make Sorbet useful in real projects |
Fundamental differences
Section titled “Fundamental differences”Execution model — the deepest difference
Section titled “Execution model — the deepest difference”| Tapioca | Rigor | |
|---|---|---|
| Approach | Loads the application (Bundler.require, require "config/application") and reflects via Ruby’s runtime API (Module#instance_methods, Class#ancestors, etc.). | Never executes application code (ADR-2 § “Plugin Trust”). Pure Prism AST walking. |
| Tapioca core | lib/tapioca/runtime/reflection.rb — wraps Kernel.instance_method(:class) etc. via bind_call for safe reflection. | Plugin contract — flow_contribution_for(call_node:, scope:), diagnostics_for_file(path:, scope:, root:). |
| Plugin authoring | Reflection-driven (short, but needs the gem to load). | AST-driven (more code, but no runtime deps on the analysed code). |
This single difference drives every other design divergence.
Output format
Section titled “Output format”- Tapioca →
.rbifiles on disk (sorbet/rbi/gems/<gem>@<version>.rbi,sorbet/rbi/dsl/<class>.rbi, …). Tapioca is a code generator. - Rigor → diagnostics on stdout (
rigor check), optionally JSON for CI baselines. Rigor is an analyser.
Tapioca’s output is consumed by Sorbet’s srb tc. Rigor’s
output is consumed directly by the user / their editor.
Coupling to Sorbet
Section titled “Coupling to Sorbet”- Tapioca: tightly coupled. RBI is its only output; Sorbet is its only consumer. Tapioca exists for Sorbet.
- Rigor: standalone. RBS (the Ruby team’s official type
language) is the canonical input; Sorbet support is a
plugin (
rigor-sorbet, ADR-11) that translates Sorbet’s vocabulary into Rigor’s RBS-superset internal carriers at the plugin boundary.
DSL coverage breadth
Section titled “DSL coverage breadth”- Tapioca: 39 built-in DSL compilers (
AASM,ActionMailer,ActiveRecord*family,FrozenRecord,GraphQL,IdentityCache,JsonApiClient,Kredis,Protobuf,SidekiqWorker,SmartProperties,StateMachines,UrlHelpers, …). Production at Shopify; years of iteration. - Rigor: 7 worked example plugins (
lisp-eval,pattern,units,statesman,deprecations,routes,activerecord) plus 1 ecosystem adapter (rigor-sorbet, ADR-11). Rails plugin family roadmapped indocs/design/20260508-rails-plugins-roadmap.md. Tapioca is years ahead in coverage; this is honest.
Trust model
Section titled “Trust model”- Tapioca: trusts the application to be loadable. If a
gem’s
initializeblows up or a Rails initializer crashes, Tapioca crashes. - Rigor: never executes application code. Adversarial / unfamiliar codebases stay safe.
File-format direction
Section titled “File-format direction”- Tapioca → RBI (Sorbet’s format)
- Rigor → RBS (Ruby team’s format) on export, with
RBS::Extended%a{rigor:v1:…}comment annotations for refinements RBS can’t natively spell.
Plugin lifecycle
Section titled “Plugin lifecycle”- Tapioca compiler: invoked once per
tapioca dslrun, writes a file. No per-call-site logic. - Rigor plugin: invoked per call site
(
flow_contribution_for) and per file (diagnostics_for_file). Continuous interaction with the analyser.
The PHP analogy (for context)
Section titled “The PHP analogy (for context)”PHP’s static-typing ecosystem split the same way years ago:
| Camp | PHP | Ruby |
|---|---|---|
| Runtime-introspection + file generation | barryvdh/laravel-ide-helper | Tapioca |
| Static-extension (analyser-time plugin API) | PHPStan extensions / Psalm plugins | Rigor |
The mapping holds tightly:
php artisan ide-helper:generate≈tapioca gemphp artisan ide-helper:models≈tapioca dsl_ide_helper.php/_ide_helper_models.php≈sorbet/rbi/{gems,dsl}/*.rbiphpstan-stubs(community-supplied stub files) ≈rbi-centralannotations
Rigor’s plugin contract is explicitly modelled on PHPStan (ADR-2 § “Context”: “PHPStan is the strongest reference point for this part of the design”). PHP allows both flavours to coexist; Rigor brings the PHPStan-style static-extension flavour to Ruby alongside Tapioca’s existing runtime-introspection flavour.
Where Rigor could strengthen RBI output
Section titled “Where Rigor could strengthen RBI output”Rigor today emits diagnostics, not RBI. Adding an
RBI-emit mode would let Rigor’s static inferences flow
into the same sorbet/rbi/ tree Tapioca populates. Three
distinct value propositions:
1. RBI from Rigor’s static inference (no app load required)
Section titled “1. RBI from Rigor’s static inference (no app load required)”For projects that can’t or won’t run Bundler.require:
- Sandboxed CI environments (no network, no gem install).
- Adversarial / partially-trusted gem source.
- Pre-load-time CI passes (lint before integration tests).
- Performance-sensitive paths (Tapioca’s full Rails boot is multi-second; Rigor’s static walk is sub-second on the same code).
Rigor emits RBI for whatever it can prove from the source
alone — class definitions, method signatures (from
RBS/RBS::Inline/inferred returns), constant types. Won’t
match Tapioca’s coverage on heavy metaprogramming, but
covers enough for the “smoke-test before commit” workflow.
2. DSL-aware RBI from Rigor plugins
Section titled “2. DSL-aware RBI from Rigor plugins”Each Rigor plugin that understands a DSL has facts a static walker can record:
rigor-activerecord: model class names, schema-derived attribute methods (already discovered for diagnostics — emitting RBI is one extra step).rigor-routes:*_path/*_urlhelpers fromconfig/routes.rb.rigor-rails-i18n(planned):t('key.path')keys.rigor-actionmailer(planned): mailer methods.
These plugins already parse the DSL source statically; adding an RBI-emit pass per plugin would make Rigor a viable Tapioca-DSL-compiler alternative for projects that prefer not to load Rails.
3. RBS::Extended precision in RBI
Section titled “3. RBS::Extended precision in RBI”Rigor’s internal types can carry refinement precision RBS
(and therefore RBI) cannot natively spell —
%a{rigor:v1:return: non-empty-string} is Rigor-only. When
emitting RBI:
- Strict types that do fit RBI (a non-empty list of literals, a tuple shape) round-trip directly.
- Refinements that don’t fit get either:
- Comment-form preservation: emit
# @rigor:return: non-empty-stringnext to the RBI method declaration. Sorbet ignores it; Rigor reads it back if the RBI is later consumed. - Conservative erasure: per ADR-1 §
“rbs-erasure.md”,
refinements erase to the broadest RBS form (e.g.
non-empty-string→String). Sorbet sees the erased form; the precision is preserved only on the Rigor side via theRBS::Extendedannotation.
- Comment-form preservation: emit
This gives projects already on Tapioca a way to layer
Rigor-derived precision on top of Tapioca-generated RBI,
delivered as a sorbet/rbi/rigor/ directory or as a
Tapioca-shim-style overlay.
Caveats
Section titled “Caveats”- Sorbet’s RBI vocabulary differs from RBS. Rigor would
need an RBI-direction translator (the inverse of
rigor-sorbet’s input-side translation in ADR-11 slice 3). The hard parts:T::Class[T]/T.attached_class/T.self_typeneed careful approximation; Sorbet’sT.untypedsemantics differ from RBS’suntyped(gradual vs lossy). - ADR-1 invariants apply. Rigor → RBI is a third leg
alongside Rigor → RBS (
rbs-erasure.md) and RBS → Rigor (lossless). The new leg needs its own normative document and round-trip rules. - Coverage match is honest. Rigor’s static walk
cannot see runtime-only definitions
(
define_methodover a runtime-computed name,class_evalof a string, modules loaded conditionally throughif Rails.env.production?). Tapioca catches these; Rigor’s RBI emit does not. Documentation should be explicit.
Where Tapioca remains the better fit
Section titled “Where Tapioca remains the better fit”Honest acknowledgement: Tapioca isn’t going away, and Rigor’s RBI-emit ambitions don’t change that.
- Heavy metaprogramming. Methods generated at
application boot via
define_method,class_evalof a computed string,method_missingover a runtime registry. Static analysis is fundamentally limited here; runtime introspection is the right tool. - Wide DSL coverage today. 39 built-in compilers covering most of the popular Rails / Shopify-stack ecosystem. Rigor would need years of plugin authoring to match.
- Established Tapioca pipelines. Large monorepos with CI integrations, custom compilers, shim-management workflows. The cost of switching outweighs the benefit unless Rigor offers something Tapioca fundamentally cannot.
- Sorbet-native typing strictness.
# typed: strict/# typed: strong— these are Sorbet-static features that Rigor doesn’t replicate (Rigor usesseverity_profilefor analogous filtering, but the per-file-mode model is Sorbet’s).
Coexistence — recommended
Section titled “Coexistence — recommended”A project using both tools is the strongest configuration:
sorbet/rbi/├── gems/ ← Tapioca (runtime reflection on installed gems)├── annotations/ ← Tapioca (rbi-central community annotations)├── dsl/ ← Tapioca (Rails DSL runtime introspection)├── shims/ ← hand-written overrides└── rigor/ ← Rigor (static + RBS::Extended precision overlays)Rigor reads the entire tree via rigor-sorbet slice 4. The
plugin’s catalog is shared between project source .rb
sigs and RBI sigs, so projects that already have
Tapioca-generated RBIs get free Rigor coverage on the
gem and DSL surface.
The rigor/ overlay is where Rigor’s static inferences
land — refinement-bearing sigs the user wants Rigor to
honour but doesn’t want Tapioca’s runtime path to overwrite.
Implementation sketch
Section titled “Implementation sketch”A future ADR (ADR-12, possibly) would fix the design. Likely structure:
rigor rbi-emitCLI command — analogous totapioca gem/tapioca dsl. Walkspaths:, runs inference, emitssorbet/rbi/rigor/<file>.rbi.- Plugin opt-in
produces_rbi:declaration — plugins that have RBI-shaped facts to contribute declare via the manifest; the runner aggregates. - RBI-direction translator — inverse of
rigor-sorbet’s input-side translation. Lives in core (analogous tolib/rigor/rbs_extended.rb’s erasure side). RBS::Extendedcomment-preservation — emit# @rigor:…annotations next to method declarations so Rigor → Tapioca-format RBI → Rigor round-trip preserves refinements.- Composition with
rigor-sorbet— emitted RBIs are themselves valid input torigor-sorbetslice 4. Round- trip test: emit, re-read, re-emit; the second emission must match the first.
The order of slices mirrors ADR-11’s pattern: emit
infrastructure first (slice 1), per-plugin RBI contributions
next (slices 2-N), RBS::Extended precision last
(slice ≥N+1).
Comparison summary
Section titled “Comparison summary”| Dimension | Tapioca | Rigor |
|---|---|---|
| Execution model | Runtime introspection (require + reflect) | Static AST analysis (Prism, no execution) |
| Output | RBI files (sorbet/rbi/**/*.rbi) | Diagnostics (rigor check); future: RBI emit |
| Target consumer | Sorbet’s srb tc | End user (CLI / editor) |
| Plugin authoring | Reflection-driven (short, needs runtime) | AST-driven (longer, no runtime deps) |
| Trust posture | Trusts code to run | Never executes code |
| RBS support | None (Sorbet-RBI only) | Native (RBS is canonical) |
| RBI support | Native output | Input via rigor-sorbet; future: native output |
| Built-in DSL coverage | 39 compilers (mature) | 7 examples + Rails roadmap (early) |
| Gem dep handling | tapioca gem auto-generates RBIs | RBS preferred; ADR-10 opt-in source walk |
| Refinement precision | RBS::Extended not supported | First-class; round-trips through Rigor |
| Ecosystem age | Production at Shopify, years of iteration | v0.1.x preview |
| Sandboxed envs | Cannot run (needs Bundler.require) | Can run (no execution) |
One-line summary
Section titled “One-line summary”Tapioca writes RBI by running the app; Rigor reads RBS (and RBI via
rigor-sorbet) by parsing the source. They address the same problem from opposite ends. Rigor’s next-tier ambition is to emit RBI alongside diagnostics, not to replace Tapioca but to complement it for projects that need static-path coverage orRBS::Extendedprecision Tapioca’s runtime path cannot supply.
See also
Section titled “See also”- ADR-2 — Extension API Strategy — the normative plugin contract.
- ADR-11 — Sorbet input as a plugin adapter — the input-side translation that makes coexistence work.
- Rails Plugins Roadmap — the catalogue of DSL-aware plugins Rigor is building.
references/tapioca— the vendored Tapioca source we read against.
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.