Align type params across declarations in module-self types and superclass validation - #3067
Merged
Merged
Conversation
…_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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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]madeModuleEntry#self_typesreturn[_Foo[A], _Foo[B]], andclass C[A] < Base[A]+class C[B] < Base[B]raised a falseSuperclassMismatchError. This broke Steep's module self type check for every class includingEnumerable, because rbs 4.1.2 renamed the coreEnumerable's type paramElemtoEwhilesig/shims/enumerable.rbsstill usesElem(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 intoModuleEntry#align_params/ClassEntry#align_params, shared by all five call sites, and returnsnilwhen the declaration already uses the entry's names. Also,Module::Self#hashno longer includeslocation.hash, matching==, so.uniqdeduplicates equal self types across files.