Module: Rigor::Environment::MissingGemConstantIndex
- Defined in:
- lib/rigor/environment/missing_gem_constant_index.rb
Overview
ADR-82 WD9 — maps a top-level constant name to the locked, RBS-less gem that declares it, so an
unresolved constant read (Faraday, Sidekiq) can carry the external_gem_without_rbs provenance
cause instead of the generic unsupported_syntax. For a gem with no RBS the constant read is where the
class name is last visible — by dispatch time the receiver is already Dynamic[top] — so this index is
what lets coverage --protection route those holes to add_rbs honestly.
Ownership is established by READING, never by guessing: each gem's conventional entry file
(lib/<name>.rb, with the dash → directory variant) is parsed with Prism and its top-level
class / module / constant declarations recorded under their root name. No gem code runs — the same
posture as ADR-72's "loads RBS data only" and the ADR-10 walker. A name-derivation heuristic
(faraday → Faraday by camelizing) is deliberately NOT used: it breaks on irregular names
(activesupport → ActiveSupport) and is exactly the guessing the ADR-82 honesty criterion forbids.
Everything fails OPEN to "no owner": a gem that is not installed, an entry file that does not exist or does not parse, a root constant declared only in a deeper file. An unindexed constant keeps today's generic cause — the failure mode is a missing label, never a wrong one.
The scan is bounded to entry files (one or two per gem) so building the index over a real app's hundreds of RBS-less gems stays sub-second; it is built lazily on the first unresolved constant, so a project whose constants all resolve never pays it.
Gem-directory resolution. Rigor runs under its OWN bundle (BUNDLE_GEMFILE=<rigor>/Gemfile), so
Gem::Specification.find_by_name sees rigor's gems, not the target project's — it would resolve only
the handful of gems both bundles happen to share (i18n, rack) and miss every project-specific gem
(the very ones a Rails app's holes root at). So the primary resolver is the target's bundle install
tree (<bundle>/ruby/*/gems/<name>-<version>/, the same pure-filesystem layout
BundleSigDiscovery walks — no Bundler API, no gem code). Gem::Specification remains a last-resort
fallback for a project installed against system gems with no discoverable bundle; reading a gem's own
entry file for its own top-level namespace constant is version-stable, so even a rigor-vs-target
version skew on a shared gem yields the same constant name.
Class Method Summary collapse
-
.build(gems, bundle_path: nil, spec_resolver: method(:installed_gem_dir)) ⇒ Hash{String => String}
Frozen
root constant name => gem name. -
.bundle_gem_dirs(bundle_path) ⇒ Object
{"<name>-<version>" => gem_dir}for the target's bundle, or{}when no bundle is resolvable. -
.entry_files(dir, gem_name) ⇒ Object
The conventional require targets for a gem name:
lib/foo.rb, andlib/foo/bar.rbfor a dashedfoo-bar. -
.installed_gem_dir(name, version) ⇒ Object
RubyGems spec metadata lookup — the gem's on-disk source root, without loading any of its code.
- .root_segment(node) ⇒ Object
-
.top_level_root_constants(path) ⇒ Object
Root names of the file's top-level declarations.
Class Method Details
.build(gems, bundle_path: nil, spec_resolver: method(:installed_gem_dir)) ⇒ Hash{String => String}
Returns frozen root constant name => gem name. On a collision (two gems
declaring the same top-level constant) the first gem wins — the CAUSE recorded downstream
(external gem without RBS) is true under either owner.
48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/rigor/environment/missing_gem_constant_index.rb', line 48 def build(gems, bundle_path: nil, spec_resolver: method(:installed_gem_dir)) bundle_dirs = bundle_gem_dirs(bundle_path) index = {} gems.each do |gem_name, version| dir = bundle_dirs["#{gem_name}-#{version}"] || spec_resolver.call(gem_name, version) next unless dir entry_files(dir, gem_name).each do |file| top_level_root_constants(file).each { |name| index[name] ||= gem_name } end end index.freeze end |
.bundle_gem_dirs(bundle_path) ⇒ Object
{"<name>-<version>" => gem_dir} for the target's bundle, or {} when no bundle is resolvable. The
glob mirrors BundleSigDiscovery: <bundle>/ruby/X.Y.Z/gems/<name>-<version>/. Keyed on the dir
basename so a platform-tagged variant (ffi-1.17.4-aarch64-linux-gnu) simply doesn't match a
<name>-<version> lookup — those gems ship native code, not the pure-Ruby constants this indexes.
66 67 68 69 70 71 72 73 74 75 |
# File 'lib/rigor/environment/missing_gem_constant_index.rb', line 66 def bundle_gem_dirs(bundle_path) return {} if bundle_path.nil? base = Pathname.new(bundle_path) return {} unless base.directory? Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc| acc[File.basename(dir)] ||= dir end end |
.entry_files(dir, gem_name) ⇒ Object
The conventional require targets for a gem name: lib/foo.rb, and lib/foo/bar.rb for a dashed
foo-bar. This is the require-name convention Bundler.require depends on — a filename convention,
not a constant-name guess; the constants come from parsing whichever of these exists.
95 96 97 98 99 100 101 102 |
# File 'lib/rigor/environment/missing_gem_constant_index.rb', line 95 def entry_files(dir, gem_name) candidates = ["lib/#{gem_name}.rb"] candidates << "lib/#{gem_name.tr('-', '/')}.rb" if gem_name.include?("-") candidates.filter_map do |relative| path = File.join(dir, relative) path if File.file?(path) end end |
.installed_gem_dir(name, version) ⇒ Object
RubyGems spec metadata lookup — the gem's on-disk source root, without loading any of its code. Exact-version first, any-version fallback. See the class note on why this is only a fallback.
79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/rigor/environment/missing_gem_constant_index.rb', line 79 def installed_gem_dir(name, version) spec = begin Gem::Specification.find_by_name(name, "= #{version}") rescue Gem::LoadError begin Gem::Specification.find_by_name(name) rescue Gem::LoadError nil end end spec&.full_gem_path end |
.root_segment(node) ⇒ Object
121 122 123 124 125 |
# File 'lib/rigor/environment/missing_gem_constant_index.rb', line 121 def root_segment(node) current = node current = current.parent while current.is_a?(Prism::ConstantPathNode) && current.parent current.respond_to?(:name) && current.name ? current.name.to_s : nil end |
.top_level_root_constants(path) ⇒ Object
Root names of the file's top-level declarations. Only direct children of the program are read — the
root constant of a gem's namespace is declared at file top level by construction (module Faraday,
class Money::Error < … roots at Money), so no recursion into bodies is needed.
107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/rigor/environment/missing_gem_constant_index.rb', line 107 def top_level_root_constants(path) result = Prism.parse(File.read(path)) return [] unless result.errors.empty? result.value.statements.body.filter_map do |node| case node when Prism::ClassNode, Prism::ModuleNode then root_segment(node.constant_path) when Prism::ConstantWriteNode then node.name.to_s end end rescue StandardError [] end |