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 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

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:

  1. <project_root>/.bundle/config carries BUNDLE_PATH: set by `bundle config set --local path

    `.
  2. <project_root>/vendor/bundle/ — the conventional in-tree install location when a developer ran bundle install --path vendor/bundle.
  3. 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.
  4. 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.expand_path(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.

Parameters:

  • bundle_path (String, Pathname, nil)

    explicit path to the bundler install root. When nil, falls back to auto_detect if auto_detect: is true.

  • project_root (String) (defaults to: Dir.pwd)

    resolution base for relative bundle_path: and the auto-detect search.

  • auto_detect (Boolean) (defaults to: true)

    when true and bundle_path: is nil, try .bundle/config's BUNDLE_PATH: and vendor/bundle/ under project_root.

  • skip_gems (Set<String>) (defaults to: SKIPPED_GEMS_BY_DEFAULT)

    gem names to exclude from discovery. Defaults to SKIPPED_GEMS_BY_DEFAULT.

  • locked_gems (Hash{String => LockfileResolver::LockedGem}, nil) (defaults to: nil)

    Optional O4-Layer-3 filter. When non-nil and non-empty, only sig/ directories whose gem (name, version, platform) tuple matches a lockfile entry are returned. Bundle entries absent from the lockfile (or at a drifted version) are silently dropped — the lockfile is treated as the source of truth for "what gems this project actually declares". Pass nil (the default) to keep the pre-Layer-3 behaviour of returning every non-skipped sig/ under the bundle.

Returns:

  • (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.



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.expand_path(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