Skip to content

A NoMethodError expectation can be satisfied by the method's absence: the mechanism, measured — and zero such passes in today's counted total #850

Closed

Description

@tannevaled

BASELINE can count a pass that comes from the method being ABSENT

Found while accounting for a single unexplained number, which is the only reason
it was found at all.

core/array/rfind_spec.rb on main (3a3fb81), with the shim at 4.0.9 so the
file's ruby_version_is "4.0" guard opens:

RBGO_RESULT pass=1 fail=0 error=15 skip=0

Array#rfind does not exist on main, so 15 of the 16 examples error with
NoMethodError. The sixteenth passes, and it is this one:

it "raises a NoMethodError if the ifnone argument does not respond to #call and no element is found" do
  -> { [1, 2, 3].rfind(42) { |x| false } }.should.raise(NoMethodError)
end

The example means "42 is not callable, so calling it raises NoMethodError". What
actually happens is that rfind itself is undefined, [1,2,3].rfind(42)
raises NoMethodError for that reason, and the expectation is satisfied by the
wrong exception. A spec that asserts an exception class cannot tell why it got
one.

So the gate recorded one passing example for a method the interpreter did not
have. #849 implements rfind and that file goes to 16/16 — the total rises by
15, not 16, and the sixteenth stops being a lie.

How much of this is there? An upper bound, honestly labelled

$ grep -rnoE "should\.raise\(NoMethodError|should raise_error\(NoMethodError" language/ core/ | wc -l
150          # across 72 files

⛔ 150 is an upper bound on candidates, not a count of phantoms. Most of
those expectations name a method that does exist and assert NoMethodError for
an unrelated and legitimate reason — a private method called from outside, a
call on nil, a refinement not active (core/module/refine_spec.rb has one of
exactly that kind, four lines above the Array.instance_methods example). The
phantom case is narrower: the spec exercises a method the interpreter does not
define, and the expectation happens to be the error that absence produces.

I am not going to guess what fraction that is. Guessing is the defect this
issue is about.

The tool this needs

A census, not a sweep, and it belongs beside cmd/ratchet and cmd/guards
(#836) for the same reason those do — so it can be re-run when the corpus moves
and so its own control lives with it:

  1. walk language/ + core/, and for each spec file find the describe "X#y"
    or describe "X.y" block containing a NoMethodError expectation;
  2. ask a real rbgo whether that method exists at all — X.method_defined?(:y)
    for the # form, X.respond_to?(:y) for the . form, both guarded by
    Object.const_defined?(:X), so no instance has to be constructed;
  3. report every example where the answer is no: those are passes the gate
    may be counting for the wrong reason.

Its control is the thing that makes it trustworthy and is cheap here: a known
absent method must be flagged and a known present one must not. On
main, Array#rfind is the planted positive and Array#find the planted
negative — one of each, already measured in this issue.

What it cannot do, and should print rather than hide: it reads the describe
string, so an example whose receiver is not what the describe names is outside
its population. Same class of limit as cmd/guards declaring its example counts
a lower bound because a static scan cannot expand it_behaves_like.

Why this matters for the headline number

The published figure is "23,511 examples pass". This is not a claim that the
figure is wrong by any particular amount — it is that nothing currently
distinguishes a pass from a pass-by-absence
, and the one instance found so far
was found by accident, while explaining why a control said 1 instead of 0. The
same accident will not happen twice.

It also bears on #799's version move: as guards open, files like
core/array/rfind_spec.rb arrive carrying whatever phantom passes they contain,
and UPDATE_BASELINE=1 would bank them.

🤖 Generated with Claude Code

Activity

  1. tannevaled commented on Oct 31, 2026

    @tannevaled
    ContributorAuthor

    ⛔ Correcting this issue's title and its headline claim

    I wrote "The gate counts at least one pass that comes from a method being absent". It does not. The one instance I had measured — core/array/rfind_spec.rb — scores pass=1 only with the shim at 4.0.9, which is not what the gate runs. At 3.4.1 the whole file is behind its ruby_version_is "4.0" guard, and BASELINE records it as:

    OK	core/array/rfind_spec.rb	0
    

    Zero. So the mechanism is real and demonstrated, but the claim that today's 23,511 includes such a pass was unfounded, and I should not have put it in the title. Retitled.

    The tool now exists, and it answers the question properly

    scripts/conformance/rubyspec/cmd/phantom, run against the pinned corpus and a main binary (3a3fb81):

    $ go run ./scripts/conformance/rubyspec/cmd/phantom -root <corpus> -rbgo <main's rbgo>
    59 NoMethodError expectation(s) over 2206 spec file(s); 1 name a method this interpreter does not define
    population: describe strings of the form "X#y" or "X.y" only — operator methods and shared blocks are outside it, and an example whose receiver differs from its describe is invisible here
    core/array/rfind_spec.rb:45	Array#rfind is not defined — a pass here may be the absence, not the behaviour

    2206 spec files — the same population the gate sums — and exactly one candidate, the one #849 implements. So: zero phantom passes in the counted total, and the single candidate becomes a real pass when #849 lands.

    ⛔ The tool's first run was wrong, and how it was wrong is worth recording

    It reported three, the other two being Complex#negative? and Complex#positive?. Both are false positives, and reading one of them settled it:

    describe "Complex#negative?" do
      it "is undefined" do
        c = Complex(1)
    
        c.methods.should_not.include?(:negative?)
    
        -> {
          c.negative?
        }.should.raise(NoMethodError)
      end
    end

    MRI does not define Complex#negative? — Complex has no ordering — and the spec asserts that absence on purpose. rbgo passing it is conformance. Reporting it would have sent a reader to implement a method Ruby deliberately lacks.

    So "the describe names a method the interpreter does not define" is not sufficient. The rule now withdraws any describe block that also asserts the absence (should_not.include?, should_not respond_to, respond_to?(…).should == false) — by the example's own assertion rather than by a list of file paths, which would have gone stale at the next corpus bump and would not have covered the next such spec. That withdrawal took the population from 82 attributable expectations to 59: 23 of them are specs deliberately asserting a method is absent, which is a useful number in its own right.

    Both false positives are now test cases, verbatim.

    What the tool still cannot see, and says so on every run

    • It reads the describe string, so an example whose receiver differs from what its describe names is outside the population.
    • A describe naming an operator method ([], <=>, +) is skipped, because those do not match the identifier shape.
    • Attribution stops at a sibling or outer describe, judged by indentation rather than by counting do/end. The failure that risks is a miss, never a mis-attribution — which is the right direction for a tool whose output sends someone to read a spec — so its count is a lower bound.

    It carries its describe-pattern control inside the binary, run on every invocation, because CI runs it as a binary and never runs its tests; and it refuses (exit 2) if the interpreter says Array#find is missing or says a method that cannot exist is present, since either would make every verdict meaningless.

    Why keep it

    The mechanism does not need an adversary and will matter at the version move: as the 4.0 guards open, 52 spec files arrive carrying whatever expectations they contain, and UPDATE_BASELINE=1 banks what it sees. This is the check that says whether a newly-counted pass is behaviour or absence — and it now has a measured baseline of 1 candidate, 0 counted, against which any future run can be compared.

    🤖 Generated with Claude Code

  2. changed the title [-]The gate counts at least one pass that comes from a method being absent: a NoMethodError expectation satisfied by the wrong NoMethodError[/-] [+]A NoMethodError expectation can be satisfied by the method's absence: the mechanism, measured — and zero such passes in today's counted total[/+] on Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions