Project

rigortype

0.0
No release in over 3 years
Rigor is a CLI-first static analyzer for Ruby applications that prioritizes type inference, clean application code, and zero runtime dependencies.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 13.0, < 15.0
>= 3.13, < 4.0
>= 1.70, < 2.0
>= 0.6, < 1.0
>= 3.0, < 4.0

Runtime

>= 1.0, < 2.0
>= 3.0, < 5.0
 Project Readme

Rigor

Gem Version GitHub License Ask DeepWiki

Inference-first static analysis for Ruby. Add Rigor to your Gemfile and run rigor check over your code — no annotations, no runtime dependency on the analyzer, no DSL.

Rigor parses Ruby with Prism, runs a flow-sensitive type-inference engine over each file, consults RBS signatures and the project's own sig/ directory for any class it can find, and reports a small but trustworthy catalogue of bugs (undefined methods on typed receivers, wrong positional arity, provable Integer / 0, …).

The differentiator is a richer type vocabulary than ordinary RBS expresses. Rigor reasons about what values an expression actually produces — literal values, integer ranges, refinement-type carriers, per-position tuple / hash shapes — not just which class an object belongs to. See Beyond Integer and String for the full type-model story; the short pitch is below.

When you want tighter types than RBS expresses, refine them through the RBS::Extended annotation surface — rigor:v1:return: / rigor:v1:param: / rigor:v1:assert directives accept the imported-built-in refinement names (non-empty-string, positive-int, non-empty-array[Integer], int<5, 10>, literal-string, non-lowercase-string, …) without changing the underlying RBS.

Installation

Add the gem to your application's Gemfile (development group is typical — Rigor is a static analyzer, not a runtime dependency):

group :development do
  gem "rigortype", require: false
end

Install:

bundle install

Or, for a one-off install outside Bundler:

gem install rigortype

The gem ships an executable named rigor (gem name is rigortype because rigor was already taken on RubyGems).

Ruby version. The gemspec requires >= 4.0.0, < 4.1.

Quick start

Drop into your project root and run the canonical commands:

# Diagnose unknown methods, wrong-arity calls, and other
# rule-driven bugs across `lib/`.
bundle exec rigor check lib

# Drop a starter .rigor.yml into the project root.
bundle exec rigor init

# Print the inferred type at a precise FILE:LINE:COL position.
bundle exec rigor type-of lib/foo.rb:10:5

# Report Scope#type_of coverage across a tree (handy when
# diagnosing why a particular call site reads as `untyped`).
bundle exec rigor type-scan lib

# Emit RBS skeletons from inference results — review with
# `--diff`, write to `sig/` with `--write`. ADR-14 sig-gen.
bundle exec rigor sig-gen --print lib/foo.rb
bundle exec rigor sig-gen --diff  lib/foo.rb
bundle exec rigor sig-gen --write lib/foo.rb

Sample output

$ cat /tmp/demo.rb
"hello".no_such_method        # undefined method
[1, 2, 3].rotate(1, 2)        # wrong number of arguments

$ bundle exec rigor check /tmp/demo.rb
/tmp/demo.rb:1:9: error: undefined method `no_such_method' for "hello"
/tmp/demo.rb:2:11: error: wrong number of arguments to `rotate' on Array (given 2, expected 0..1)

The rule catalogue is deliberately conservative: a diagnostic fires only when the receiver type is statically known and the method set on that class is enumerable through RBS or in-source def / define_method discovery. Implicit- self calls, dynamic receivers, and constant-decl alias classes (e.g. YAMLPsych) are skipped to avoid false positives.

Faster runs through the cache

Rigor caches expensive RBS work (the loaded RBS::Environment, constant-type translation, class hierarchy, type-parameter names, known-class set) under .rigor/cache/ so the second rigor check is significantly faster than the first. The cache is keyed by your project's .rbs file digests + the locked rbs gem version, so a signature change or a gem upgrade invalidates exactly what it should.

# Inspect what is cached on disk and what this run did.
bundle exec rigor check --cache-stats lib

# Wipe the cache (do this if you suspect staleness).
bundle exec rigor check --clear-cache lib

# Run with caching disabled.
bundle exec rigor check --no-cache lib

Add .rigor/ to your .gitignore — the cache is per-checkout and contains nothing reproducible to share.

Beyond Integer and String: Rigor's richer type vocabulary

A vanilla static checker answers "what class is this object?" Rigor answers a much narrower question: "what subset of values can this expression actually produce?" That distinction is the whole point of Rigor — types like Integer and String describe classes, but real-world code carries far more structure (a count that's always non-negative, a name that's never empty, a flag that's one of three Symbols). Rigor reasons about that structure out of the box, without you writing a single annotation.

The carrier zoo

Carrier What it records Example
Literal types (Type::Constant) A single Ruby value Constant<42>, Constant<"hello">, Constant<:foo>
Integer ranges (Type::IntegerRange) A bounded integer interval int<a, b> positive-int = int<1, max>, int<5, 10>
Refinement types — split into two halves: Type::Difference and Type::Refined A base nominal minus a single value, or a base nominal restricted by a predicate non-empty-string = String - "", lowercase-string = String & lowercase?, literal-string
Intersection (Type::Intersection) Composition of multiple refinements non-empty-lowercase-string = non-empty-string ∩ lowercase-string
Tuple / HashShape Heterogeneous arrays / known-key hashes that carry per-position / per-key types [1, "two", :three] types as Tuple[Constant<1>, Constant<"two">, Constant<:three>]; {name: "Alice", age: 30} as HashShape{name: Constant<"Alice">, age: Constant<30>}
Union (Type::Union) "One of these literal values" — finite enums Rigor can enumerate Constant<:zero> | Constant<:small> | Constant<:large>
Method binding (Type::BoundMethod) The receiver / method-name pair Object#method(:sym) produces, so .call / .() / [] recover the precise backing dispatch "1".method(:to_i).call resolves to Constant<1> instead of untyped
Dynamic[T] The gradual carrier — wraps a static facet with a "could be anything" admission Dynamic[Top] is the conservative fallback Rigor uses when it cannot prove a narrower type

Each refinement / range / literal carrier erases to its base class for ordinary RBS interop, so importing Rigor is a strictly additive change: a method whose RBS sig says -> String keeps that contract, and Rigor's narrower inference just sits on top.

What this buys you in practice

# Rigor doesn't just see "Integer", it sees "non-negative integer".
n = ARGV.size                  # int<0, max>  (non-negative-int)
m = n + 1                      # int<1, max>  (positive-int)
m.zero?                        # Constant<false>  — proven; the
                               # branch elision can drop the `else`

# String composition stays as precise as the inputs allow.
greeting = "Hello, "           # Constant<"Hello, ">
name     = ARGV.first          # String?       — RBS-declared
hello    = "Hello, #{name}!"   # literal-string — every part is
                               # literal-bearing, so the result is
                               # provably source-derived.

# Tuple-shaped destructuring stays per-position.
first, _middle, last = [10, 20, 30]
first                          # Constant<10>
last                           # Constant<30>

# Constant folding through user methods.
def is_odd(n) = n.odd?
is_odd(3)                      # Constant<true>  — folded through
                               # the body, not just typed as `bool`

# Case/when narrowing produces a literal-set Union.
label = case n
        when 0      then :zero
        when 1..9   then :small
        else             :large
        end
label                          # Constant<:zero> | Constant<:small>
                               #   | Constant<:large>

# Method bindings keep their receiver — `.method(:sym).call`
# round-trips through the original dispatch.
[:to_i, :to_f, :to_sym].map { |m| "1".method(m).call }
                       # Tuple[Constant<1>, Constant<1.0>, Constant<:"1">]
                       # — per-element fold + BoundMethod backward fold

# RBS::Extended directives let you tighten beyond what RBS expresses.
class Slug
  %a{rigor:v1:return: non-empty-string}
  def normalise: (::String id) -> ::String
end
Slug.new.normalise("foo").size  # positive-int  — provably ≥ 1

Rigor never invents these answers — every narrower carrier is derived from literals in the source, control-flow narrowing (is_a?, nil?, == against finite literal sets, integer comparisons), per-class catalogues for the bundled built-ins, or RBS::Extended directives the user opted into. When the inference cannot prove a value is in a narrower carrier, it stays at the wider one (or Dynamic[Top]) and Rigor stays silent — diagnostics fire only when the narrow type is genuinely proved.

Where the type model is documented

How Rigor finds your types

Rigor consults, in order:

  1. In-source RBS. If your project has a sig/ directory, Rigor auto-loads it. rigor init writes a .rigor.yml that points at sig/ by default.
  2. Bundled RBS core + stdlib. Pathname, OptParse, JSON, YAML, etc. ship with the analyzer.
  3. Gem RBS. RBS files vendored with installed gems (Prism's own .rbs, the rbs gem's, …).
  4. In-source class discovery. When no RBS is available, Rigor walks def / define_method / attr_* / Data.define(*Symbol) so user-defined methods on a class are recognised.
  5. Opt-in gem-source inference (ADR-10). Gems listed under dependencies.source_inference: in .rigor.yml have their lib/ walked the same way project source is, so methods on those gems' classes resolve even without RBS. Inferred returns crossing the gem boundary are wrapped in Dynamic[T] so the call site retains the provenance — RBS / RBS::Inline / generated stubs / plugin contracts always win on conflict. Default behaviour is unchanged: gems not listed stay at the RBS-or-Dynamic[Top] boundary.

If a type cannot be proved, the engine returns Dynamic[Top] (Rigor's gradual carrier) and stays silent — Rigor never invents diagnostics it cannot prove.

Refining types through RBS::Extended

When the RBS-declared type is too wide, attach a %a{rigor:v1:…} annotation to the relevant method in your sig/ file. The annotation is a no-op for ordinary RBS tools and a tightening signal for Rigor.

class Slug
  # The runtime always returns a non-empty string. The override
  # tightens the call-site result to non-empty-string and tells
  # the body's `assert_type` that `id` cannot be "".
  %a{rigor:v1:return: non-empty-string}
  %a{rigor:v1:param: id is non-empty-string}
  def normalise: (::String id) -> ::String
end

Right-hand side accepts:

  • RBS class namesString, ::Foo::Bar (with optional ~T negation for assert / predicate-if-*).
  • Imported-built-in refinement names (kebab-case):
    • Point-removal — non-empty-string, non-zero-int, non-empty-array[T], non-empty-hash[K, V].
    • IntegerRange aliases — positive-int, non-negative-int, negative-int, non-positive-int, int<min, max>.
    • Predicate refinements — lowercase-string, uppercase-string, numeric-string, decimal-int-string, octal-int-string, hex-int-string.
    • Paired complements (~T-symmetric) — non-lowercase-string, non-uppercase-string, non-numeric-string. Writing ~lowercase-string narrows String to non-lowercase-string instead of the generic Difference[String, lowercase-string] fallback.
    • Composed shapes — non-empty-lowercase-string, non-empty-uppercase-string, non-empty-literal-string.
    • Flow-tracked source-literal — literal-string. Rigor lifts "hi #{name}!", "a" + literal_str, and literal_str * 3 to literal-string when every operand is itself literal-bearing.

The full directive table is in docs/type-specification/rbs-extended.md; the catalogue of refinement names is in docs/type-specification/imported-built-in-types.md.

Example: argument-type-mismatch caught at the call site

# sig/normaliser.rbs
class Normaliser
  %a{rigor:v1:param: id is non-empty-string}
  def normalise: (::String id) -> ::String
end
# app/normaliser.rb
class Normaliser
  def normalise(id)
    id.upcase
  end
end

n = Normaliser.new
n.normalise("hello")   # OK
n.normalise("")        # rigor flags: argument type mismatch

rigor check reports the second call as an argument-type-mismatch because the literal "" does not satisfy non-empty-string. Inside the method body, Rigor also sees id as non-empty-string (so id.empty? reduces to Constant[false] and id.size reduces to positive-int).

What rigor sees today

  • Local / instance / class / global variables — intra-method bindings, cross-method ivar / cvar accumulators, program-wide globals, and compound writes (||=, &&=, +=).
  • self typing and constant lookup — class and method body boundaries inject Singleton[T] / Nominal[T]; lexical constant resolution walks RBS-core, common stdlib, in-source class discovery, and in-source constant-value tracking (BUCKETS = [:a, :b]; BUCKETS.firstConstant[:a]).
  • Predicate narrowing — truthiness, nil?, is_a? / kind_of? / instance_of?, finite-literal equality, case-equality (===) for Class / Module / Range / Regexp, case / when integration. Paired-complement narrowing for Refined predicates (~lowercase-stringnon-lowercase-string).
  • Tuple / HashShape carriers — shape-aware element access, range / start-length slices, closed / open / required / optional policies, per-element block fold over map, select, filter_map, flat_map, find / find_index, count, any? / all? / none?, zip. &:symbol block-pass on these methods is treated as { |x| x.symbol } and dispatches against the element type so Hash#transform_values(&:freeze) returns Hash[K, V] instead of Enumerator[...].
  • Constant folding — aggressive arithmetic / string / Symbol / Tuple-shaped divmod folding, cartesian fold over Union[Constant…], integer-range arithmetic (positive-int + 1int<2, max>), branch elision on provably-truthy / falsey predicates, Constant<String>#% format-string fold against Tuple / HashShape arguments.
  • Built-in catalogues — Numeric / Integer / Float, String / Symbol, Array, Hash, IO, File, Range, Set, Time, Date / DateTime, Comparable, Enumerable, Rational, Complex, Pathname, Random, Struct (+ Data), Encoding, Regexp / MatchData, Proc / Method / UnboundMethod, Exception. Each catalog drives the fold dispatcher with per-class blocklists for indirect mutators.
  • Refinement carriersType::Difference, Type::Refined, Type::Intersection provide the imported-built-in catalogue end-to-end through Builtins::ImportedRefinements. The parser accepts Symbol / String literals and |-unions at type-arg position (pick_of[Shape, :a | :b], Pick[T, "name" | "email"]).
  • Method carrier (Type::BoundMethod)Object#method(:sym) lifts into a binding carrier so .call / .() / [] recover the precise dispatch ("1".method(:to_i).call resolves to Constant<1>). Reflective Method members (#owner / #name / #arity) still resolve via the Method RBS sig.
  • RBS::Extended directive routesreturn:, param: (call-site + body-side), assert: / predicate-if-(true|false) accept refinement payloads, and roll up into a single Rigor::FlowContribution bundle per method (the v0.1.0 plugin contribution merger reads bundles directly).
  • Opt-in gem-source inference (ADR-10) — gems listed under dependencies.source_inference: have their lib/ walked. Per-gem budget, per-gem-version cache slice, dynamic.dependency-source.* diagnostic family covering gem-not-found / budget-exceeded / config-conflict / boundary-cross (the last surfaces RBS+gem-source overlap on mode: :full gems for audit).

The full per-release surface lives in CHANGELOG.md. The internal contracts the analyzer guarantees live under docs/internal-spec/.

Plugins

v0.1.0 introduced the extension API; v0.1.x rounds it out with the ADR-9 cross-plugin fact channel (one plugin publishes a fact like :model_index, another consumes it), ADR-11 Sorbet ingestion, and ADR-13 plugin-supplied type-vocabulary resolvers. Nineteen worked examples ship under examples/ — each is a fully-shaped plugin gem with a runnable demo and an end-to-end integration spec.

Plugin-contract teaching examples (focus on a single extension-point):

  • rigor-deprecations — smallest possible plugin (~80 lines); config-driven rules.
  • rigor-lisp-eval — typing literal AST arguments at a method call.
  • rigor-statesman — two-pass DSL analysis (collect declarations, then validate references).
  • rigor-pattern — plugin → analyzer collaboration via Scope#type_of and the literal-string carrier.
  • rigor-units — local-variable flow tracking through arithmetic.
  • rigor-routesPlugin::IoBoundary reads under TrustPolicy plus cache producers.
  • rigor-typescript-utility-typesPlugin::TypeNodeResolver chain wiring TS-canonical names (Pick / Omit / Partial / Required / Readonly) onto Rigor's shape-projection type functions.

Rails ecosystem plugins (Tier 1 + Tier 2 + Tier 3 + Sorbet):

examples/README.md is the plugin authoring landing page — comparison table, recommended reading order, and the architectural map of which surface each example exercises. The binding contract for the plugin API lives in docs/adr/2-extension-api.md; the slice-by-slice normative specs are under docs/internal-spec/plugin*.md; the sibling ADRs that extend it ride the same surface (ADR-9 cross-plugin facts, ADR-11 Sorbet adapter, ADR-13 TypeNode resolver).

Configuration

rigor init writes a starter .rigor.yml:

bundle exec rigor init           # fails if .rigor.yml exists
bundle exec rigor init --force   # overwrite

Common knobs the file exposes:

  • paths — directories rigor check and rigor type-scan scan when no path is given (defaults to lib).
  • target_ruby — minimum Ruby version your project targets.
  • libraries — extra stdlib libraries to load on top of the bundled defaults (e.g. ["csv", "set"]).
  • signature_paths — explicit list of sig/-style directories. Leave unset (or null) to auto-detect <root>/sig. Use [] to disable project-RBS loading entirely.
  • disable — rule identifiers to silence project-wide. Shipped rules: undefined-method, wrong-arity, argument-type-mismatch, possible-nil-receiver, dump-type, assert-type, always-raises. In-source # rigor:disable <rule> end-of-line comments silence per-line; # rigor:disable all suppresses every rule.

Status

Current released version: v0.1.2. The analyzer is usable on real Ruby code today; the rule catalogue is deliberately narrow — Rigor's stance is to surface zero false positives while the inference surface stabilises. The roadmap is tracked in docs/MILESTONES.md; release-by-release detail lives in CHANGELOG.md.

v0.1.4 is the active development cluster on master and delivers:

  • ADR-10 closed end-to-end — opt-in gem-source inference, per-gem budget, cache slice, and the dynamic.dependency-source.boundary-cross :info diagnostic that surfaces RBS / gem-source overlap under mode: :full.
  • **ADR-11 primary surface
    • per-call-site assertion gating** — rigor-sorbet ingests Sorbet sig { ... } blocks, T.let / T.cast / T.must / T.bind / T.assert_type! / T.reveal_type / T.absurd, and RBI files. Per-call-site enforce_sigil gates assertion recognisers by the caller file's # typed: sigil.
  • ADR-13 plugin TypeNode resolver + TypeScript-utility-type adapterPlugin::TypeNodeResolver extension point + five Rigor-canonical shape-projection type functions (pick_of / omit_of / partial_of / required_of / readonly_of) + the opt-in rigor-typescript-utility-types plugin mapping TS spellings onto the core functions. Pick[T, :a | :b] round-trips through the directive grammar.
  • ADR-14rigor sig-gen CLI — emits RBS from inference results across five classifications (new-file / new-method / tighter-return / equivalent / skipped); --params=untyped default, --params=observed opt-in via --observe=PATH.
  • Method carrier (Type::BoundMethod)Object#method(:sym).call / .() / [] round-trip with full precision instead of collapsing to untyped.
  • Rails ecosystem (Tier 1 + Tier 2)rigor-rails-routes, rigor-rails-i18n, rigor-actionmailer, rigor-activejob, rigor-actionpack (4 phases), rigor-factorybot, and rigor-activerecord publishing :model_index via the ADR-9 cross-plugin fact channel.

Nineteen worked plugin examples now ship under examples/ — see examples/README.md for the comparison table.

Contributing

See CONTRIBUTING.md for the minimal git clone → green-tests path and a map of the spec / ADR / skill documentation contributors should know about.

License

Mozilla Public License Version 2.0. See LICENSE.