Skip to content

Mutate methods wrapped by a prepended module - #1685

Merged
mbj merged 1 commit into
mbj:mainfrom
sferik:prepended-method-wrappers
Oct 29, 2026
Merged

mbj merged 1 commit into
mbj:mainfrom
sferik:prepended-method-wrappers

Conversation

@sferik

@sferik sferik commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

memosa memoizes a method by prepending a MemosaMethods module that defines a wrapper of the same name, which calls super. When mutant asks the class for that method, it gets the wrapper back, and the wrapper's source location points into memosa itself. Mutant parses memosa.rb, finds no matching def, and silently drops the method. No warning, it just never becomes a subject.

For example:

class Calc
  extend Memosa

  def initialize(value)
    @value = value
  end

  memoize def double
    @value * 2
  end

  memoize private def secret
    @value + 1
  end

  def reveal
    secret
  end
end

On main, mutant finds 2 subjects here (initialize and reveal) and never touches double or secret. With this patch it finds all 4, and all 37 mutations are killed by a small spec, which also shows the mutated code really runs behind memosa's wrapper.

The fix is in Matcher::Methods: after looking up a method, follow super_method until the owner is the scope itself. The original method is still defined on the class, so mutations replace it and the prepended wrapper keeps working around them (caching included). Since it lives in the shared matcher, it covers singleton methods too.

The fork linked in the issue special-cased memosa, but nothing here is memosa specific, so mutant doesn't need to know about it. Anything that wraps methods with prepend (instrumentation, other memoizers) gets the same treatment, and mutating the method on the class while leaving the wrapper in place seems like the right behavior for all of them.

I checked the case where a subclass changes the visibility of an inherited method (private :foo), since that creates a method entry without a body. Ruby reports the subclass as the owner, so the loop stops right away.

@sferik
sferik force-pushed the prepended-method-wrappers branch from 7c0421a to c0d9611 Compare October 29, 2026 06:27
Gems such as memosa memoize a method by prepending a module with a
wrapper of the same name that calls super. Reflection on the scope
returns that wrapper, whose source location points into the gem, so
mutant found no matching def and dropped the method without a warning.

Follow super_method past prepended modules until reaching the method
defined on the scope itself. Mutations replace that method and the
wrapper keeps running around them. This applies to instance and
singleton methods alike and needs no knowledge of memosa. When the
chain never reaches the scope, as with the methods DelegateClass lists
as its own, the method is not defined there and is skipped as before.
For instance methods the walk runs inside the existing rescue, so a
reflection failure warns and skips the method.

Read visibility from the scope without its ancestors, since a wrapper
can have a different visibility than the method it wraps. Otherwise a
private method behind a public wrapper became a public subject, and
inserting each mutation made the original method public.

Detect module_function the same way, ignoring ancestors on both sides,
so a wrapper prepended to the singleton class no longer hides the copy
of the method there.

Remove a nil source location guard in matched_view that call already
handles, and extra spacing that RuboCop flagged in the instance methods
spec.

Fixes mbj#1682
@sferik
sferik force-pushed the prepended-method-wrappers branch from c0d9611 to 76f1270 Compare October 29, 2026 14:29
@mbj
mbj merged commit 233c802 into mbj:main Oct 29, 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