Rigor

Gem Version GitHub License

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

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> \
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>

# 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.

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

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

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.0.8 (the eighth preview). The analyzer is usable on real Ruby code today but 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.0.9 is the active development cluster on master and covers the persistent cache infrastructure (.rigor/cache/, --cache-stats, --clear-cache, --no-cache), paired-complement Refined narrowing, literal-string flow tracking, the Rigor::FlowContribution bundle struct, and six additional built-in catalogues (Random, Struct, Encoding, Regexp + MatchData, Proc / Method / UnboundMethod, Exception). The next release after 0.0.9 will be 0.1.0.

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.