Module: Rigor::Environment::BundleSigDiscovery
- Defined in:
- lib/rigor/environment/bundle_sig_discovery.rb
Overview
Target-project Bundler awareness (O4, implemented).
Walks a Bundler-installed gem tree (e.g., the project’s ‘vendor/bundle` or a Docker-mounted bundle root) and returns the per-gem `sig/` directories to feed into `RbsLoader`’s ‘signature_paths:`. Of the ~3% of gems that ship `sig/` in their gem package today (per the four-project Mastodon Docker bundle-install measurement on 2026-05-15: 10 of 343 gems shipped sig — `prism`, `aws-sdk-s3`, `aws-sdk-kms`, `aws-sdk-core`, `playwright-ruby-client`, `mutex_m`, `webrick`, `base64`, `stoplight`, `ffi`), this discovery surfaces the typed contract the gem author explicitly published.
Conflicts with rigor’s bundled stdlib RBS (the prism case was the motivating example) degrade gracefully via O7’s failure-memo in ‘RbsLoader#env`: a single warning naming the offending file is emitted and analysis continues with `Dynamic` everywhere rather than hanging.
The discovery is intentionally a pure file-system walk —no ‘Bundler` API call, no `Gemfile.lock` parse — so rigor doesn’t need the target project’s Bundler context.
Constant Summary collapse
- SKIPPED_GEMS_BY_DEFAULT =
Gems already covered by rigor’s ‘DEFAULT_LIBRARIES` (stdlib RBS) plus the `data/vendored_gem_sigs/` bundle. Skipping these from bundle discovery prevents `RBS::DuplicatedDeclarationError` (the prism case was the motivating example — Ruby 4.0 ships prism’s RBS in stdlib, and the gem also ships its own ‘sig/`, so loading both raises on `Prism::BACKEND` etc.).
The list is hard-coded for the MVP because it tracks rigor’s bundled coverage 1:1. When a new gem is vendored under ‘data/vendored_gem_sigs/` or added to `DEFAULT_LIBRARIES`, add its name here.
Set[ # DEFAULT_LIBRARIES (lib/rigor/environment.rb) "pathname", "optparse", "json", "yaml", "fileutils", "tempfile", "tmpdir", "stringio", "forwardable", "digest", "securerandom", "uri", "logger", "date", "pp", "delegate", "singleton", "observable", "abbrev", "find", "tsort", "shellwords", "benchmark", "base64", "did_you_mean", "monitor", "mutex_m", "timeout", "open3", "erb", "etc", "ipaddr", "bigdecimal", "bigdecimal-math", "prettyprint", "random-formatter", "time", "open-uri", "resolv", "csv", "pstore", "objspace", "io-console", "cgi", "cgi-escape", "strscan", "prism", "rbs", # data/vendored_gem_sigs/ "pg", "mysql2", "nokogiri", "bcrypt", "redis", "idn-ruby" ].freeze
Class Method Summary collapse
-
.auto_detect(project_root:, home: nil) ⇒ Object
Auto-detection order — project-local strategies win over the user-global one, mirroring Bundler’s own config precedence: 1.
-
.discover(bundle_path:, project_root: Dir.pwd, auto_detect: true, skip_gems: SKIPPED_GEMS_BY_DEFAULT, locked_gems: nil, home: nil) ⇒ Array<Pathname>
Every ‘<gem-dir>/sig` directory under the resolved bundle path, minus any whose gem name is in `skip_gems` and (when `locked_gems` is supplied) minus any whose `(name, version, platform)` does not match a lockfile entry.
-
.gem_name_from_sig_path(sig_dir) ⇒ Object
‘<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name>`.
-
.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true, home: nil) ⇒ Object
Returns ‘Pathname` resolved bundle path, or `nil` when neither explicit nor auto-detected.
Class Method Details
.auto_detect(project_root:, home: nil) ⇒ Object
Auto-detection order — project-local strategies win over the user-global one, mirroring Bundler’s own config precedence:
-
‘<project_root>/.bundle/config` carries `BUNDLE_PATH:` set by `bundle config set –local path <dir>`.
-
‘<project_root>/vendor/bundle/` — the conventional in-tree install location when a developer ran `bundle install –path vendor/bundle`.
-
The user-global bundler config ‘<home>/.bundle/config` `BUNDLE_PATH:` (`bundle config set –global path <dir>`), resolved relative to the project root and used only when it points at an existing directory — the last resort for a project with no in-tree bundle. Purely additive: it is consulted only when steps 1–2 found nothing, so it never changes an already-working detection.
-
‘nil` — let the caller proceed without bundle sig discovery (rigor’s vendored RBS still loads).
Note (ADR-27): rigor reads the project as data, so detection is limited to paths recorded in project-local or user-global Bundler config files. The pure-default install location — gems in the active Ruby’s GEM_HOME with no ‘path` configured — is the *project’s* Ruby’s gem home, which the isolated analyzer cannot know without running the project’s toolchain. Point rigor at it with ‘bundler.bundle_path:`, or supply signatures via `rbs collection install` / `dependencies.source_inference:`. `BUNDLE_PATH` from rigor’s own environment is deliberately NOT consulted — it describes rigor’s bundle, not the analyzed project’s.
‘home:` defaults to the invoking user’s home directory; it is a parameter so tests stay hermetic (no read of the real ‘~/.bundle/config`).
193 194 195 196 197 198 199 200 201 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 193 def self.auto_detect(project_root:, home: nil) from_config = read_bundle_config_path(File.join(project_root, ".bundle", "config")) return File.(from_config, project_root) if from_config vendor = File.join(project_root, "vendor", "bundle") return vendor if File.directory?(vendor) global_bundle_path(project_root: project_root, home: home) end |
.discover(bundle_path:, project_root: Dir.pwd, auto_detect: true, skip_gems: SKIPPED_GEMS_BY_DEFAULT, locked_gems: nil, home: nil) ⇒ Array<Pathname>
Returns every ‘<gem-dir>/sig` directory under the resolved bundle path, minus any whose gem name is in `skip_gems` and (when `locked_gems` is supplied) minus any whose `(name, version, platform)` does not match a lockfile entry.
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 86 def self.discover(bundle_path:, project_root: Dir.pwd, auto_detect: true, skip_gems: SKIPPED_GEMS_BY_DEFAULT, locked_gems: nil, home: nil) resolved = resolve_bundle_path( bundle_path: bundle_path, project_root: project_root, auto_detect: auto_detect, home: home ) return [] if resolved.nil? # `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig/` is the # canonical bundler layout. `*` on the ruby version dir # picks up whichever Ruby the bundle was installed for. all = Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")).map { |d| Pathname.new(d) } filtered = all.reject { |sig_dir| skip_gems.include?(gem_name_from_sig_path(sig_dir)) } return filtered if locked_gems.nil? || locked_gems.empty? expected_dirs = expected_gem_dirs(locked_gems) filtered.select { |sig_dir| expected_dirs.include?(sig_dir.parent.basename.to_s) } end |
.gem_name_from_sig_path(sig_dir) ⇒ Object
‘<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name>`. The gem directory follows the canonical `<name>-<version>` pattern; we strip everything from the last hyphen onwards to recover the name. (Platform-tagged variants like `ffi-1.17.4-aarch64-linux-gnu/` keep their platform suffix in the version part, so the first hyphen from the right is still the name boundary.)
Public so the O4 Layer 3 slice-3 coverage report (‘RbsCoverageReport`) can classify discovered bundle sigs against locked gem names without re-running discovery.
136 137 138 139 140 141 142 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 136 def self.gem_name_from_sig_path(sig_dir) gem_dir = sig_dir.parent.basename.to_s # Strip `-<version>` and any platform suffix. The version # always starts with a digit, so split at the first # `-` followed by a digit. gem_dir.sub(/-\d.*\z/, "") end |
.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true, home: nil) ⇒ Object
Returns ‘Pathname` resolved bundle path, or `nil` when neither explicit nor auto-detected. Public for the stats banner so end users can see what rigor picked up.
147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 147 def self.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true, home: nil) if bundle_path path = Pathname.new(File.(bundle_path.to_s, project_root)) return path if path.directory? return nil end return nil unless auto_detect detected = auto_detect(project_root: project_root, home: home) Pathname.new(detected) if detected end |