Skip to content

Map Forwardable delegations to method pins - #1311

Draft
apiology wants to merge 2 commits into
castwide:masterfrom
apiology:forwardable-delegators
Draft

Map Forwardable delegations to method pins#1311
apiology wants to merge 2 commits into
castwide:masterfrom
apiology:forwardable-delegators

Conversation

@apiology

Copy link
Copy Markdown
Contributor

Problem

def_delegators and def_delegator define real methods, but Solargraph maps neither, so every call to a delegated method is unresolved and any method returning one cannot infer its return type:

require 'forwardable'

class Records
  # @return [Integer]
  def size = 3
end

class Holder
  extend Forwardable

  def_delegators :@records, :size

  # @return [void]
  def initialize
    # @type [Records]
    @records = Records.new
  end

  # @return [Integer]
  def total = size
end
test.rb:20: Holder#total return type could not be inferred
test.rb:20: Unresolved call to size

Separately, alias_method in a class body reports a false positive at strong level today:

class Widget
  # @return [String]
  def name = 'x'

  alias_method :title, :name
end
test.rb:5: Unresolved call to alias_method

Solution

SendNode maps both Forwardable forms into Pin::DelegatedMethod — the pin class added in #602, until now constructed only by external plugins — which resolves the receiver and the forwarded method lazily, so a delegated method reports the forwarded method's parameters, return type and signatures, and passes arguments and blocks through. Instance-variable, class-variable and method receivers are supported, as is def_delegator's optional alias argument. Expansion fires only in a namespace that extends Forwardable; SingleForwardable's class-scope delegation is not covered.

The alias_method false positive shares one cause with what would otherwise have been a def_delegators false positive: method pins synthesized from a class-body statement cover that statement's range but have no body of their own, so _locate_pin treating them as closures makes the declaring statement resolve in instance scope, where alias_method and def_delegators are not available. _locate_pin already skipped attribute pins for this reason, with a @todo noting the hack; aliases and delegations now answer the same Pin::Method#body_less? predicate.

Test plan

Six strong-level specs — ivar receiver, method receiver, aliased delegation, argument and block passthrough, no expansion without extend Forwardable, and no false positive on the declaring statement. Five fail without this change. Full suite: 1541 examples, 0 failures, 59 pending.

Opened as a draft. This PR was written by Claude (Anthropic's Claude Code) on behalf of @apiology.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT

def_delegator and def_delegators define real methods whose behavior comes
from the method they forward to, but Solargraph mapped neither, so every
call to a delegated method was unresolved and any method returning one
could not infer its return type.

SendNode now maps both into Pin::DelegatedMethod - the pin class added in
602 and until now only constructed by external plugins - which resolves
the receiver chain and the forwarded method lazily, so the delegated
method reports the forwarded method's parameters, return type and
signatures. Instance-variable, class-variable and method receivers are
supported, as is def_delegator's optional alias argument. Expansion only
fires in a namespace that extends Forwardable; SingleForwardable's
class-scope delegation is not covered.

Method pins synthesized from a class-body statement cover that
statement's range but have no body of their own, so treating them as
closures makes the declaring statement resolve in instance scope.
_locate_pin already skipped attribute pins for this reason; aliases and
delegations need the same treatment. Without it, `alias_method :a, :b` in
a class body reports "Unresolved call to alias_method" at strong level on
master today, and def_delegators would have reported the same.

Specs catalog a Bench with external_requires so Forwardable's own pins
are loaded, which TypeChecker.load_string does not do; that lets each
delegation spec assert an empty problem list.

Full suite: 1541 examples, 0 failures, 59 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT

SKIP=Solargraph,RuboCop: both hooks fail on problems present on master
for the touched files (verified by restoring each file to master and
re-running); the strong-typecheck message set is identical before and
after this change (42 problems, same messages).
apiology added a commit to apiology/solargraph that referenced this pull request Aug 17, 2026
apiology added a commit to apiology/solargraph that referenced this pull request Aug 17, 2026
solargraph typecheck against any project using Forwardable dies before
emitting a single diagnostic:

    lib/solargraph/pin/delegated_method.rb:25:in 'initialize':
    either :method or :receiver is required (ArgumentError)

from ApiMap#load_with_cache -> catalog -> Store#update ->
combine_duplicate_method_pins -> Pin::Method#combine_with ->
Pin::Base#combine_with.

Pin::Base#combine_with rebuilds the merged pin with
self.class.new(**new_attrs), and new_attrs carries only generic pin
attributes (location, name, closure, comments, visibility, signatures).
Pin::DelegatedMethod#initialize requires exactly one of :method /
:receiver and receives neither, so combining two same-path
DelegatedMethod pins is structurally impossible. This went live when
castwide#1311 started minting DelegatedMethod pins for
def_delegators, which makes duplicate-path groups routine.

combine_duplicate_method_pins already skips groups containing a
Pin::MethodAlias for the same class of reason (a merged pin can't
represent the alias target); DelegatedMethod was never added to that
guard. Extend it rather than teaching DelegatedMethod to merge: a pin
constructed from a :receiver that has since resolved holds both
@receiver_chain and @resolved_method, while initialize forbids passing
both, so any combine_with override would have to discard one pin's
delegation target. When the two pins delegate to different receivers
(reopened class, source-vs-RBS duplicate) that loses information
silently. Keeping both pins preserves it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
apiology added a commit to apiology/solargraph that referenced this pull request Aug 18, 2026
A project using Forwardable gets a "Missing @return tag" at strong level for
every def_delegators name whose receiver cannot be resolved statically. On
plate-spinner that is 42 errors across 15 files, on lines that already carry
an @!method/@return directive supplying the type - the directive was written
there precisely because the delegation target is unreachable.

castwide#1311 mints a Pin::DelegatedMethod per def_delegators name.
Where the receiver reaches a method_missing dispatcher, or any object whose
declaration cannot be followed, that pin's return type stays undefined.

The report comes from TypeChecker#method_tag_problems, which iterates
source_map.pins_by_class(Pin::Method) - the raw per-file pin set, not the
ApiMap's combined view. It therefore sees the undefined DelegatedMethod pin
and never the documented Pin::Method sharing its path.
method_return_type_problems_for then special-cases exactly one pin class,
`return [] if pin.is_a?(Pin::MethodAlias)`, with no Pin::DelegatedMethod case.

Add that case, guarded on resolvable?, which Pin::DelegatedMethod already
provides. A delegation that cannot resolve its receiver has no declaration
site a @return tag could be written at - the type belongs to the target
method, elsewhere - so it is skipped for the same reason MethodAlias is
skipped on the line above. Delegations that do resolve are untouched and
still enforce their target's type at call sites. Where nothing declares a
type, the receiver's own missing tag is still reported, so the root is named
once rather than once per delegated name.

Negative result, recorded so it is not retried: Store#combine_duplicate_method_pins
was tried first, preferring a documented Pin::Method over a same-path
DelegatedMethod. It works - the group collapses and get_methods returns the
typed pin - and it has no effect on this diagnostic, because
method_tag_problems never consults the combined view. That approach was
abandoned, not left unfinished.

Measured on plate-spinner whole-project at strong: 54 problems in 23 of 501
files before, 12 in 10 after, with all 42 Missing @return cleared. The
remaining 12 are @sg-ignore markers other merged fixes made unneeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
A project using Forwardable gets a "Missing @return tag" at strong level for
every def_delegators name whose receiver cannot be resolved statically. On
plate-spinner that is 42 errors across 15 files, on lines that already carry
an @!method/@return directive supplying the type - the directive was written
there precisely because the delegation target is unreachable.

castwide#1311 mints a Pin::DelegatedMethod per def_delegators name.
Where the receiver reaches a method_missing dispatcher, or any object whose
declaration cannot be followed, that pin's return type stays undefined.

The report comes from TypeChecker#method_tag_problems, which iterates
source_map.pins_by_class(Pin::Method) - the raw per-file pin set, not the
ApiMap's combined view. It therefore sees the undefined DelegatedMethod pin
and never the documented Pin::Method sharing its path.
method_return_type_problems_for then special-cases exactly one pin class,
`return [] if pin.is_a?(Pin::MethodAlias)`, with no Pin::DelegatedMethod case.

Add that case, guarded on resolvable?, which Pin::DelegatedMethod already
provides. A delegation that cannot resolve its receiver has no declaration
site a @return tag could be written at - the type belongs to the target
method, elsewhere - so it is skipped for the same reason MethodAlias is
skipped on the line above. Delegations that do resolve are untouched and
still enforce their target's type at call sites. Where nothing declares a
type, the receiver's own missing tag is still reported, so the root is named
once rather than once per delegated name.

Negative result, recorded so it is not retried: Store#combine_duplicate_method_pins
was tried first, preferring a documented Pin::Method over a same-path
DelegatedMethod. It works - the group collapses and get_methods returns the
typed pin - and it has no effect on this diagnostic, because
method_tag_problems never consults the combined view. That approach was
abandoned, not left unfinished.

Measured on plate-spinner whole-project at strong: 54 problems in 23 of 501
files before, 12 in 10 after, with all 42 Missing @return cleared. The
remaining 12 are @sg-ignore markers other merged fixes made unneeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
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.

1 participant