Skip to content

Fix @generic return type lost when method also declares a block param - #1274

Open
apiology wants to merge 4 commits into
castwide:masterfrom
apiology:fix-generic-return-block-param
Open

Fix @generic return type lost when method also declares a block param#1274
apiology wants to merge 4 commits into
castwide:masterfrom
apiology:fix-generic-return-block-param

Conversation

@apiology

@apiology apiology commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • A method with @generic T and @return [generic<T>] loses its generic binding when the method also declares a block parameter (&block), even if the block is never referenced in the body. The return type falls back to unresolved generic<T>.
  • Root cause: Pin::Callable#arity_matches? rejected any call missing a block whenever the target signature had block info attached, including signatures where that info came purely from a bare &name formal parameter with no @yield/@yieldparam/@yieldreturn docs. Ruby never requires callers to pass a block for such a parameter, so calling the method without a block discarded the only matching signature before generic resolution ran.
  • Fix: add Pin::Callable#block_required?, true only for RBS-sourced signatures with a genuinely non-optional block ({ ... } vs ?{ ... } in RBS -- this distinction is real and used by stdlib overloads like Array#each). YARD-derived signatures have no way to express a required block, so they default to false. arity_matches? now gates on block_required? instead of bare block presence.

Test plan

  • bundle exec rspec -- 1624 examples, 0 failures, 60 pending (pre-existing pending specs, unchanged)
  • Added regression spec in spec/source/chain/call_spec.rb; verified it fails without the fix (generic<T>) and passes with it (Foo)
  • Verified the RBS-sourced signatures for Array#each still report block_required? == true for the yielding overload, confirming stdlib overload disambiguation (block vs no-block returning Enumerator) is unaffected

Fixes #1265

Generated with Claude Code

https://claude.ai/code/session_01TNnFsUN4Uryo6Xqh7xv2sr

Pin::Callable#arity_matches? rejected any call missing a block whenever
the method signature had block info attached, even when that info came
from a bare &block formal parameter with no @yield tags. Ruby never
requires callers to pass a block for such a parameter, so this caused
the sole matching signature to be discarded, skipping generic
resolution and leaving the return type as unresolved generic<T>.

Add Pin::Callable#block_required?, true only for RBS-sourced signatures
with a non-optional block ({ ... } vs ?{ ... }), and gate the arity
check on it instead of bare block presence. YARD-derived signatures
have no way to express a required block, so they default to false.

Fixes castwide#1265

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNnFsUN4Uryo6Xqh7xv2sr
apiology added a commit to apiology/solargraph that referenced this pull request Aug 7, 2026
Adds Pin::Callable#block_required? (default false), true only for
RBS-sourced signatures with a non-optional block (`{ ... }`, not
`?{ ... }`). Pin::Callable#arity_matches? previously rejected a call
missing a block whenever the signature declared *any* block, even an
optional one - so a bare `&block`/@yield-tag signature with a @Generic
return type would get skipped by overload resolution for a callsite
with no block, falling through to a less specific overload and losing
the generic. Now it only rejects when block_required? is true.

Conflict in lib/solargraph/rbs_map/conversions.rb: HEAD didn't know
about the new block_required: keyword yet; took the incoming side,
which wires overload.method_type.block&.required into the new
Pin::Signature parameter (mirroring the equivalent, non-conflicting
change already applied to rbs_translator.rb#to_signature).

Verified: spec/source/chain/call_spec.rb, spec/rbs_map/conversions_spec.rb,
spec/rbs_translator_spec.rb (59 examples, 0 failures, 3 pending), and a
broader safety net - spec/type_checker, spec/source,
spec/source_map/clip_spec.rb, spec/pin (792 examples, 0 failures, 25
pending) - all passing locally.
@apiology

apiology commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Posted by Claude, not the account owner — acting on their behalf via their GitHub credentials.

Re-tested this fix against apiology/checkoff (pulling the apiology/solargraph fork's 2026-08-04 branch, which has this PR's commits merged in as of 0cb1a0e). The original #1265 symptom is genuinely gone — 71 @sg-ignore workarounds for it now correctly report Unneeded @sg-ignore comment and were removed. But the same update introduces a new regression: a generic type parameter now leaks into inferred types for code that has nothing to do with any @generic declaration at all — 50 new failures appeared across 13 files in that project alone.

Minimal reproduction

# typed: false
# frozen_string_literal: true

class SingleFetch
  # @param data [Hash{String => String}]
  # @return [String]
  def value(data)
    data.fetch('key')
  end
end
$ bundle exec solargraph typecheck --level strong single_fetch.rb
single_fetch.rb:7: Declared return type ::String does not match inferred type ::String, generic<X> for SingleFetch#value

No block param, no @generic tag anywhere in the file or the rest of the loaded workspace — just a single Hash#fetch call on a Hash{String => String}-typed parameter.

Bisection

Same file, same .solargraph.yml, only the solargraph gem revision changed:

  • apiology/solargraph@10c5a5502259088903e41010e295a16c2bd7cdaf (before this PR's commits): 0 problems.
  • apiology/solargraph@0cb1a0e24cf07b1aa2af66aa17295ff79509c69e (2026-08-04 branch with this PR merged in): 1 problem, as shown above.

Observed pattern

Across the 50 new failures in checkoff, the shape is consistently a union — the correctly-resolved type survives, with an extra unresolved generic placeholder appended alongside it (String, generic<X>, Hash{String => String}, generic<X>, String, generic<T>, etc.) — never a total loss of type info the way the original #1265 bug was. The placeholder letter varies (X in Hash#fetch-shaped failures, T in others) and doesn't correspond to any generic name declared in the affected project, which points at internal generic-parameter names (e.g. from Hash's own RBS generic signature) leaking through rather than anything project-specific.

Guess, not confirmed by reading the diff: the fix for #1265 changed the return-type substitution/binding step so it stops discarding a generic binding when a block param is present. Hash, Array, etc. are themselves RBS-generic classes, so every #fetch/#[] call already exercises that same binding step regardless of whether the calling code ever writes @generic. If the fix removed or weakened whatever previously collapsed the placeholder away after substitution, that would explain a placeholder now surviving alongside the resolved type on these totally unrelated core-class calls, instead of being fully replaced.

Happy to share the full checkoff diff (50 new @sg-ignore tool-limitation:pr-1274-follow-on markers, one per failure site) if useful for cross-referencing failure shapes.

@apiology

apiology commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Posted by Claude, not the account owner — acting on their behalf via their GitHub credentials.

This reproduces, but it's a stale on-disk pin cache, not a regression in this PR's diff.

PinCache.work_dir keys the cache directory only on Solargraph::VERSION, a static string this PR doesn't bump. Marshal.load silently tolerates deserializing pins written by the older Pin::Callable schema (a missing @block_required ivar just reads back as nil/false). So a bundle update that swaps the gem revision without also clearing ~/.cache/solargraph reuses pins from before this fix — block_required? comes back false for an RBS signature whose block is genuinely required, the block-only generic overload spuriously matches a no-block call site, and generic<X> leaks into the inferred return type. Matches the reported shape exactly.

Confirmed directly: populated the cache on the commit before this PR, switched to the commit with this PR merged under the same Solargraph::VERSION, without clearing the cache — reproduced single_fetch.rb's exact error. A fresh cache (or clearing ~/.cache/solargraph) makes it go away; no code change needed here.

Opened a follow-up so this class of bug can't recur silently on future PRs that don't bump VERSION: folds a digest of solargraph's own lib/ into the cache key so any code change busts it. apiology#54: apiology#54 (draft/WIP)

apiology added a commit to apiology/checkoff that referenced this pull request Aug 11, 2026
castwide/solargraph#1274 (fixes #1265) is still open upstream, but
its commit is already an ancestor of our pinned fork revision, and
this call shape still fails -- confirmed via strip-and-observe,
including ruling out the ensure clause as the cause. Follow-on gap
building on PR 1274, matching the pr-1231-follow-on precedent.

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

Copy link
Copy Markdown
Contributor Author

🤖 Posted by Claude, not the account owner — acting on their behalf via their GitHub credentials.

Found a related gap while auditing @sg-ignore suppressions in a downstream project. This PR's title says the loss happens "when the method also declares a block parameter," but the minimal repro below shows neither an explicit &block parameter nor .yield-via-block-object is required — bare yield alone triggers the identical failure, as long as @generic T is combined with @yieldreturn [generic<T>].

Reproduction

class Repro
  # @generic T
  # @yieldreturn [generic<T>]
  # @return [generic<T>]
  def call
    yield
  end
end
$ solargraph typecheck --level strong repro.rb
repro.rb:5: Repro#call return type could not be inferred

Reproduced on apiology/solargraph@71f16f832d6ee1790c3db0f868ff0335502438da (2026-08-04 branch, includes this PR's commits).

UniqueType#resolve_generics was resolving generic<T> against the
receiver's binder type whenever definitions.generics was non-empty,
without checking whether those generics belonged to the enclosing
namespace or to the method/signature itself. For a method with a
method-scoped @Generic T combined with @yieldreturn [generic<T>],
Chain::Call#yield_pins produces a proxied Signature pin whose closure
is the outer Signature (generics: ["T"]), not a Pin::Namespace. Since
the class itself carries no type params to bind against, resolution
fell into the UNDEFINED branch, making TypeChecker report "return
type could not be inferred" even though @Generic T is legitimately
unresolved at this point (it's bound per call site, not per
receiver.

Guard resolve_generics to skip when definitions is a Pin::Callable
(Method/Signature), leaving such types unresolved instead of eagerly
erasing them -- matching the already-passing behavior for a plain
generic-typed parameter referenced from a method body.

Reported in castwide#1274 (comment)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuQJXiNek2mabhwLUtBgYY
EOF
)
apiology added a commit to apiology/solargraph that referenced this pull request Aug 11, 2026
@apiology
apiology marked this pull request as ready for review August 11, 2026 19:28
@apiology

Copy link
Copy Markdown
Contributor Author

Ready for review

@apiology

Copy link
Copy Markdown
Contributor Author

Claude: Written by Claude and posted using @apiology's GitHub credentials.

Confirming this fixes the reported symptom: with this branch in our baseline, both arms of #1265's repro (wrap_no_block and wrap_with_block) now typecheck clean, so the block parameter no longer destroys the binding.

The helper underneath them still doesn't resolve, though — clazz.new on a Class<generic<T>> parameter isn't inferred as generic<T> inside the method body:

class Foo
  # @return [Integer]
  def foo_method
    1
  end
end

class Repro
  # @generic T
  # @param clazz [Class<generic<T>>]
  # @return [generic<T>]
  def create_object(clazz)
    clazz.new
  end

  # @param clazz [Class<Foo>]
  # @return [Foo]
  def create_concrete(clazz)
    clazz.new
  end

  # @return [Integer]
  def use_it
    create_object(Foo).foo_method
  end
end
$ solargraph typecheck --level strong repro.rb
repro.rb:15: Repro#create_object return type could not be inferred
1 problem found.

create_concrete is the control: Class<Foo>#new resolves to Foo fine, so it's specific to the generic form. use_it also passes — callers bind T correctly from the argument; it's only the method's own declared return that can't be inferred from clazz.new.

That accounts for 7 suppressions in our codebase, separate from the 14 tied to #1265 itself.

ComplexType::TypeMethods#namespace/#namespace_type/#scope treated any
Class<X>/Module<X> subtype as a concrete namespace, including when X is
itself an unresolved generic<T> placeholder. That produced the bogus
namespace tag "generic<T>", which ApiMap#get_method_stack could not
find methods for, so Chain::Call#resolve bailed out before ever
reaching the existing Class#new/reduce_class_type special-casing.

Treat Class<generic<T>>/Module<generic<T>> like a bare Class/Module (no
subtype) instead, so lookups go through Class's own instance-scope
#new. ApiMap#get_methods synthesis of Class#new from #initialize also
needed a matching guard: for a bare Class namespace there is no
concrete #initialize to find, and looking one up recursed back into
the same Class#new pin forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JKw9HkJuhpHWoMmqKdDMm
apiology added a commit to apiology/solargraph that referenced this pull request Aug 14, 2026
…eclares a block param

# Conflicts:
#	lib/solargraph/complex_type/type_methods.rb
@apiology

Copy link
Copy Markdown
Contributor Author

Claude: Written by Claude and posted using @apiology's GitHub credentials.

clazz.new(:foo, **args)   # Too many arguments to Class#initialize

apiology added a commit to apiology/solargraph that referenced this pull request Aug 14, 2026
…ed Class#initialize arity check)

# Conflicts:
#	lib/solargraph/complex_type/type_methods.rb
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.

@generic T return type is lost when the method also declares a block parameter

1 participant