Skip to content

Understanding errors

This chapter is the catalogue of diagnostics Rigor ships, the families they belong to, and how to suppress one when it is wrong (or move its severity around). It is the page to land on when a diagnostic surprises you — in either direction.

In this chapter Anatomy of a diagnostic · Rule cataloguecall.* · flow.* · def.* · assert.* · dump.* · Severity profiles · Per-rule overrides · Suppression — in source · whole file · project-wide · Baseline diffing for CI · Didn’t fire when expected? · Fired unexpectedly? · Adoption workflow

lib/user.rb:42:7: error: undefined method `upcas' for "alice" [call.undefined-method]
↑ ↑ ↑
│ │ └─ qualified rule
│ └─ message
└─ severity (error / warning / info)

The qualified rule (call.undefined-method, flow.always-raises, def.return-type-mismatch, …) is the stable identifier for the rule. Use it in:

  • # rigor:disable <rule> end-of-line suppressions in source
  • # rigor:disable-file <rule> file-scope suppressions
  • severity_overrides: in .rigor.yml
  • disable: in .rigor.yml

Wildcards work — # rigor:disable call suppresses every call.* rule on that line.

Need to look up what a rule does without leaving the shell? rigor explain <rule> prints the rule’s summary, when it fires, when it doesn’t, the suppression token, the authored severity, and the per-profile severity. rigor explain with no argument prints the index of every shipped rule.

Five families, each with one or more rules:

Fire when a method call’s shape is wrong.

RuleFires whenDefault severity
call.undefined-methodThe receiver class is statically known and the method is not defined on it (RBS or in-source).error
call.wrong-arityThe number of positional arguments does not satisfy any overload’s arity.error
call.argument-type-mismatchAn argument’s type provably does not satisfy the parameter contract (RBS or RBS::Extended param:).error
call.possible-nil-receiverThe receiver type is `Tniland the method is not defined onNilClass`.
call.unresolved-toplevelAn implicit-self call at the top level (outside any def / class / module) resolves against no same-file def, pre_eval: monkey-patch, or Kernel / Object method — surfacing typos in standalone scripts.warning under balanced, error under strict, suppressed under lenient

call.* rules are the highest-volume diagnostics on real-world code. They are also the most refined — every one fires only when Rigor can prove the underlying fact.

Fire when the control flow itself is unsound.

RuleFires whenDefault severity
flow.always-raisesEvery reachable evaluation of an expression raises (e.g. n / 0 where n: Integer).error
flow.unreachable-branchAn if / unless / ternary’s predicate is a syntactic literal AND the corresponding dead branch is non-empty.warning
flow.always-truthy-conditionThe predicate of an if / unless / ternary is provably truthy (or falsey) by inferred type, with surgical skips inside loop bodies and on defensive predicate calls.warning
flow.dead-assignmentA plain local-variable write whose target name is never read in the same def body.warning

Fire when the body of a method violates its declared contract.

RuleFires whenDefault severity
def.return-type-mismatchThe body’s last expression’s inferred type cannot satisfy the RBS-declared return type. Honors %a{rigor:v1:return: <refinement>} overrides.warning under balanced profile, error under strict
def.ivar-write-mismatchA later @var = ... write’s concrete class disagrees with the first write’s class in the same class body (NilClass-to-clear is allowlisted).error
def.method-visibility-mismatchAn explicit-receiver call targets a Nominal[X] whose discovered method is :private in the surrounding class body.error
def.override-visibility-reducedAn override reduces the visibility it inherits from a project-defined ancestor (public → protected/private, protected → private), breaking a caller that holds the supertype.warning under balanced, error under strict, suppressed under lenient
def.override-return-widenedAn override’s declared return widens the inherited return (covariance). Fires only on a proven violation when both sides carry an authored RBS signature.warning under balanced, error under strict, suppressed under lenient
def.override-param-narrowedAn override narrows an inherited parameter type (contravariance), comparing matching positional parameters. Requires an authored single-overload RBS signature on both sides.warning under balanced, error under strict, suppressed under lenient

The three def.override-* rules are the Liskov Substitution Principle signature rule applied across a project-defined class/module hierarchy (superclass chain + included/prepended modules, resolved cross-file). They are the conceptual subject of appendix: Liskov substitution.

RuleFires whenDefault severity
assert.type-mismatchAn assert_type("expected", value) call’s actual inferred type does not match the expected string.error
RuleFires whenDefault severity
dump.typedump_type(value) was called — emits an info diagnostic naming the inferred type.info

dump_type is your introspection probe during debugging: sprinkle it through suspicious code, run rigor check, read the inferred types from the diagnostic stream.

Rigor ships three named severity profiles that re-stamp the shipped severities:

ProfileBehaviour
lenientMost rules → warning; uncertain rules drop to info. CI-friendly for legacy code.
balanced (default)Most rules → error; dump.typeinfo. The shipped behaviour.
strictEverything → error including the :warning rules under balanced. Suitable for new projects with no legacy noise.

Set in .rigor.yml:

severity_profile: strict

Override a single rule’s severity:

severity_overrides:
call.argument-type-mismatch: warning
def.return-type-mismatch: off

off drops the diagnostic from the result entirely — useful when you want a profile-wide setting for most rules but silence one specifically.

Family wildcards work in overrides too:

severity_overrides:
call: warning # demote every call.* rule
dump: off # drop every dump.* rule

Per-rule entries beat family-wildcard entries:

severity_overrides:
call: warning # every call.* → warning
call.undefined-method: error # except undefined-method, still error

YAML reserves the bareword off. If the stripped severity seems not to apply, quote it: "off". Same for on.

"hello".no_such_method # rigor:disable call.undefined-method

The comment must be on the same line as the diagnostic. Use the qualified rule, the family wildcard, or all:

"hello".no_such_method # rigor:disable call
"hello".no_such_method # rigor:disable all

For multiline blocks, suppress at every line — Rigor does not yet ship a disable-block syntax.

When you need to silence a rule everywhere in a file — typically a generated file, a fixture, or a vendored snippet that triggers a known false positive — drop a single # rigor:disable-file comment anywhere in the file:

# rigor:disable-file call.undefined-method
# This whole file is generated; the analyzer's call surface
# is mismatched with the runtime layer for these stubs.

Convention is to put the comment near the top, but Rigor scans every comment in the file so any placement works. The same token forms apply: qualified rule, family wildcard, or all. The line-scope # rigor:disable form continues to work — the two compose, and any project-wide disable: [...] in .rigor.yml also still applies.

.rigor.yml
disable:
- call.possible-nil-receiver

Drops the rule project-wide. Heavier hammer than severity_overrides: { call.possible-nil-receiver: off } — both work; the choice is stylistic.

When you adopt Rigor on an existing codebase, you usually inherit a long tail of legitimate-but-pre-existing diagnostics that nobody is going to fix today. The pragmatic move is to snapshot the current state as a baseline and then have CI fail only on new diagnostics introduced by a PR:

Terminal window
# Once: capture the current diagnostic surface.
rigor check --format=json > rigor.baseline.json
git add rigor.baseline.json
git commit
# Per PR: compare against the committed baseline.
rigor diff rigor.baseline.json

rigor diff prints + NEW rows for each diagnostic that wasn’t in the baseline and - FIXED rows for each that has been resolved since. The exit code is 1 when any new diagnostic appears and 0 otherwise — so adding a new violation fails CI, but the legacy diagnostics recorded in the baseline don’t.

When you fix a row in the baseline, regenerate it with the same rigor check --format=json > rigor.baseline.json so the project tightens monotonically over time. The --format=json form of rigor diff itself is also available for editor / dashboard integrations.

rigor diff is the lightweight, ad-hoc form — a JSON file you diff by hand in a CI script. Most projects instead adopt the managed baseline: rigor baseline generate writes a .rigor-baseline.yml, you point at it with the baseline: config key, and from then on rigor check itself exits clean on recorded diagnostics and surfaces only new ones — no separate diff step. That is the path the rigor-project-init skill sets up for you; see Baselines for the full workflow (ADR-22 for the design).

Why a diagnostic might NOT fire when you expected one

Section titled “Why a diagnostic might NOT fire when you expected one”

The most common reasons:

  1. The receiver is Dynamic[Top]. Rigor stays silent on gradual receivers. Run rigor type-of <file>:<line>:<col> to confirm what the engine sees.
  2. The method exists somewhere in the hierarchy. Even one matching def in any ancestor class / module silences call.undefined-method.
  3. The call is implicit-self inside a method body. Rigor does not flag implicit-self calls — too much noise on metaprogramming-heavy code.
  4. The literal might be empty / nil at runtime in a way the analyzer cannot prove. s = ARGV.first; s.upcase silently passes because s could legitimately be a non-empty string at runtime, and Rigor will not flag what it cannot prove. Add an explicit guard or a param: tightening.
  5. The target rule is disabled by configuration. Check your .rigor.yml and any # rigor:disable comments in the offending file.
  6. The severity profile dropped it. Under lenient, rules that fire as :warning may have been further demoted to :info and filtered out of your CI script.

When in doubt, run with --explain:

Terminal window
rigor check --explain lib

This adds an :info diagnostic for every fail-soft fallback the engine took — every place it widened to Dynamic[Top] because it could not see further. The output is noisy on realistic code but invaluable when “I expected a diagnostic here” debugging.

Why a diagnostic IS firing when you think it should not

Section titled “Why a diagnostic IS firing when you think it should not”

Almost always one of:

  1. Rigor is right. The classic case: a method’s RBS sig says String? but the project’s runtime invariants guarantee non-nil. Either fix the sig (preferred), add a RBS::Extended return: directive, or add a # rigor:disable on the line.
  2. An RBS sig is missing or wrong. The class lives in a gem with no .rbs, or the project’s own sig/ is out of date with the source. Update or add the sig.
  3. A constant is being looked up wrong. Constant resolution can fall back to RBS-core or in-source class discovery; if both miss, the call goes through Dynamic[Top] and you see no diagnostic, but a sibling call against the wrong class might fire.
  4. A diagnostic is genuinely false-positive. Rare — Rigor’s design priority is no-false-positives — but possible. File an issue with the smallest reproducer you can extract.

The pragmatic loop on a project that just adopted Rigor:

  1. Run rigor check lib once to see the baseline.
  2. Skim every diagnostic. Triage as one of: a. Real bug. Fix the code. b. Missing / wrong RBS. Update the sig or add a new one. c. Genuine noise. Add # rigor:disable <rule> on the line, or disable: to .rigor.yml.
  3. Re-run. Repeat until the diagnostic stream is clean.
  4. Add rigor check lib to CI under the balanced profile (or stricter).
  5. As the project’s invariants get more proven, demote # rigor:disable lines into RBS::Extended directives so the analyzer learns the real contract.

A clean rigor check run is the goal; a green CI badge says “every diagnostic that fires is one we accept.”

Chapter 9 — Plugins is a one-page pointer to the examples/ directory. Plugins extend Rigor for project-specific DSLs (units of measure, route helpers, deprecations, …). Most projects will never write one; the chapter exists so you know the option is there. Chapter 10 — Coexisting with Sorbet is for projects arriving from a Sorbet codebase: the rigor-sorbet adapter reads sig { ... } blocks, RBI files, and T.let / T.cast / T.must / T.unsafe assertions as type sources.

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