Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions