From b572aba1ccb37b1030d554d7607cedc6b7c280ae Mon Sep 17 00:00:00 2001 From: Masataka Pocke Kuwabara Date: Sat, 22 Aug 2026 20:51:35 +0900 Subject: [PATCH 1/2] Say where the gem list comes from when `rbs collection init` runs 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: it reads `Gemfile.lock`, and `rbs` and Steep load the installed RBS files on their own. Agents rarely find it, and the tools we ship are part of why. `rbs collection init` printed one line, `created: rbs_collection.yaml`. It named neither the next command nor the fact that would stop someone from enumerating gems. The generated `rbs_collection.yaml` had the same gap: its comments explained `sources`, `path` and `gems[].ignore` -- the knobs -- but never said the gem list is read from `Gemfile.lock`. The file is read right after it is written, so it is a good place to say so. - `init` now prints what the command does, that gems in `Gemfile.lock` need no listing anywhere, and the next steps: `.gitignore` and `rbs collection install`. `rbs_collection.lock.yaml` is what pins the RBS revisions -- the source is `revision: main` -- so the output says to keep it in version control. - The generated config opens with the same two facts. - Its commented-out `gems:` example showed only `ignore: true`. The other half -- adding an entry for a library that `Gemfile.lock` doesn't carry -- was documented only in `docs/collection.md`. `socket` is the example: it is a non-gem standard library (`NONGEM_STDLIBS`), unlike `pathname`, whose types moved to `core/pathname.rbs` in 8066053b, and unlike `csv`, which is in `ALUMNI_STDLIBS` and warns when installed. - `docs/collection.md` gets a quickstart, so readers who see only the first screen get the two commands. Its `cat rbs_collection.yaml` had drifted from the generated file (no `type: git`, no local source comment) and now matches it byte for byte. The `.gitignore` paragraph right below the `init` output is gone, since that output now says it. Ref: https://github.com/ruby/rbs/issues/3093 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BXtMkmp27LieHgSjNyEWZ8 --- docs/collection.md | 44 +++++++++++++++++++++++++++++++++----------- lib/rbs/cli.rb | 17 ++++++++++++++++- test/rbs/cli_test.rb | 24 ++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/collection.md b/docs/collection.md index ffebc9b832..45417080cb 100644 --- a/docs/collection.md +++ b/docs/collection.md @@ -2,6 +2,17 @@ `rbs collection` sub command manages third party gems' RBS. In short, it is `bundler` for RBS. +## Quickstart + +In a project that has a `Gemfile.lock`: + +```console +$ rbs collection init +$ rbs collection install +``` + +`rbs collection` reads `Gemfile.lock`, so there is no list of dependencies to maintain. `rbs` and type checkers such as Steep load the installed RBS files automatically. + ## Requirements * `git(1)` @@ -17,33 +28,44 @@ First, generate the configuration file, `rbs_collection.yaml`, with `rbs collect $ rbs collection init created: rbs_collection.yaml +rbs collection installs RBS files for the gems in your Gemfile.lock. +Gems in Gemfile.lock don't need to be listed anywhere. + +Next steps: + $ echo "/.gem_rbs_collection/" >> .gitignore + $ rbs collection install # writes rbs_collection.lock.yaml; keep it in version control + $ cat rbs_collection.yaml +# rbs collection installs RBS files for the gems in your Gemfile.lock. +# Run `rbs collection install` to resolve them into rbs_collection.lock.yaml and install them. + # Download sources sources: - - name: ruby/gem_rbs_collection + - type: git + name: ruby/gem_rbs_collection remote: https://github.com/ruby/gem_rbs_collection.git revision: main repo_dir: gems +# You can specify local directories as sources also. +# - type: local +# path: path/to/your/local/repository + # A directory to install the downloaded RBSs path: .gem_rbs_collection # gems: +# # RBS for a library that doesn't appear in Gemfile.lock, such as a non-gem standard library. +# - name: socket +# # # If you want to avoid installing rbs files for gems, you can specify them here. # - name: GEM_NAME # ignore: true ``` -I also recommend updating `.gitignore`. - -```console -$ echo /.gem_rbs_collection/ >> .gitignore -``` - ### Install dependencies Then, install gems' RBS with `rbs collection install`! It copies RBS from [the gem RBS repository](https://github.com/ruby/gem_rbs_collection) to `.gem_rbs_collection/` directory by default. -I recommend to ignore `.gem_rbs_collection/` from version control system, such as Git. ```console $ rbs collection install @@ -86,9 +108,9 @@ sources: path: .gem_rbs_collection gems: - # If the Gemfile.lock doesn't contain csv gem but you use csv gem, - # you can write the gem name explicitly to install RBS of the gem. - - name: csv + # If the Gemfile.lock doesn't contain socket but you use it, + # you can write the library name explicitly to install RBS of the library. + - name: socket # If the Gemfile.lock contains nokogiri gem but you don't want to use the RBS, # you can ignore the gem. diff --git a/lib/rbs/cli.rb b/lib/rbs/cli.rb index bf3a3c22af..eaeff09ee8 100644 --- a/lib/rbs/cli.rb +++ b/lib/rbs/cli.rb @@ -1104,6 +1104,9 @@ def run_collection(args, options) end config_path.write(<<~'YAML') + # rbs collection installs RBS files for the gems in your Gemfile.lock. + # Run `rbs collection install` to resolve them into rbs_collection.lock.yaml and install them. + # Download sources sources: - type: git @@ -1120,11 +1123,23 @@ def run_collection(args, options) path: .gem_rbs_collection # gems: + # # RBS for a library that doesn't appear in Gemfile.lock, such as a non-gem standard library. + # - name: socket + # # # If you want to avoid installing rbs files for gems, you can specify them here. # - name: GEM_NAME # ignore: true YAML - stdout.puts "created: #{config_path}" + stdout.puts <<~MESSAGE + created: #{config_path} + + rbs collection installs RBS files for the gems in your Gemfile.lock. + Gems in Gemfile.lock don't need to be listed anywhere. + + Next steps: + $ echo "/.gem_rbs_collection/" >> .gitignore + $ rbs collection install # writes rbs_collection.lock.yaml; keep it in version control + MESSAGE when 'clean' unless lock_path.exist? puts "#{lock_path} should exist to clean" diff --git a/test/rbs/cli_test.rb b/test/rbs/cli_test.rb index 3c70ffa203..bf99e646f7 100644 --- a/test/rbs/cli_test.rb +++ b/test/rbs/cli_test.rb @@ -1082,6 +1082,30 @@ def foo: () -> void end end + def test_collection_init + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + with_cli do |cli| + assert_cli_success do + cli.run(%w(collection init)) + end + + config = Pathname(dir).join(RBS::Collection::Config::PATH).read + assert_match(/gems in your Gemfile\.lock/, config) + assert_match(/rbs collection install/, config) + + yaml = YAML.load(config) + assert_equal ".gem_rbs_collection", yaml["path"] + assert_equal ["ruby/gem_rbs_collection"], yaml["sources"].map {|source| source["name"] } + + assert_match(/created: .*rbs_collection\.yaml/, stdout.string) + assert_match(/gems in your Gemfile\.lock/, stdout.string) + assert_match(/\$ rbs collection install/, stdout.string) + end + end + end + end + def test_collection_install omit_on_jruby! "`rbs collection install` runs `bundle install`, which builds native gem extensions that do not compile on JRuby" From 2bd3d701adfceacaef8379449519aace42603f50 Mon Sep 17 00:00:00 2001 From: Masataka Pocke Kuwabara Date: Sat, 22 Aug 2026 20:52:00 +0900 Subject: [PATCH 2/2] Replace the backtraces on the missing-file paths of `rbs collection` Running `rbs collection install` before the files it needs exist ended in an uncaught exception with a full backtrace, because `exe/rbs` has no rescue. Someone -- or a coding agent -- who sees that concludes that `rbs collection` is broken and goes back to listing gems with `library`. `rbs collection init` now points at `rbs collection install`, so these paths are easier to reach than they were. Four of them: - No `rbs_collection.yaml`: `Errno::ENOENT` from `Config.from_path`, for both `install` and `update`. Now a message naming `rbs collection init`. - No `rbs_collection.lock.yaml` under `--frozen`: `Errno::ENOENT` from `Installer#initialize`. The message says to run without `--frozen`, or, when the config is missing too, to run `rbs collection init` first, so that one message is enough to get unstuck. - No `Gemfile`: `Bundler::GemfileNotFound` from `Bundler.definition`. - A `Gemfile` but no `Gemfile.lock`: `Bundler.definition` returns, and `LockfileGenerator#initialize` dies with `undefined method 'specs' for nil` on `definition.locked_gems.specs`. Now a message naming `bundle install`. Raising `RBS::Collection::Config::CollectionNotAvailable`, as the issue suggested, would keep the backtrace for exactly the same reason, so these write to `stderr` and return 1 like the rest of `run_collection`. `already exists` and `should exist to clean` used `Kernel#puts`, which writes to the real `$stdout` and ignores the injected IO. Both are failures that return 1, so they go to `stderr` now; this changes which stream they appear on. Tests cover each of the four paths, `--frozen` with neither file, and `--frozen` with a lock file and no config, which keeps working. Ref: https://github.com/ruby/rbs/issues/3093 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BXtMkmp27LieHgSjNyEWZ8 --- lib/rbs/cli.rb | 45 +++++++++++++++--- sig/cli.rbs | 6 +++ sig/shims/bundler.rbs | 3 ++ test/rbs/cli_test.rb | 104 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 7 deletions(-) diff --git a/lib/rbs/cli.rb b/lib/rbs/cli.rb index eaeff09ee8..c645c2deb2 100644 --- a/lib/rbs/cli.rb +++ b/lib/rbs/cli.rb @@ -1089,17 +1089,30 @@ def run_collection(args, options) case args[0] when 'install', 'instal', 'insta', 'inst', 'ins', 'in', 'i' - unless params[:frozen] - Collection::Config.generate_lockfile(config_path: config_path, definition: Bundler.definition) + if params[:frozen] + unless lock_path.exist? + if config_path.exist? + stderr.puts "#{lock_path} not found. Run `rbs collection install` without `--frozen` to generate it." + else + stderr.puts "#{lock_path} not found. Run `rbs collection init` and `rbs collection install` to generate it." + end + return 1 + end + else + return 1 unless collection_config_available?(config_path) + definition = bundler_definition or return 1 + Collection::Config.generate_lockfile(config_path: config_path, definition: definition) end Collection::Installer.new(lockfile_path: lock_path, stdout: stdout).install_from_lockfile when 'update', 'updat', 'upda', 'upd', 'up', 'u' + return 1 unless collection_config_available?(config_path) + definition = bundler_definition or return 1 # TODO: Be aware of argv to update only specified gem - Collection::Config.generate_lockfile(config_path: config_path, definition: Bundler.definition, with_lockfile: false) + Collection::Config.generate_lockfile(config_path: config_path, definition: definition, with_lockfile: false) Collection::Installer.new(lockfile_path: lock_path, stdout: stdout).install_from_lockfile when 'init' if config_path.exist? - puts "#{config_path} already exists" + stderr.puts "#{config_path} already exists" return 1 end @@ -1142,20 +1155,38 @@ def run_collection(args, options) MESSAGE when 'clean' unless lock_path.exist? - puts "#{lock_path} should exist to clean" + stderr.puts "#{lock_path} should exist to clean" return 1 end Collection::Cleaner.new(lockfile_path: lock_path) when 'help', 'hel', 'he', 'h' - puts opts.help + stdout.puts opts.help else - puts opts.help + stdout.puts opts.help return 1 end 0 end + def collection_config_available?(config_path) + return true if config_path.exist? + + stderr.puts "#{config_path} not found. Run `rbs collection init` to generate it." + false + end + + def bundler_definition + definition = Bundler.definition + return definition if definition.lockfile.file? + + stderr.puts "#{definition.lockfile} not found. Run `bundle install` to generate it." + nil + rescue Bundler::GemfileNotFound + stderr.puts "Gemfile not found. `rbs collection` reads the gems to install from Gemfile.lock." + nil + end + def collection_options(args) OptionParser.new do |opts| opts.banner = <<~HELP diff --git a/sig/cli.rbs b/sig/cli.rbs index 6dcd5bfdfd..c631ed8216 100644 --- a/sig/cli.rbs +++ b/sig/cli.rbs @@ -76,6 +76,12 @@ module RBS def run_collection: (Array[String], LibraryOptions) -> Integer + # Returns `true` if the collection config exists, otherwise prints a message to `stderr` and returns `false` + def collection_config_available?: (Pathname) -> bool + + # Returns `nil` if `Gemfile` or `Gemfile.lock` is not found, after printing a message to `stderr` + def bundler_definition: () -> Bundler::Definition? + def run_annotate: (Array[String], top) -> Integer def run_subtract: (Array[String], top) -> Integer diff --git a/sig/shims/bundler.rbs b/sig/shims/bundler.rbs index d43bd595c6..e80ef39ec7 100644 --- a/sig/shims/bundler.rbs +++ b/sig/shims/bundler.rbs @@ -1,4 +1,7 @@ module Bundler + class GemfileNotFound < StandardError + end + class LockfileParser def initialize: (String) -> void diff --git a/test/rbs/cli_test.rb b/test/rbs/cli_test.rb index bf99e646f7..808f7de410 100644 --- a/test/rbs/cli_test.rb +++ b/test/rbs/cli_test.rb @@ -1106,6 +1106,110 @@ def test_collection_init end end + def test_collection_install_without_config + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + with_cli do |cli| + refute_cli_success do + cli.run(%w(collection install)) + end + + assert_match(/rbs_collection\.yaml not found/, stderr.string) + assert_match(/rbs collection init/, stderr.string) + end + end + end + end + + def test_collection_update_without_config + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + with_cli do |cli| + refute_cli_success do + cli.run(%w(collection update)) + end + + assert_match(/rbs_collection\.yaml not found/, stderr.string) + assert_match(/rbs collection init/, stderr.string) + end + end + end + end + + def test_collection_install_without_gemfile + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + Pathname(dir).join(RBS::Collection::Config::PATH).write(<<~YAML) + sources: [] + path: .gem_rbs_collection + YAML + + _stdout, stderr = run_rbs_collection("install", bundler: false) do |status| + refute_predicate status, :success? + end + + assert_match(/Gemfile not found/, stderr) + end + end + end + + def test_collection_install_without_gemfile_lock + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + dir = Pathname(dir) + dir.join(RBS::Collection::Config::PATH).write(<<~YAML) + sources: [] + path: .gem_rbs_collection + YAML + dir.join("Gemfile").write(<<~RUBY) + source "https://rubygems.org" + RUBY + + _stdout, stderr = run_rbs_collection("install", bundler: false) do |status| + refute_predicate status, :success? + end + + assert_match(/Gemfile\.lock not found/, stderr) + assert_match(/bundle install/, stderr) + end + end + end + + def test_collection_install_frozen_without_lockfile + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + Pathname(dir).join(RBS::Collection::Config::PATH).write(<<~YAML) + sources: [] + path: .gem_rbs_collection + YAML + + with_cli do |cli| + refute_cli_success do + cli.run(%w(collection install --frozen)) + end + + assert_match(/rbs_collection\.lock\.yaml not found/, stderr.string) + assert_match(/without `--frozen`/, stderr.string) + end + end + end + end + + def test_collection_install_frozen_without_config_and_lockfile + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + with_cli do |cli| + refute_cli_success do + cli.run(%w(collection install --frozen)) + end + + assert_match(/rbs_collection\.lock\.yaml not found/, stderr.string) + assert_match(/rbs collection init/, stderr.string) + end + end + end + end + def test_collection_install omit_on_jruby! "`rbs collection install` runs `bundle install`, which builds native gem extensions that do not compile on JRuby"