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[top] 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 thedata/vendored_gem_sigs/bundle. Skipping these from bundle discovery preventsRBS::DuplicatedDeclarationError(the prism case was the motivating example — Ruby 4.0 ships prism's RBS in stdlib, and the gem also ships its ownsig/, so loading both raises onPrism::BACKENDetc.).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 toDEFAULT_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>/sigdirectory under the resolved bundle path, minus any whose gem name is inskip_gemsand (whenlocked_gemsis 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
Pathnameresolved bundle path, ornilwhen 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/configcarriesBUNDLE_PATH:set by `bundle config set --local path`. <project_root>/vendor/bundle/— the conventional in-tree install location when a developer ranbundle install --path vendor/bundle.- The user-global bundler config
<home>/.bundle/configBUNDLE_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).
152 153 154 155 156 157 158 159 160 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 152 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.
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 64 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.
107 108 109 110 111 112 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 107 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.
116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/rigor/environment/bundle_sig_discovery.rb', line 116 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 |