Skip to content

Fix method signatures being overwritten by prepended modules - #2718

Open
DaneHarrison wants to merge 3 commits into
Shopify:mainfrom
DaneHarrison:fix/prepend-method-signature
Open

DaneHarrison wants to merge 3 commits into
Shopify:mainfrom
DaneHarrison:fix/prepend-method-signature

Conversation

@DaneHarrison

@DaneHarrison DaneHarrison commented Sep 18, 2026 •

Copy link
Copy Markdown

Motivation

Fixes #2072 - When a module is prepended onto a class, Tapioca generates the RBI using the prepended module's signature instead of its own.


Implementation

compile_method receives methods obtained with Module#instance_method, which follows the ancestor chain. When a
module is prepended to a class and defines the method, instance_method returns the prepended module's UnboundMethod rather than the class's own, so compile_method was compiling the wrong method.

  • method_owned_by_constant? becomes method_defined_by_constant. It walks super_method and returns the method the
    constant itself defines.

  • New signature_defined_by_constant returns the signature declared on that method. A signature is only accepted
    if signature.method.owner matches the method's owner.

  • The attr_accessor writer inference resolves the constant's own reader first, so it works in either evaluation order.


Further Considerations:

  • If both the class and the prepended module have a sig for the same name, Sorbet keeps only one per key. Whichever block runs last wins - see wrap_method_if_needed and unwrap_method
  • There are other callers to signature_of i.e under dsl, I suspect those will need to be updated as well
    • Let me know if you'd like me to do a follow up PR on this

Tests

  • No sig: a prepended module in front of the method, and multiple prepended modules
  • Cross-gem prepend matching the issue, checking both gems' RBIs
  • Sig with a prepended module that has different parameters
  • Only the prepended module has a sig (the class's method must not take it)
  • Sig evaluated before the prepend, and between two prepends
  • attr_accessor writer inference with the reader's sig evaluated before and after the prepend
  • A prepended module's sig that fails to load is reported once, against the module
  • Skipped: both the class and the prepended module have a sig (Sorbet keeps only one)

@DaneHarrison
DaneHarrison requested a review from a team as a code owner September 18, 2026 18:19
@DaneHarrison

Copy link
Copy Markdown
Author

I have signed the CLA!

@DaneHarrison
DaneHarrison marked this pull request as draft September 18, 2026 18:41
@DaneHarrison
DaneHarrison marked this pull request as ready for review September 19, 2026 05:14
@KaanOzkan KaanOzkan self-assigned this Sep 23, 2026

@KaanOzkan KaanOzkan left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to do this for DSL generation but it was too complex. I think this is acceptable.

return unless method
return unless method_owned_by_constant?(method, constant)

method = method_defined_by_constant(method, constant)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent brought up that the method visibility can also be incorrectly attributed to the prepended method, could you add a test and a fix for this?

it "uses the constant's own method visibility instead of the prepended method's visibility" do
  add_ruby_file("foo.rb", <<~RUBY)
    module Foo
      def bar(x); end
    end

    class Baz
      prepend Foo

      private

      def bar; end
    end
  RUBY

  output = template(<<~RBI)
    class Baz
      include ::Foo

      private

      def bar; end
    end

    module Foo
      def bar(x); end
    end
  RBI

  assert_equal(output, compile)
end

it "compiles each method with its own sig when both the class and the prepended module have one" do
# Sorbet files the class's sig under the prepended module's method, which is also where the module's
# own sig is filed, so whichever sig is evaluated last overwrites the other one.
skip "Sorbet keeps only one of the two signatures"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in support of removing this test, I don't see it being changed in the future.

return unless method_owned_by_constant?(reader_method, constant)

reader_method
method_defined_by_constant(reader_method, constant)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this second call necessary?

assert_equal(output, compile)
end

it "compiles a method using its own signature, not the signature of a module prepended in front of it" do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the next test shouldn't say "signature" IMO since it's not Sorbet signatures. Maybe method definition?

assert_equal(output, compile)
end

it "compiles a method with a sig using its own signature when the prepended module has different parameters" do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a singleton equivalent for this?

it "compiles a singleton method using its own signature through a prepended module" do
  add_ruby_file("foo.rb", <<~RUBY)
    module Wrapper
      def bar(x, y); end
    end

    class Baz
      extend T::Sig

      sig { params(x: Integer).returns(Integer) }
      def self.bar(x)
        x
      end

      singleton_class.prepend(Wrapper)
    end
  RUBY

  output = template(<<~RBI)
    class Baz
      extend ::Wrapper

      class << self
        sig { params(x: ::Integer).returns(::Integer) }
        def bar(x); end
      end
    end

    module Wrapper
      def bar(x, y); end
    end
  RBI

  assert_equal(output, compile)
end

This branch has not been deployed

No deployments
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.

Tapioca does not properly attribute prepended methods

2 participants