Skip to content

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.require fails or is undesirable.
    • DSL surfaces Rigor’s plugins already understand statically (rigor-activerecord reads db/schema.rb, rigor-routes reads config/routes.rb, etc.).
    • RBS::Extended precision (refinements, narrowed types, flow facts) that Tapioca’s runtime-reflection path cannot capture.
  • 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.

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”).

DimensionBoth Rigor and Tapioca
Target domainSorbet’s blind spots — DSL-generated methods, gem internals, metaprogramming
Plugin / compiler architecturePer-DSL extension; not hard-coded into core
Rails awarenessFirst-class ActiveRecord / ActionPack / ActiveJob support
User extensibilityCustom compilers / plugins for non-standard DSLs
RBI as a meeting pointTapioca writes; Rigor reads (rigor-sorbet slice 4)
Sorbet ecosystem alignmentBoth designed to make Sorbet useful in real projects

Execution model — the deepest difference

Section titled “Execution model — the deepest difference”
TapiocaRigor
ApproachLoads 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 corelib/tapioca/runtime/reflection.rb — wraps Kernel.instance_method(:class) etc. via bind_call for safe reflection.Plugin contractflow_contribution_for(call_node:, scope:), diagnostics_for_file(path:, scope:, root:).
Plugin authoringReflection-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.

  • Tapioca.rbi files 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.

  • 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.
  • 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 in docs/design/20260508-rails-plugins-roadmap.md. Tapioca is years ahead in coverage; this is honest.
  • Tapioca: trusts the application to be loadable. If a gem’s initialize blows up or a Rails initializer crashes, Tapioca crashes.
  • Rigor: never executes application code. Adversarial / unfamiliar codebases stay safe.
  • 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.
  • Tapioca compiler: invoked once per tapioca dsl run, 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.

PHP’s static-typing ecosystem split the same way years ago:

CampPHPRuby
Runtime-introspection + file generationbarryvdh/laravel-ide-helperTapioca
Static-extension (analyser-time plugin API)PHPStan extensions / Psalm pluginsRigor

The mapping holds tightly:

  • php artisan ide-helper:generatetapioca gem
  • php artisan ide-helper:modelstapioca dsl
  • _ide_helper.php / _ide_helper_models.phpsorbet/rbi/{gems,dsl}/*.rbi
  • phpstan-stubs (community-supplied stub files) ≈ rbi-central annotations

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.

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.

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 / *_url helpers from config/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.

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-string next 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-stringString). Sorbet sees the erased form; the precision is preserved only on the Rigor side via the RBS::Extended annotation.

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.

  • 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_type need careful approximation; Sorbet’s T.untyped semantics differ from RBS’s untyped (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_method over a runtime-computed name, class_eval of a string, modules loaded conditionally through if Rails.env.production?). Tapioca catches these; Rigor’s RBI emit does not. Documentation should be explicit.

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_eval of a computed string, method_missing over 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 uses severity_profile for analogous filtering, but the per-file-mode model is Sorbet’s).

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.

A future ADR (ADR-12, possibly) would fix the design. Likely structure:

  1. rigor rbi-emit CLI command — analogous to tapioca gem / tapioca dsl. Walks paths:, runs inference, emits sorbet/rbi/rigor/<file>.rbi.
  2. Plugin opt-in produces_rbi: declaration — plugins that have RBI-shaped facts to contribute declare via the manifest; the runner aggregates.
  3. RBI-direction translator — inverse of rigor-sorbet’s input-side translation. Lives in core (analogous to lib/rigor/rbs_extended.rb’s erasure side).
  4. RBS::Extended comment-preservation — emit # @rigor:… annotations next to method declarations so Rigor → Tapioca-format RBI → Rigor round-trip preserves refinements.
  5. Composition with rigor-sorbet — emitted RBIs are themselves valid input to rigor-sorbet slice 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).

DimensionTapiocaRigor
Execution modelRuntime introspection (require + reflect)Static AST analysis (Prism, no execution)
OutputRBI files (sorbet/rbi/**/*.rbi)Diagnostics (rigor check); future: RBI emit
Target consumerSorbet’s srb tcEnd user (CLI / editor)
Plugin authoringReflection-driven (short, needs runtime)AST-driven (longer, no runtime deps)
Trust postureTrusts code to runNever executes code
RBS supportNone (Sorbet-RBI only)Native (RBS is canonical)
RBI supportNative outputInput via rigor-sorbet; future: native output
Built-in DSL coverage39 compilers (mature)7 examples + Rails roadmap (early)
Gem dep handlingtapioca gem auto-generates RBIsRBS preferred; ADR-10 opt-in source walk
Refinement precisionRBS::Extended not supportedFirst-class; round-trips through Rigor
Ecosystem ageProduction at Shopify, years of iterationv0.1.x preview
Sandboxed envsCannot run (needs Bundler.require)Can run (no execution)

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 or RBS::Extended precision Tapioca’s runtime path cannot supply.

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