Rigor
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. YAML → Psych) 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
- One-page mental model:
docs/types.md. - Binding spec corpus:
docs/type-specification/. - Imported refinement names (kebab-case catalogue):
docs/type-specification/imported-built-in-types.md. - The
RBS::Extendedannotation grammar that opens this vocabulary up to your own RBS:docs/type-specification/rbs-extended.md.
How Rigor finds your types
Rigor consults, in order:
- In-source RBS. If your project has a
sig/directory, Rigor auto-loads it.rigor initwrites a.rigor.ymlthat points atsig/by default. - Bundled RBS core + stdlib. Pathname, OptParse, JSON, YAML, etc. ship with the analyzer.
- Gem RBS. RBS files vendored with installed gems
(Prism's own
.rbs, therbsgem's, …). - 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 names —
String,::Foo::Bar(with optional~Tnegation forassert/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-stringnarrowsStringtonon-lowercase-stringinstead of the genericDifference[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, andliteral_str * 3toliteral-stringwhen every operand is itself literal-bearing.
- Point-removal —
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
(
||=,&&=,+=). selftyping and constant lookup — class and method body boundaries injectSingleton[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.first→Constant[:a]).- Predicate narrowing — truthiness,
nil?,is_a?/kind_of?/instance_of?, finite-literal equality, case-equality (===) for Class / Module / Range / Regexp,case/whenintegration. Paired-complement narrowing for Refined predicates (~lowercase-string→non-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
divmodfolding, cartesian fold overUnion[Constant…], integer-range arithmetic (positive-int + 1→int<2, max>), branch elision on provably-truthy / falsey predicates,Constant<String>#%format-string fold againstTuple/HashShapearguments. - 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 carriers —
Type::Difference,Type::Refined,Type::Intersectionprovide the imported-built-in catalogue end-to-end throughBuiltins::ImportedRefinements. RBS::Extendeddirective routes —return:,param:(call-site + body-side),assert:/predicate-if-(true|false)accept refinement payloads, and roll up into a singleRigor::FlowContributionbundle 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— directoriesrigor checkandrigor type-scanscan when no path is given (defaults tolib).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 ofsig/-style directories. Leave unset (ornull) 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 allsuppresses 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.