Skip to content

Align type params across declarations in module-self types and superclass validation - #3067

Merged
soutaro merged 4 commits into
masterfrom
claude/module-self-type-param-alignment-2wpbja
Aug 7, 2026
Merged

Align type params across declarations in module-self types and superclass validation#3067
soutaro merged 4 commits into
masterfrom
claude/module-self-type-param-alignment-2wpbja

Conversation

@soutaro

@soutaro soutaro commented Aug 6, 2026

Copy link
Copy Markdown
Member

When a module/class has multiple declarations with different (but compatible) type parameter names, the variables written in a non-primary declaration leaked through as free variables: module M[A] : _Foo[A] + module M[B] : _Foo[B] made ModuleEntry#self_types return [_Foo[A], _Foo[B]], and class C[A] < Base[A] + class C[B] < Base[B] raised a false SuperclassMismatchError. This broke Steep's module self type check for every class including Enumerable, because rbs 4.1.2 renamed the core Enumerable's type param Elem to E while sig/shims/enumerable.rbs still uses Elem (soutaro/steep#2256).

This PR renames the variables to the primary declaration's type parameter names in both places — the same alignment that methods, instance variables, and mixin arguments already receive — keeping the original locations. The substitution is extracted into ModuleEntry#align_params / ClassEntry#align_params, shared by all five call sites, and returns nil when the declaration already uses the entry's names. Also, Module::Self#hash no longer includes location.hash, matching ==, so .uniq deduplicates equal self types across files.

claude added 3 commits August 6, 2026 02:10
…_types

When a module has multiple declarations with different (but compatible)
type parameter names, `Environment::ModuleEntry#self_types` collected the
self type constraints of each declaration as-is. The type variables from
non-primary declarations were left as free variables that are not bound
to any type parameter of the module, so downstream tools (e.g. Steep's
module self type check) could never satisfy the constraint:

    # a.rbs
    module M[out A] : _Foo[A]
    end

    # b.rbs
    module M[out B] : _Foo[B]
    end

    entry.self_types  # => [_Foo[A], _Foo[B]]  (B is unbound)

Mixin members already get this alignment via `align_params` in
`DefinitionBuilder::AncestorBuilder#mixin_ancestors`, but module self
types did not. Fix it in `ModuleEntry#self_types` — the aggregation
point every consumer goes through — by renaming the type variables of
each declaration's self types to the primary declaration's type
parameters, using the same substitution as `mixin_ancestors`. The
`location` of substituted self types keeps pointing to the original
declaration, so error locations (NoSelfTypeFoundError,
InvalidTypeApplicationError) are unchanged.

Also drop `location` from `AST::Declarations::Module::Self#hash` to make
it consistent with `#==`/`#eql?`, which only compare `name` and `args`.
The inconsistency made the `.uniq` in `ModuleEntry#self_types`
ineffective across files, so identical self types from different
declarations were duplicated.

With both fixes, the example above now yields `[_Foo[A]]`.

This is what broke Steep's self check with rbs 4.1.2, where
core/enumerable.rbs renamed `Elem` to `E` while other environments still
declare `module Enumerable[unchecked out Elem] : _Each[Elem]`:
`one_instance_ancestors(::Enumerable).self_types` became
`[_Each[E, void], _Each[Elem, void]]`, failing every class that includes
Enumerable. (soutaro/steep#2256)

Note: `sig/shims/enumerable.rbs` intentionally keeps the `Elem` name —
this repository's own `steep check` runs on rbs 3.9 whose core still
uses `Elem`, and renaming the shim to `E` makes the self check fail
there. With the alignment fix the name difference is harmless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBY8ct4HpkVkZNPdsNHEDE
The superclass comparison across multiple declarations compared the
superclass args as written, so declarations that declare the same
superclass with different type parameter names (`class C[A] < Base[A]`
and `class C[B] < Base[B]`) raised a false SuperclassMismatchError.
Align the args to the entry's type parameter names before comparing,
like ModuleEntry#self_types and mixin_ancestors do.

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

The substitution that renames a declaration's type parameters to the
entry's type parameters was built inline in five places: MethodBuilder,
DefinitionBuilder#define_instance, AncestorBuilder#mixin_ancestors,
ModuleEntry#self_types, and AncestorBuilder#validate_super_class!.
Define it once as ModuleEntry#align_params / ClassEntry#align_params and
use it from all of them. The method returns nil when the declaration
already uses the entry's type parameter names, so the callers can skip
the substitution in the common case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBY8ct4HpkVkZNPdsNHEDE
Type params validation runs before the alignment, so the arity mismatch
raises GenericParameterMismatchError instead of building a broken
substitution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBY8ct4HpkVkZNPdsNHEDE
@soutaro soutaro changed the title Align module-self type params across declarations in ModuleEntry#self_types Align type params across declarations in module-self types and superclass validation Aug 7, 2026
@soutaro
soutaro added this pull request to the merge queue Aug 7, 2026
Merged via the queue into master with commit 6c2f00c Aug 7, 2026
48 checks passed
@soutaro
soutaro deleted the claude/module-self-type-param-alignment-2wpbja branch August 7, 2026 02:05
soutaro added a commit to soutaro/steep that referenced this pull request Aug 27, 2026
Bump the gem dependency to `rbs` 4.2 as well. ruby/rbs#3067 fixes type
param alignment for module self types, which Steep relies on when it
validates mixin constraints, and the fix is not backported to the 4.1
series.

RBS 4.2 requires Ruby 3.3, so `required_ruby_version` moves up to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
soutaro added a commit to soutaro/steep that referenced this pull request Aug 27, 2026
Require `rbs` 4.2 rather than 4.0. ruby/rbs#3067 fixes type param
alignment for module self types, which Steep relies on when it validates
mixin constraints, and the fix is not backported to the 4.1 series. The
Gemfile does not pin `rbs` at all anymore, now that the gemspec
constraint is enough.

RBS 4.2 requires Ruby 3.3, so `required_ruby_version` moves up to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
soutaro added a commit to soutaro/steep that referenced this pull request Aug 27, 2026
Require `rbs` 4.2 rather than 4.0. ruby/rbs#3067 fixes type param
alignment for module self types, which Steep relies on when it validates
mixin constraints, and the fix is not backported to the 4.1 series.

RBS 4.2 requires Ruby 3.3, so `required_ruby_version` moves up to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
felixefelip added a commit to felixefelip/steep that referenced this pull request Sep 8, 2026
…mmits (#159)

* bundle update

* bundle update

* feat: support the `it` block parameter

Recognize Ruby 3.4's `it` (e.g. `[1].map { it + 1 }`) by handling
the new :itblock node alongside :numblock across type checking,
source mapping, diagnostics, hover, and signature help.

This requires bumping Prism::Translation::Parser33 to Parser34.

Closes soutaro#1450

* bundle update

* bundle update

* bundle update

* bundle update

* bundle update

* bundle update

* Generate the changelog from the previous release tag

`rake changelog` searched for pull requests by milestone, which only works
while the milestone is maintained by hand and says nothing about what is
actually merged into the branch being released.

Replace it with `rake gem:changelog[version]`, which walks the commits
between the given version (derived from `Steep::VERSION` by default) and
`HEAD`, and asks GitHub which pull request each commit came from. Going
through `associatedPullRequests` rather than parsing commit messages means
every merge strategy works, and commits pushed straight to the branch are
left out on their own.

Where the changelog starts is the step that is easy to get wrong by hand: a
prerelease documents what changed since the previous prerelease, so it starts
from the latest tag, while a release proper documents the whole cycle and has
to skip the prerelease tags in between. Both follow from `Steep::VERSION`.

Pull requests labeled `skip-changelog` are omitted, and the omitted ones are
reported so they do not disappear silently. Commits that record a
`git cherry-pick -x` origin are attributed to the pull request the change was
written in rather than to the one that carried the backport.

Only the changelog itself goes to STDOUT, so the output can be piped.
`rake gem:changelog:json` prints the same pull requests with the changed
files, labels, and body of each, as the input for classifying them into the
sections of CHANGELOG.md.

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

* Remove the milestone check

Milestones were the input to `rake changelog`, which now derives the list
from the commits between the previous release tag and `HEAD`. Nothing reads
a milestone any more, so requiring one on every pull request -- and the
`no-milestone` label for the ones that legitimately have none -- is upkeep
with no consumer.

The automated pull requests that carried `no-milestone` now carry
`skip-changelog` instead, which is the label the changelog generation
actually looks at, and dependabot labels its own pull requests with it too.

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

* Release the gem from a workflow

README had the maintainer release from a laptop: bump `version.rb`, run
`bundle exec rake release`, and let the enhanced task chain draft the GitHub
release and label the released pull requests. That puts the RubyGems
credentials of whoever runs it in the path, and publishes whatever state the
working copy happens to be in.

`Release gem` does it instead. Dispatched with the commit being released and
the version that commit declares, it builds the gem, checks it, pushes it to
RubyGems through trusted publishing, and publishes the GitHub release. A
release is now a pull request and one workflow run.

The two inputs state the same fact twice -- once as a commit, once as a name
-- and the run stops before anything is built unless they agree with each
other and with the repository, so dispatching the wrong commit, or the right
one under the wrong name, is a failed run rather than a gem that has to be
yanked.

The built gem is installed the way a user would install it and used to type
check a small project, one that has to pass and one that has to fail: the
gemspec filters `git ls-files` by hand, so a missing file or a broken
dependency only shows when the installed gem actually runs.

Publishing is ordered so that the reversible step always comes first. The
tag is created by the workflow once the gem is known to build and run; the
artifact is uploaded before the push, so a failed push still leaves the gem
behind; and the GitHub release comes last, so a failed push never announces
a release that has no gem. `dry_run` stops after the artifact, which is how
a release is rehearsed.

`gem:check_release`, `gem:tag`, and `gem:gh_release` are what the workflow
runs; all three work locally too, which is the fallback if a release ever
has to be assembled by hand. `gem:gh_release` publishes rather than drafts:
the notes are the CHANGELOG.md section that was already reviewed in the
release pull request, so there is nothing left to edit. `.dev.N` versions
are not written up, so they get a tag and a gem and nothing else.

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

* Remove the release path that predates the release workflow

`rake release` published from a working copy, `release:note` and
`release:github` drafted what `gem:gh_release` now publishes, and
`release:release-prs` labeled pull requests `Released` so that the
milestone-based `rake changelog` could exclude them -- a consumer that is
gone now that the changelog is derived from the release tags. README pointed
at `rake release`.

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

* Add doc/release.md

How a release is prepared and cut, what the three kinds of release are, what
the version on `master` means, how a new minor is started, and how the
changelog is assembled -- with `rake gem:changelog`, or with the GitHub MCP
tools from a session where `gh` cannot reach the API.

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

* Version 2.1.0.dev.1

A dev release cut to exercise the new release workflow end to end.

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

* Bump json from 2.21.1 to 2.21.2 in /gemfile_steep

Bumps [json](https://github.com/ruby/json) from 2.21.1 to 2.21.2.
- [Release notes](https://github.com/ruby/json/releases)
- [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md)
- [Commits](ruby/json@v2.21.1...v2.21.2)

---
updated-dependencies:
- dependency-name: json
  dependency-version: 2.21.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Accept block-pass arguments for optional blocks

`&:sym` and `&method(:name)` failed to type check when the method
declares an optional block:

```
test.rb:1:13: [error] Cannot pass a value of type `::Proc` as a block-pass-argument of type `(^(::Integer) -> void | nil)`
│   ::Proc <: (^(::Integer) -> void | nil)
│     ::Proc <: ^(::Integer) -> void
│
│ Diagnostic ID: Ruby::BlockTypeMismatch
│
└ Foo.new.each(&:to_s)
               ~~~~~~
```

Both are special cased in `:block_pass` so that `Symbol#to_proc` and
`Method#to_proc` are given a proc type built from the expected block
type. The special cases match on `AST::Types::Proc`, but the hint of a
block-pass argument is `^(...) -> ... | nil` when the block is optional,
so neither applied and the argument kept the plain `::Proc` type it
converts to.

Hint the argument with the block type itself instead of the union.
`BlockPassArg` builds both, so it exposes `proc_type` next to
`node_type`, and the union stays where it belongs -- the subtyping check
that lets `nil` through for an optional block.

Fixes soutaro#1207

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

* Accept a type alias as the type of a lambda's block parameter

`# @type var blk: callback` on a lambda's block parameter reported
`Ruby::ProcTypeExpected`, while the same proc type written inline was
accepted.

`type_lambda` tests the structure of the annotated type: `Proc` for a
required block, and `optional_proc?` -- a two clause union of `Proc` and
`Nil` -- for an optional one. A type alias is a `Name::Alias`, so it
misses both, whether it stands alone or appears in the union.

Expand aliases before the tests. The diagnostic keeps reporting the type
as written, so a non-proc alias still says `::not_proc` rather than its
expansion.

Fixes soutaro#2262

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

* Fix Applying#map_type to use the mapped type arguments

`AST::Types::Name::Applying#map_type` built the mapped type arguments
and then threw them away, returning a copy with the original arguments.
Pass the mapped arguments to the new object instead.

Found via an "assigned but unused variable" warning from `ruby -w`.

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

* Call handle_job in tests that expect jobs to be skipped

test_handle_job_validate_lib_signature_skip and
test_handle_job_typecheck_skip constructed a job to verify that
`handle_job` writes nothing for it, but never called `handle_job`.

Found via "assigned but unused variable" warnings from `ruby -w`.

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

* Fix "assigned but unused variable" warnings

Running the test suite with `ruby -w` (which `rake test` enables) printed
about 180 "assigned but unused variable" warnings. Fix them by:

* Renaming unused targets of multiple assignments to `_`
* Dropping assignments whose right hand side has no side effects
* Removing computations whose results were never used

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

* Fix "mismatched indentations" warnings

`ruby -w` warned about `end` keywords whose indentation didn't match
their `def`. Align them with the method definitions.

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

* Fix "method redefined" warning in TypeCheckWorker

`attr_reader :service` was immediately overridden by the `#service`
method that lazily initializes `@service`, so `ruby -w` warned about the
redefinition. Drop `:service` from the `attr_reader` and keep the
explicit method.

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

* Silence "setting Encoding.default_external" warning in test_helper

Setting `Encoding.default_external` prints a warning under `ruby -w`.
The setting is intentional, so temporarily disable `$VERBOSE` around it.

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

* Support tuple hints through to_ary conversion

* Update RBS to 4.2.0.pre.1

* Point the Steepfile template, README and CLAUDE.md at rbs collection

Ask a coding agent to set up RBS and Steep in a project and it usually
writes a Steepfile that lists every dependency with `library`, one gem per
line. `rbs collection` exists so that nobody has to maintain that list, and
Steep already reads `rbs_collection.yaml` from the project root without
being told to (`Project::DSL::LibraryOptions#to_library_options`), so gems
in `Gemfile.lock` need no `library` call at all.

The places someone reads before writing a Steepfile say otherwise:

- The `steep init` template explained dependencies with
  `library "pathname"  # Standard libraries` and
  `library "strong_json"  # Gems`, and never mentioned rbs collection, so
  hand-written `library` calls read as the way to load RBSs.
- `README.md` showed a Steepfile with `library "pathname"` right after
  `steep init`. It is read far more than the generated template.
- `CLAUDE.md` described `library` as "Standard library dependencies" -- the
  description this change is correcting -- in the file coding agents read
  before working in this repository.

The template now points at `rbs collection init` / `rbs collection install`
and says what `library` is for: an RBS that rbs collection doesn't manage.
Not "an RBS without rbs collection": `guides/src/gem-rbs-collection/`
tells people to keep `library` calls for implicitly installed gems that are
not in the Gemfile even when they do use rbs collection.

The example is `monitor` rather than `pathname`. Since ruby/rbs 8066053b
moved `Pathname` to `core/pathname.rbs`, `stdlib/pathname/0` holds two
methods, so `library "pathname"` demonstrates nothing. `monitor` is a
non-gem standard library that rbs collection installs only when it is
listed in `rbs_collection.yaml`, which is what the comment above the line
claims.

Motivation and the matching ruby/rbs change: ruby/rbs#3093

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXtMkmp27LieHgSjNyEWZ8

* Regenerate sig for the added test

`test_rbs_check` runs `rake rbs:generate` and fails on any diff, and the
test added in the previous commit has no entry in
`sig/test/init_command_test.rbs` yet.

https://github.com/soutaro/steep/actions/runs/32571558366/job/97027618261

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXtMkmp27LieHgSjNyEWZ8

* Fix `bin/steep-check.rb` for the current API

The profiling driver has not been run since `SignatureService.load_from` and
`Interface::Builder.new` gained the `implicitly_returns_nil:` keyword, and
since RBS replaced `Environment#add_signature` with `#add_source`. It raised
before reaching the type checker, so none of the profiling modes worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Build expensive log tags only when something is logged

`Steep.logger.tagged` takes the tag as a string, so the hottest paths of the
type checker rendered a tag on every call and threw it away: `check_type`
stringified both sides of every relation, `Builder#shape` stringified the type
of every shape, and `synthesize` formatted a source range for every node. The
default log level is ERROR, so almost none of it was ever written.

`tagged` now also accepts a proc, which `formatted_tags` calls only while
writing a message, and the hot call sites pass one.

Checking `lib` single-threaded (`bin/steep-check.rb --target=app`) goes from
68.5s to 61.9s, and from 92.3M to 86.4M allocated objects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* bundle update

* Type check the reassigned paths again after refork

Reforked workers start from a copy of the primary worker's state, which holds the
results of the primary's assigned paths only, and the results the replaced workers
computed are discarded with them. Everything except the primary's share of the
project was left with no stored type checking result after the refork.

Nothing read the stored results back so far, so it went unnoticed; a request like
the upcoming `$/steep/query/diagnostics` observes it. The master now starts a type
check of all paths once the refork finishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add `steep query diagnostics`

`steep check` is the imperative "run the type check now" command, while the new
`steep query diagnostics` asks the running server for the diagnostics it has
computed -- designed for coding agents that edit files on disk and want fast
feedback without spawning a checker.

Typecheck workers return the diagnostics they have stored (LSP-formatted,
including signature validation results). The master waits until no type check is
running -- starting one for the paths that went dirty first -- then fans out to
all workers and merges the results. Requested files the server has not type
checked are reported with `diagnostics: null`, as distinct from a file with no
errors. The CLI prints the result as JSONL, one line per file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a command socket that forwards LSP messages to the master

`Server::CommandSocket` accepts connections on the per-project UNIX socket
and forwards each message it receives to the master, which processes it as
if it came from the IDE: the request gets a fresh id, and the response is
routed back to the originating session. `textDocument/publishDiagnostics`
and `window/showMessage` are copied to the sessions waiting on a
`$/steep/typecheck` request, which is what lets `steep check` render
diagnostics in server mode.

Only the methods that `steep check` and `steep query` use are accepted.
Everything else is rejected -- lifecycle methods because the server belongs
to whoever started it, and document synchronization because the LSP client
owns the content of the files it opens: a `didOpen` from a socket client
would mark the file as client-owned, and nothing would ever unmark it.

Each connection is served on its own thread, with the session passed as a
thread argument -- `while` shares its locals across iterations, so a block
reading the variable would serve a later iteration's session whenever the
accept loop wins the race against the thread's start, leaving the accepted
connection unread. The accept loop logs the connection lifecycle and
survives unexpected errors in its body: it runs with `abort_on_exception`
disabled, so an uncaught error would kill it silently and every following
connection would sit in the backlog forever.

Nothing binds the socket yet; `steep langserver` starts serving it next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Serve the command socket from `steep langserver`

The language server binds the per-project UNIX socket that the
`steep server` daemon serves, so `steep check` and `steep query` can talk
to the language server the editor is already running instead of requiring
a separate daemon process. Whichever server binds the socket first serves
it: the language server skips the socket when another process is already
serving it, and `--no-command-socket` disables it altogether.

The error messages of `steep query` now name `steep langserver` as a way
of getting a server running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Ask the socket instead of the pid file to detect a running server

The pid file has one meaning: the daemon that `steep server stop` may
signal. A language server serving the command socket records no pid --
nothing may signal it, its lifecycle belongs to whoever started it -- so
`Daemon.running?` probes the socket instead of requiring the pid file, or
`steep check` and `steep query` would not find a language server.

The `steep server` commands need no notion of what else may be serving.
They act on their two facts -- whether the socket is served, and whether
the pid in their own file is alive -- and otherwise report that the socket
belongs to another process. Two side effects fall out: `steep server stop`
with a stale pid file no longer deletes a socket another process is
serving, and `steep server start` while the pid file holds a live pid -- a
daemon still binding its socket -- no longer wipes that pid file and forks
a second daemon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reload files changed on disk before serving a command socket request

Command socket clients -- coding agents editing files with whatever tool
they have -- modify files without sending `didChange` notifications, so the
server would answer their requests from stale content. The master records
the mtimes of the project files when they are loaded, and sweeps them
before processing a message from the socket, reloading what changed. Files
open in the editor are skipped: the client owns their contents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Never supersede a type check a command socket client waits for

Starting a type check superseded the running one. For the IDE that is
right -- newer edits win -- but a command socket client waits for the
response of its own request, and superseding it truncated the check.
Nothing reads the `completed` flag of the response, so `steep check`
would print the diagnostics of the files that happened to be checked as
if they were the whole project.

A type check requested through the socket is now started only when no
other one is running, and queued otherwise; the automatic type checking
of the editor's edits is deferred the same way while it runs. The
quiescent callbacks wait for the queue to drain as well. Type checks
nobody waits for keep superseding each other.

The `$/steep/typecheck` handler passes the running request as
`last_request:`, so a superseded request is finished and answered, and
its unchecked paths are merged into the request that replaces it.
Neither happened before: the client was left waiting for a response that
never came.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep a wedged language server from hanging the test suite

`start_langserver` waited for the language server without a bound: first
in the `shutdown` handshake, then in `reader_thread.join` and the process
wait of `Open3.popen2`. A server that stops responding kept the whole
suite on `Process.wait` until the CI job was cancelled, with the server's
process tree still alive, and the test output showed nothing but a bare
`Timeout::Error`.

The shutdown handshake and the joins are bounded now, and a server that
outlives the bound is killed through its process group. The server runs
with `--log-level=info` and its stderr goes to a file that is dumped into
the test output when a test fails or the server has to be killed -- and
dumped once more at the very end of the teardown, because the dump at
failure time misses what the server writes afterwards, like the shutdown
handshake or the kill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Adapt to the core signature changes in RBS 4.2

RBS 4.2 makes the elements coming from the other arrays optional in the
return type of `Array#zip`, and gives `Hash#fetch` a `Hash::_Key`
parameter. Both are used by Steep itself, so `bin/steep check` started
reporting errors against `lib/`.

`check_type_arg` now asserts that the type argument and the type
parameter it zips are there -- they are, because the two types share a
name and a class -- the way the other `zip` calls in the file already do.
`fetch_cache` bounds its key type parameter by `Hash::_Key`, which also
makes the `steep:ignore` on the `key?` call unnecessary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Skip inline sources when looking up Ruby definitions

`constant_definition_in_ruby` and `method_locations` walk every type
checked source file and demand a target from `target_for_source_path`,
raising when there is none. Inline sources have no such target -- only
`target_for_inline_source_path` returns one -- but `source_file?` accepts
them, so they land in `source_files` all the same, and once one has been
type checked it carries a `typing` and reaches the `raise`.

In a project with an inline target, that made `steep query definition`
hang for good: the worker died on the exception, and `BaseWorker#run`
answered it with `window/showMessage` alone, so the client waited for a
response that never came. Before anything was type checked no source had
a `typing`, which is why the query only broke after the first check.

Skip those files instead. An inline source has no Ruby definition to go
to; its declarations are found through the RBS index, which is where
`in_rbs` already looks for them.

The test helper type checks inline sources now too, the way
`TypeCheckWorker` does through `TypeCheckInlineCodeJob` -- without that
no test reached the `raise`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Answer a request with an error when its job raises

A job that raised was logged and reported through `window/showMessage`,
and nothing else. For a job that answers a request, the client was left
waiting for a response that never arrived, so every bug of this kind
surfaced as a hang rather than an error -- which is how the inline source
lookup above went unnoticed.

Jobs carrying an `id` are the ones answering a request, so reply to them
with an LSP error response as well. `ResultHandler` then completes and
`Master` moves on: the group handlers skip a response without a `result`,
so the client gets whatever the other workers found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Remove the `if false` block from the gemspec

It existed to make dependabot read 3.3 rather than the declared
`required_ruby_version`, but dependabot no longer updates the gems here:
`.github/dependabot.yml` only covers GitHub Actions, and gem updates come
from the `bundle update` workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Bump actions/checkout from 6 to 7

Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Add regression tests for pair interface hints on array literals

The tuple inference for `Hash::_Pair`-style interface hints landed in
81cb48c (Support tuple hints through to_ary conversion). These tests
cover the end-to-end behavior: interface hints, intersection hints,
multi-parameter blocks, and the tuple-typed diagnostic reported for a
mismatched pair body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update RBS to 4.2.0

Require `rbs` 4.2 rather than 4.0. ruby/rbs#3067 fixes type param
alignment for module self types, which Steep relies on when it validates
mixin constraints, and the fix is not backported to the 4.1 series.

RBS 4.2 requires Ruby 3.3, so `required_ruby_version` moves up to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop checking whether `Process.warmup` is defined

It landed in Ruby 3.3, which is now the minimum supported version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Version 2.1.0

Dependabot and `bundle update` pull requests are left out of the changelog,
as in every release before this one; soutaro#2217, which bumped the development
version, is left out for the same reason the release pull request itself is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add subtyping cache stats collection to steep check

Setting STEEP_SUBTYPING_STATS=1 makes each worker process report cache
statistics on exit: hits and computes with their per-kind breakdown,
reflexive relations, cache entry composition, and top-level compute
time. The stderr summary contains only counts and type kinds so it can
be shared without exposing type names; STEEP_SUBTYPING_STATS_FILE
appends a JSON report including the most frequent relations, and
STEEP_SUBTYPING_STATS_MEMORY measures the memory exclusively retained
by the cache. With the variables unset, the cost is a nil check per
check_type call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Count cache misses caused by context keying

The cache key includes the (self_type, instance_type, class_type,
bounds) context, but the result of a relation without free variables
does not depend on it. Count computes of such ground relations that
were already computed under another context, and report them as
context-fragmented misses: on Steep itself they are 55-70% of all
computes, so keying ground relations by the relation alone is the
largest available improvement to the hit rate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Report the relations with the highest compute time

Track top-level compute time per relation and add
top_relations_by_compute_time to the JSON stats report. After removing
the context-fragmented misses, the remaining subtyping time concentrates
in a small number of expensive relations (interface checks above all),
and this ranking identifies them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Measure shape building time in the stats report

Interface subtyping builds the full shape of both sides to compare only
the interface's methods, and shape building also runs during method call
resolution outside the subtyping check. Time Interface::Builder#shape
(outermost calls), report the total and a per-target-kind breakdown on
stderr, and add the most expensive shape targets to the JSON report. On
Steep itself shape building takes 4-5x the subtyping compute time, so it
is the next optimization target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Skip sorting method defs of single-def overloads

`Shape::MethodOverload#initialize` sorted its defs by a location string
built with concatenation, allocating a string per def and running
`sort_by`/`uniq!` even for the empty and single-def arrays that shape
construction and substitution pass most of the time.

Sort and dedupe only when there are two or more defs, and compare
`[buffer name, start position]` tuples instead of concatenated strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Share method overloads and entries between shapes

`Interface::Builder` converted every type def of every method into a
fresh `Shape::MethodOverload` for each type, although RBS definitions
are flat and the methods inherited from Object, Kernel, etc. appear in
the definition of every type. Building the shapes of all 1,800 types in
the environment of Steep's own `app` target converted 857k type defs
while only 24k of them are distinct.

The conversion is a pure function of the method name and the type def,
so the resulting overload is immutable and can be shared between the
shapes of all types. The same holds for whole `Shape::Entry` objects
when the method converts to the same overloads. The only exception is
`Kernel#class`, whose return type is replaced with the type being built,
so methods named `class` keep the per-type conversion.

The caches hit on the identity of the `Definition::Method`/`TypeDef`
objects, which the definition builder shares between definitions unless
a substitution applies, and fall back to an index keyed by the
identities of the type def components, so rebuilt-but-identical type
defs share one conversion too. Value-based keys don't work here because
`RBS::MethodType` has no value-based `hash`.

Building the shapes of all types in the environment of Steep's `app`
target drops from 9.9s to 3.8s and from +269MB to +150MB, converting
24,480 overloads and 19,876 entries instead of 857k/584k.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Enumerate method names without resolving entries

`Shape::Methods#each_name` iterated with `each`, which resolves every
entry -- applying the pending substitutions and rebuilding every
overload -- just to yield the names. `union_shape` computes the common
method names of the member shapes with it, so building a union shape
materialized every method of every member, while only the entries of
the common methods are used afterwards.

`key?` decides if the method exists without resolving the entry, so
iterate the raw table with it instead. Computing the common method
names was 60% of `union_shape`, which was 7.8% of type checking Steep's
own `app` target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Copy method entries lazily in Shape::Methods#merge!

`merge!` iterated `other` with `each`, resolving every entry -- applying
the pending substitutions and rebuilding every overload -- to insert
them into the target table. Tuple, record, and proc shapes copy the
whole Array/Hash/Proc shape this way, and intersection shapes copy
every member shape, while only a few of the copied methods are used
afterwards.

Insert a lazy entry that resolves the original entry when it is used
instead. The entries without method types are skipped like before, and
the visibility is available on the unresolved entry, so the
`intersection_shape` block works without forcing the resolution.

Copying the entries was 7.1% of type checking Steep's own `app` target,
mostly under tuple and record shapes, and drops below 0.5% with the
lazy copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cache the shapes of closed types in Interface::Builder

`Builder#shape` built a fresh shape on every call: `raw_shape` returned a
new `Shape` with an empty `resolved_methods`, and `Config#subst` pushed
another layer with its own empty cache, so the method types of a type
were substituted again at every call site.

The raw shape of a closed type -- one with no `self`, `instance`,
`class`, or type variables -- is a function of the type alone. And when
every component of the type resolves `self`/`instance`/`class` by itself
(class instances, singletons, literals, `nil`, `bool`, procs, tuples,
and records, and unions, intersections, and aliases of them), the
substitution of `Config` has nothing left to replace. Cache the raw
shapes of such types in `closed_shape_cache` and return them without the
`Config` layer, so that the entries resolved in a shape are reused
across calls. The components of union, intersection, and alias types go
through the cache too, so the members of `Foo | nil` no longer resolve
the methods of `nil` for each union.

Interface types are excluded because `interface_subst` resolves only
`self`, leaving `instance`/`class` to `Config`. The expansion of an
alias must be closed as well, because the free variables of an alias
type do not include the ones of its expansion (RBS accepts
`type foo = Array[self]`).

Type checking `lib` of Steep goes from 42.5s to 35.8s (-16%), with 13%
fewer allocations and about 13% less heap retained after the check; 66%
of the `shape` calls are served from the cache. The diagnostics are
identical.

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

* Index shared overloads and entries by identity buckets

The two indexes that share overloads and entries between shapes were
keyed by arrays -- `[member id, type id, defined_in, implemented_in]`
and `[visibility, overloads]` -- so every lookup hashed an array, and
every entry retained its key array.

Key them by one identity instead, and compare the remaining components
in the bucket: the type of the type def discriminates 23,436 of the
24,472 overloads of the self-check environment (99th percentile bucket
size 1), and the first overload discriminates 19,680 of the 19,870
entries (largest bucket 3). A bucket holds an array only where the
identities collide.

Building the shapes of all types in the environment of the `app` target
drops from 1.51-1.89s to 1.13-1.18s and from 78.1MB to 75.4MB -- the
key arrays are no longer allocated or retained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Return the overload itself from MethodOverload#subst when unchanged

`MethodOverload#subst` allocated a new overload even when
`MethodType#subst` returned the method type itself, which is the common
case: most method types mention no `self`, `instance`, `class`, or type
variables, and the shapes of closed types now resolve their entries
once. Return `self` in that case.

The overloads are immutable after construction, so sharing them is
safe. Type checking `lib` of Steep retains about 2.6% less heap (251.9MB
to 245.2MB) with 0.5% fewer allocations; the time is unchanged, and the
diagnostics are identical.

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

* Add sig for the added test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep the documentation of Stats in the signature

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Cache ground subtype relations without the context

The cache key included the (self_type, instance_type, class_type,
bounds) context, but the result of a relation without free variables
does not depend on it -- the self/instance/class types count as free
variables. Keying such ground relations per context made 55-70% of all
computes recomputations of relations already decided under another
context, and stored ~3 copies per relation.

* Store ground relations in a flat relation-to-result table, keeping
  the context key only for relations with free variables. The ground
  path also skips cache_bounds, the free_variables union, and the
  unknown-constraint check.
* Resolve trivially successful relations (T <: T, untyped on either
  side, top/void as super type, bot as sub type) without consulting
  the cache; check_type0 decides them in its first branches, so
  caching them only costs memory and lookups.
* Store successful results without their derivation tree, which is
  only read through Result::Base#failure_path to explain failures.

Checking Steep itself: 71% fewer subtype computes, hit rate 57.6% to
71.3%, cache entries 80,286 to 21,450, memory exclusively retained by
the cache 70.2MB to 24.1MB, with identical diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Regenerate sig for the added tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Type the cache tables and keep their documentation in the signature

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Add a shape warmup benchmark

`bin/shape_warmup_bench.rb` estimates the boot cost of a zygote process
that builds the shapes ahead of time: it loads the project, builds the
RBS definitions and the shapes of every type in the environment, and
reports the time, memory, and GC of each phase, split between the types
of the project and the ones of the libraries.

It can also record the types a type check of the target actually
touches and warm only those, and fork workers that type check their
share of the files to measure the memory each one owns privately -- the
number that decides how many workers fit in memory. The report contains
no type names, so it can be shared.

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

* Add a benchmark of the type checking itself

`steep check` measures the type checking together with the boot, the
signature loading, and the scheduling of the workers, which makes the
effect of a change to the type checker hard to read from its wall-clock
time.

`bin/typecheck_bench.rb` loads the project once and type checks the
source files of the targets in one process, reporting the time,
allocations, GC, and retained memory of the type checking alone. It
takes the same TARGETS and STACKPROF options as the shape warmup
benchmark, and FILES limits the files for quick comparisons. The script
needs nothing but the `steep` gem, so a copy of it can measure an older
Steep too.

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

* Explain the elements of the cache key

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Time out the subprocesses of DaemonTest

`steep check` waits for the response of the daemon without a timeout,
so when the daemon stopped responding on CI, the test blocked until the
job was cancelled six hours later, and the log told neither which test
it was -- `setup` skipped `super`, so the class printed no Start/End
markers -- nor what the daemon had been doing.

`sh` now runs the command with `Open3.popen2`, kills it when it does
not finish in 120 seconds, and fails the test with the daemon log. A
failing test prints the daemon log from `teardown` as well, and
`setup`/`teardown` call `super` so that the markers are printed.

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

* Fix Literal#hash to depend on the value

Literal#hash returned the same value for every literal type while #==
compares the values, so every hash keyed by types -- the subtyping cache
above all -- degenerated into linear bucket scans as literal relations
accumulated. A benchmark checking 200 distinct literals against a
200-literal union could not finish in minutes before this change and
takes milliseconds after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a membership fast path for literal unions

Checking a literal against a union of literals tried the branches one
by one, building an Any result with a failure tree for every branch
that did not match. When the union has no free variables and contains
an equal literal, return a plain Success instead: success derivations
are never inspected, so the result is indistinguishable. Unions with
unknown type variables keep the branching path so that constraint
recording is not skipped, and failures keep it so that diagnostics do
not change. Enum-like aliases hit this path after alias expansion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Regenerate sig for the added tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Add an in-process benchmark for the subtyping cache

Runs signature validation and source type checking in a single process,
so that one Subtyping::Check and its cache serve every file, and
measures the cache with it disabled or removed, cleared per file, or
with its memory analyzed (entry composition, reachable size, memory
freed by clearing). Also reports the share of type checking spent in
subtyping and hit locality across files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Handle a singleton method definition on an untyped receiver

`TypeConstruction#for_new_method` reads the call context of the method
out of `self_type`, which for `def obj.foo` is the type of `obj` and can
be any type the receiver evaluates to. Only `nil`, an instance, a
singleton and an intersection were handled and anything else raised, so
defining a method on an `untyped` receiver -- what RBS does in
`with_aliases.rb` -- ended as a `Ruby::UnexpectedError` covering the
whole definition.

Fall back to `UnknownContext`, which a method without a `self_type`
already uses: the definition belongs to no type we can name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Soutaro Matsumoto <matsumoto@soutaro.com>
Co-authored-by: Takeshi KOMIYA <i.tkomiya@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Soutaro Matsumoto <soutaro.matsumoto@shopify.com>
Co-authored-by: Masataka Pocke Kuwabara <kuwabara@pocke.me>
Co-authored-by: Erik Berlin <sferik@gmail.com>
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.

2 participants