Skip to content

RBSCommentsToSorbetSigs emits two consecutive sig blocks for overloaded #: annotations, which sorbet-runtime rejects at load time #913

@dduugg

Description

@dduugg

Summary

Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs translates each #: line preceding a method into a separate sig do … end block. For methods that use overloaded RBS annotations (multiple #: lines on a single method — a valid and common RBS pattern, e.g. for the with-block / without-block split on enumerable each methods), this produces two sig calls back-to-back. When the translated source is then loaded under sorbet-runtime — which is what tapioca's tapioca/rbs/rewriter.rb does via RequireHooks.source_transform during bin/tapioca gemT::Private::Methods._declare_sig_internal raises:

RuntimeError: You called sig twice without declaring a method in between

…and gem load aborts. This makes bin/tapioca gem <name> fail for any gem that uses overloaded #: annotations. The one I hit it on is herb (see Herb::DiffResult#each, lines 17-18).

Minimal reproducer

Standalone — only spoom and sorbet-runtime, no tapioca or herb required:

require "spoom"
require "sorbet-runtime"

source = <<~RUBY
  # typed: true
  class Foo
    #: () { (Integer) -> void } -> void
    #: () -> Enumerator[Integer, void]
    def each(&block)
      return [1, 2, 3].each unless block
      [1, 2, 3].each(&block)
    end
  end
RUBY

translated = Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs(source, file: "(repro)")
puts translated

Module.include(T::Sig)
Object.class_eval(translated, "(repro-translated)")

Spoom's output

# typed: true
class Foo
  sig { params(block: ::T.proc.params(arg0: Integer).void).void }
  sig { returns(::T::Enumerator[Integer, void]) }
  def each(&block)
    return [1, 2, 3].each unless block
    [1, 2, 3].each(&block)
  end
end

Runtime failure

RuntimeError: You called sig twice without declaring a method in between
  from sorbet-runtime/lib/types/private/methods/_methods.rb:55:in '_declare_sig_internal'
  from sorbet-runtime/lib/types/private/methods/_methods.rb:40:in 'declare_sig'
  from sorbet-runtime/lib/types/sig.rb:28:in 'T::Sig#sig'
  from (repro-translated):4

Why this matters

Tapioca's rbs/rewriter.rb installs the RequireHooks transform globally for **/*.rb during gem RBI generation. Any gem in the bundle that uses overloaded #: annotations will abort tapioca's gem load. The blast radius scales with RBS adoption — and overloads are exactly the case where RBS is most expressive vs. Sorbet's single-sig syntax.

Concrete real-world example: I'm trying to migrate packwerk from better_html (deprecated) to herb, and bin/tapioca gem herb fails on load with the trace above. The relevant herb source is one block-overloaded each — a textbook RBS overload pattern.

Possible remediations

A few options for RBSCommentsToSorbetSigs, roughly in order of "amount of work":

  1. Emit only the last sig when a method has multiple #: annotations. This matches Sorbet's existing "last sig wins" behavior for overloaded sigs (see Shopify/tapioca#1789). Loses overload fidelity in the rewritten source but unblocks gem load.
  2. Raise Spoom::Sorbet::Translate::Error when overloads are detected. Tapioca's rewriter already rescues this and falls back to the original source — so the #: lines would stay as inert comments and the gem would load without runtime sigs. Tapioca's generator would produce an untyped (but otherwise valid) RBI.
  3. Collapse the overloads into a single sig with appropriately-typed params/returns (e.g. accept T.nilable(T.proc...) for the optional block; return type becomes T.any(T::Enumerator[...], void)). Closest in spirit to the original RBS but a substantial implementation lift.

(1) seems like the smallest viable fix and would unblock everyone immediately; (2) is even smaller but produces less useful RBIs. Happy to send a PR for whichever route you'd prefer.

Environment

  • spoom 1.7.13
  • sorbet-runtime 0.6.13184
  • tapioca 0.19.1 (downstream)
  • Ruby 3.4.9

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions