Skip to content

Fix crash on blocks using the it parameter - #1674

Merged
mbj merged 1 commit into
mbj:mainfrom
mostlyobvious:itblock
Oct 18, 2026
Merged

mbj merged 1 commit into
mbj:mainfrom
mostlyobvious:itblock

Conversation

@mostlyobvious

Copy link
Copy Markdown
Contributor

Summary

  • Mutating foo { bar { it } } raised KeyError: key not found: :itblock. Now it mutates to nil, foo, foo {}, foo { raise }, foo { nil } and bar { it }.
  • Ruby 3.4 it blocks get their own mutator. It emits nil and the mutations of the receiving send. It also emits body mutations, but only those that still use it.
  • items.map { it.price * 2 } keeps its 11 mutations and gains nil.
  • The mutator loads only on Ruby above 3.4.0.

Why

  • Without its own mutator, an it block falls back to the generic one. The generic mutator mutates every child. Some results are invalid, such as nil { it.price }. Meta examples reject a mutator that emits invalid mutations.

  • A body without it turns the block into a plain block. items.map { nil } is one example. So the filter keeps only body mutations that use it.

  • An it inside a nested block belongs to that block. The same goes for a method definition. So the filter skips their bodies:

    Source Mutation Result
    foo { it.bar(1) { it } } foo { it.bar { it } } emitted
    foo { it.bar(1) { it } } foo { self.bar(1) { it } } filtered out
    foo { it.bar { |it| it } } foo { self.bar { |it| it } } filtered out
    foo { it; def bar(it) = it } foo { def bar(it) = it } filtered out
  • Up to Ruby 3.4.0, Unparser parses with the parser gem. The parser gem never builds it block nodes. The mutator would never run there. The ruby-3.3 mutant run --since job would then report its mutations alive.

Ruby 3.4 parses a block using the `it` parameter, such as `bar { it }`,
into an itblock node. Mutant had no structure for that node type. The
block mutator walks the body of a block without parameters to find
`break` and `next`, so mutating `foo { bar { it } }` failed with:

  KeyError: key not found: :itblock

  Before: KeyError
  After:  nil, foo, foo {}, foo { raise }, foo { nil }, bar { it }

* itblock nodes now have a structure, so walking a body that contains
  one works.

* itblock nodes now get their own mutator instead of the generic one.
  The generic mutator mutates every child, so it emits invalid
  mutations, such as `nil { it.price }`, and body mutations that
  remove every `it`, such as `items.map { nil }`. That prints as a
  block without parameters and parses back as a block node, not an
  itblock. A run discards invalid mutations silently, but meta examples
  reject a mutator that emits them, so the generic mutator could not be
  covered by meta examples.

* The new mutator emits what the numblock mutator emits, nil and the
  mutations of the receiving send, plus the body mutations that still
  use `it`. An `it` in a nested block or method definition belongs to
  that block or method, so for those only the first child counts: the
  receiving send of a block, the receiver of `def obj.bar`, and nothing
  for `def bar`, whose first child is its name. Ruby allows `it` in a
  nested block only as that block's own parameter: it is a syntax
  error next to other parameters or numbered parameters, and a block
  without parameters that uses `it` becomes an itblock itself.

  foo { it.bar(1) { it } }      emits       foo { it.bar { it } }
                                filters out foo { self.bar(1) { it } }
  foo { it.bar { |it| it } }    filters out foo { self.bar { |it| it } }
  foo { it; def bar(it) = it }  filters out foo { def bar(it) = it }

* class, module and `class << obj` bodies start a new scope too, but
  the filter still searches them. An `it` block containing
  `class Bar; it = 1; it; end` emits 2 mutations that remove the
  outer `it`, such as `foo { class Bar; it = 1; it; end }`, which no
  longer parse back as an itblock. `mutant run` discards them silently
  and `mutant util mutation` reports them as invalid.

  Compared to the generic mutator, the valid mutations are the same
  plus nil:

    items.map { it.price * 2 }

    Before: 11 mutations, such as items.each { it.price * 2 } and
            items.map { it.price * 3 }
    After:  the same 11, plus nil

* The mutator, and the examples, load only when RUBY_VERSION is above
  3.4.0. Unparser parses with the parser gem up to Ruby 3.4.0 and with
  Prism after it, and the parser gem never builds itblock nodes, so
  there the mutator would never run. Loaded anyway, nothing exercises
  it, and the `mutant run --since HEAD~1` CI job on ruby-3.3 would
  report its mutations alive. Ignoring the subject in mutant.yml would
  stop that check on Ruby 3.4.1 and later too, and such entries outlive
  their reason: the "alive for ruby < 3.1" entries remain although the
  gemspec requires Ruby >= 3.3.
@mbj
mbj merged commit 8d22e9b into mbj:main Oct 18, 2026
162 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants