runtimeanalyz.ing ● early access · sign-up open

← All capabilities

where_nil_comes_from

CLI · MCP free

Where nil shows up. The methods that returned nil and the arguments that received it at runtime, logged as NilClass and tied to the errors that followed. The case a declared type quietly hides.

Enrollment enrollment.rb:4
def unlock_next
  curriculum
    .next_after(current_book)
    .unlock!(student)
end
next_after ( current_book Book )
book Book
3,144 runs · 12 examples
nil NilClass
37 prod runs · 0 examples
Curriculum recorded
# @return [Book]
def next_after(book)
  books[books.index(book) + 1]
end
✗ NoMethodError unlock! for nil · in Enrollment#unlock_next · all 37 of the trees where next_after returned nil
The comment says Book, and 3,144 times it was. The other 37 returned NilClass, never in a spec, and every one of them ended in the same error one line later.

The problem

A few times a day, production raises this:

NoMethodError: undefined method 'unlock!' for nil
  app/models/enrollment.rb:4:in 'unlock_next'

The stack trace tells you where the nil was used. It doesn’t tell you where it came from:

class Enrollment
  def unlock_next
    curriculum.next_after(current_book).unlock!(student)
  end
end

class Curriculum
  # @return [Book]
  def next_after(book)
    books[books.index(book) + 1]
  end
end

The comment says next_after returns a Book. So does an RBS signature, if you have one, and so does the name. Nothing checks it at runtime, and every spec for it picks a book from the middle of the list. The suite is green and the declared type is wrong for exactly one case.

The fix

Ask where the nil came from:

$ ra where_nil_comes_from Enrollment#unlock_next

Curriculum#next_after  returned nil
  37 of 3,181 production runs · 0 of 12 spec examples
  all 37 unwound with NoMethodError in Enrollment#unlock_next (enrollment.rb:4)
  otherwise returned Book

next_after returns nil in production and never in a spec. That’s the missing case: the student is on the last book. Handle it where it happens, and write the spec that was never there:

def unlock_next
  next_book = curriculum.next_after(current_book)
  return complete! if next_book.nil?

  next_book.unlock!(student)
end
it "completes the enrollment after the last book" do
  enrollment = build_enrollment(current_book: curriculum.books.last)

  expect { enrollment.unlock_next }.to change(enrollment, :completed?).to(true)
end

Run it without a method and you get the list for the whole app: every method that ever returned nil and every argument that ever received it, ranked by how often, with the ones no spec covers first.

How it works

  1. Classes on every call. Each recorded call carries the classes of its arguments and of what it returned. nil is an object like any other, so it’s recorded as NilClass, exactly where it happened.
  2. The exception on unwind. When a traced method raises, the recording keeps the exception’s class as the call unwinds. That’s how the 37 nil returns are tied to the 37 NoMethodErrors.
  3. Spec or production. Every call tree is stamped with its entry point, so a nil seen only in production, never in a spec, is reported as exactly that.

Limits

  • Only nils that cross a traced method. A nil from params[:id] or a hash lookup, used on the next line of the same method, never passes through a traced call, so it isn’t seen. Once it’s returned or passed to one of your methods, it is.
  • Only what ran. A method that could return nil but never did in any recording isn’t flagged.
  • It finds the source, not the intent. Sometimes nil is the right answer and the caller should handle it; sometimes the method shouldn’t return it. The report shows where; you decide which.

how_it_got_here

CLI · MCP free

How execution got here. The real call chains that reach a method, in order, taken from recorded runs. The stack trace you'd have got if it had raised, on demand, for every route in.

See more →

what_it_receives

CLI · LSP · MCP free

What a method receives. The observed shape of each argument at a call site. The messages the method actually sends to what it's passed, not only the class it was given.

See more →

observed_shape

CLI · LSP · MCP free

The observed contract. The messages your code actually sends to a value, read from real runs. The interface a call depends on, whether or not anyone declared it.

See more →