Class: Rigor::Environment::RbsLoader

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/environment/rbs_loader.rb,
sig/rigor/environment.rbs

Overview

Loads RBS class declarations and method definitions from disk and exposes them to the inference engine in a small, stable surface.

Slice 4 phase 1 only enabled the RBS core signatures shipped with the rbs gem (Object, Integer, String, Array, ...). Phase 2a adds opt-in stdlib library loading (pathname, json, tempfile, ...) and arbitrary-directory signature loading (typically the project's local sig/ tree). Both are off by default on RbsLoader.default so the core-only fast path stays cheap; project-aware loading is opted into through for_project or by constructing a custom loader.

The default instance is shared across the process: building the core RBS environment costs hundreds of milliseconds and the data is read-only. The shared instance is frozen, but holds a mutable state hash for lazy memoization of the heavy RBS::Environment and RBS::DefinitionBuilder -- the user-visible API stays purely functional.

See docs/internal-spec/inference-engine.md for the binding contract. rubocop:disable Metrics/ClassLength

Constant Summary collapse

SYNTHETIC_NAMESPACE_BUFFER =

Buffer name stamped on the module declarations synthesized by synthesize_missing_namespaces. Re-read off the built env by #synthesized_namespaces so the analysis layer can surface an :info diagnostic naming the project's malformed-RBS namespaces — robust across the marshalled env cache, since the sentinel rides along on each synthetic declaration's location.

"(rigor: synthesized namespaces)"
SYNTHETIC_STUB_BUFFER =

Buffer name stamped on the stub class / module declarations synthesized by stub_missing_referenced_types for types the project's RBS references but no loaded signature declares. #synthesized_stub_types reads them back off the built env (so the answer survives the marshalled env cache), and #synthesized_type_names folds them together with the namespace stubs into the set MethodDispatcher resolves to Dynamic[Top] (no false call.undefined-method).

"(rigor: synthesized stub types)"
QUARANTINE_WARN_LIMIT =

Cap on how many quarantined signature_paths: files #warn_about_quarantined_signatures lists by name before collapsing the tail to "… and N more" — a broken generator can emit many, and a wall of parse errors buries the signal.

10
MAX_STUB_PASSES =

ADR-5 robustness, second tier. A project signature_paths: RBS that references a type no loaded signature declares — def x: () -> DRb::DRbServer when the drb RBS is not available, or a stale reference to its own removed Textbringer::EditorError — makes RBS::DefinitionBuilder#build_instance raise NoTypeFoundError, and (per RBS's all-or-nothing per-class build) that single unresolved reference takes down EVERY method on the class, not just the one signature. Observed on shugo/textbringer: one DRb::DRbServer reference left the whole Textbringer::Commands module — including its 186-call-site define_command DSL — resolving as Dynamic[Top].

We synthesize an empty stub for each such referenced-but- undeclared type so the rest of the class builds. A leaf type is stubbed as class, its enclosing namespaces as module. Stubbed types carry no methods, so a call against a value of a stubbed type would otherwise mis-fire call.undefined-method; MethodDispatcher consults #synthesized_type_names and resolves such calls to Dynamic[Top] instead (the same no-false-positive contract as the dependency-source tier).

Detection reads the PROJECT declarations and mirrors rbs's own membership test (unresolved_referenced_types); it is bounded to signature_paths classes (stdlib / vendored RBS is well-formed) and to MAX_STUB_PASSES iterations — a fresh stub can expose a deeper reference the first pass could not see past, but empty stubs reference nothing, so the fixpoint converges quickly.

5
INVALID_ENCODING_NOTE =

The quarantine note for a file rejected by invalid_encoding? — worded to be distinct from any rbs-emitted parse error so specs (and users) can tell Rigor's pre-parser skip from the parser's own UTF-8 diagnostics.

"not valid UTF-8 — skipped before reaching the RBS parser"
CORE_OVERLAY_SIGS_ROOT =

Rigor-owned core-overlay RBS (data/core_overlay/). Reopens Ruby core classes to add methods upstream ruby/rbs omits but which every concrete value answers at runtime — loaded last so upstream always wins on conflict. Public so the cache descriptor can digest these files into the env-blob key.

File.expand_path(
  "../../../data/core_overlay",
  __dir__
).freeze
GEM_OVERLAY_SIGS_ROOT =

Rigor-owned per-gem RBS overlays (data/gem_overlay/<gem>/), ADR-72. Unlike the unconditional core_overlay, each gem's overlay is loaded ONLY when that gem is locked in the project's Gemfile.lock but ships no RBS of its own — Rigor::Environment.for_project decides eligibility and passes the already-filtered gem-name set here. One directory per gem name keeps the membership check a cheap File.directory?.

File.expand_path(
  "../../../data/gem_overlay",
  __dir__
).freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: []) ⇒ RbsLoader

Returns a new instance of RbsLoader.

Parameters:

  • libraries (Array<String, Symbol>) (defaults to: [])

    stdlib library names to load on top of core (e.g., ["pathname", "json"]). Empty by default. Each entry MUST correspond to a directory under the rbs gem's stdlib/ tree; unknown names are silently dropped on environment build (the underlying RBS::EnvironmentLoader raises and we fail-soft).

  • signature_paths (Array<String, Pathname>) (defaults to: [])

    additional directories of .rbs files to load (typically the project's sig/ tree). Non-existent or non-directory paths are filtered out at build time so the loader stays robust to fixtures and bare repositories.

  • cache_store (Rigor::Cache::Store, nil) (defaults to: nil)

    the persistent cache the loader threads through to RbsEnvironment, RbsKnownClassNames, RbsConstantTable, RbsClassAncestorTable, and RbsClassTypeParamNames producers. Pass nil (the default) to skip caching; the runner threads its own Store through here when enabled.

  • virtual_rbs (Array<[String, String]>) (defaults to: [])

    ADR-32 WD4 — [virtual_filename, rbs_source] pairs synthesised from project source by a plugin's Manifest#source_rbs_synthesizer. Merged into the env after signature_paths: and the vendored stubs. Pass [] (the default) when no synthesizer-emitting plugin is loaded.

  • libraries: (Array[String]) (defaults to: [])
  • signature_paths: (Array[String | _ToPath]) (defaults to: [])
  • cache_store: (Object, nil) (defaults to: nil)


800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
# File 'lib/rigor/environment/rbs_loader.rb', line 800

def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [])
  @libraries = libraries.map(&:to_s).freeze
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
  @cache_store = cache_store
  @virtual_rbs = virtual_rbs.map { |name, content| [name.to_s.dup.freeze, content.to_s.dup.freeze].freeze }.freeze
  # Per-loader memoization bucket. Held as a single mutable Hash so the loader instance itself can be
  # `.freeze`d (per ADR-15 reflection-facade contract) without losing the lazy-memo behaviour. Slot
  # names currently consulted: `:env`, `:env_loaded`, `:env_build_warned`, `:definition_build_warned`,
  # `:builder`, `:reflection`, `:instance_definitions_table`, `:singleton_definitions_table`.
  # Constructed via `Hash.new` (NOT a `{ ... }` literal) so Rigor's `HashShape` narrowing doesn't
  # infer a fixed key set from the initial state and fold post-initial slot reads (e.g.
  # `@state[:env_loaded]`) to a constant `nil`.
  @state = Hash.new # rubocop:disable Style/EmptyLiteral
  @instance_definition_cache = {}
  @singleton_definition_cache = {}
  @class_known_cache = {}
  @hierarchy = RbsHierarchy.new(self)
end

Instance Attribute Details

#cache_storeObject? (readonly)

Returns the value of attribute cache_store.

Returns:

  • (Object, nil)


783
784
785
# File 'lib/rigor/environment/rbs_loader.rb', line 783

def cache_store
  @cache_store
end

#librariesArray[String] (readonly)

Returns the value of attribute libraries.

Returns:

  • (Array[String])


783
784
785
# File 'lib/rigor/environment/rbs_loader.rb', line 783

def libraries
  @libraries
end

#signature_pathsArray[String | _ToPath] (readonly)

Returns the value of attribute signature_paths.

Returns:

  • (Array[String | _ToPath])


783
784
785
# File 'lib/rigor/environment/rbs_loader.rb', line 783

def signature_paths
  @signature_paths
end

#virtual_rbsObject (readonly)

Returns the value of attribute virtual_rbs.



783
784
785
# File 'lib/rigor/environment/rbs_loader.rb', line 783

def virtual_rbs
  @virtual_rbs
end

Class Method Details

.add_bundled_signatures(rbs_loader, loaded_library_names) ⇒ Object

Adds the Rigor-shipped signature sources to rbs_loader: every data/vendored_gem_sigs/<gem>/ directory, then the data/core_overlay/ files — the overlay LAST so an upstream declaration always wins on conflict (these reopenings only fill genuine holes, e.g. Numeric#to_f/to_i/ to_r, which upstream RBS declares on the concrete subclasses but not on the abstract Numeric that Rigor's arithmetic-chain widening produces). The overlay is added per-file, not per-directory, because the LIBRARY_SUPPLEMENT_CORE_OVERLAYS files must be gated individually.

Parameters:

  • loaded_library_names (Set<String>)

    libraries that actually resolved on this loader.



710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
# File 'lib/rigor/environment/rbs_loader.rb', line 710

def add_bundled_signatures(rbs_loader, loaded_library_names)
  vendored_gem_sig_paths.each do |path|
    next unless path.directory?
    next unless supplement_dependency_loaded?(LIBRARY_SUPPLEMENT_VENDORED_DIRS, path, loaded_library_names)

    rbs_loader.add(path: path)
  end
  core_overlay_sig_paths.each do |dir|
    next unless dir.directory?

    dir.children.sort.each do |file|
      next unless file.file? && file.extname == ".rbs"
      next unless supplement_dependency_loaded?(LIBRARY_SUPPLEMENT_CORE_OVERLAYS, file, loaded_library_names)

      rbs_loader.add(path: file)
    end
  end
end

.add_parsed_decls(env, buffer, directives, decls) ⇒ Object

Appends freshly-parsed declarations to an RBS::Environment across the gemspec's supported RBS range (rbs >= 3.0, < 5.0). RBS 4.x wraps the declarations in an RBS::Source::RBS and takes them through env.add_source; RBS 3.x has neither RBS::Source nor add_source and instead registers them with env.add_signature(buffer:, directives:, decls:) (a bare env << decl is NOT enough — it skips the signatures table that resolve_type_names rebuilds from, so the synthesized declarations silently vanish on resolve). Without this guard the synthesis paths (synthesize_missing_namespaces, append_stub_declarations, add_virtual_rbs) crashed under RBS 3.x with uninitialized constant RBS::Source.



509
510
511
512
513
514
515
516
517
518
519
# File 'lib/rigor/environment/rbs_loader.rb', line 509

def add_parsed_decls(env, buffer, directives, decls)
  decls ||= []
  directives ||= []
  if env.respond_to?(:add_source)
    env.add_source(::RBS::Source::RBS.new(buffer, directives, decls))
  elsif env.respond_to?(:add_signature)
    env.add_signature(buffer: buffer, directives: directives, decls: decls)
  else
    decls.each { |decl| env << decl }
  end
end

.add_project_signatures(env, signature_paths) ⇒ Object

Load the project's signature_paths: RBS files into env ONE FILE AT A TIME, quarantining any file that fails to parse rather than letting it collapse the whole env (the from_loader batch parse is all-or-nothing — see build_env_for). A quarantined file's declarations are simply absent, so calls into the types it would have declared read Dynamic[top]; the rest of the project's (and all bundled) RBS still loads. The user is told which files were skipped via #warn_about_quarantined_signatures, so this is a graceful degrade, not a silent one.

Buffer names are the file's absolute path (matching project_sig_files) so project_entry? — which attributes a class_decls entry to the project by buffer name — still recognises these declarations. Sorted for a deterministic add order (the env feeds the cache, ADR-54).



290
291
292
293
294
295
296
297
298
# File 'lib/rigor/environment/rbs_loader.rb', line 290

def add_project_signatures(env, signature_paths)
  project_sig_files(signature_paths).sort.each do |file|
    parsed = parse_signature_file(file)
    next if parsed.nil? # quarantined (unparseable) or unreadable — skip so the env survives

    buffer, directives, decls = parsed
    add_parsed_decls(env, buffer, directives, decls)
  end
end

.add_virtual_rbs(env, virtual_rbs) ⇒ Object

ADR-32 WD4 — merge synthesised-from-source RBS strings into the freshly-built environment. Each entry is a [virtual_filename, rbs_source] pair. virtual_filename is purely for diagnostic provenance (RBS parse errors cite it) — it is not a real file path. Per WD6 the synthesizer-emit path is responsible for catching its own parse errors and returning nil rather than garbage; this method assumes its input is parseable and only rescues RBS::ParsingError as a fail-soft.



617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
# File 'lib/rigor/environment/rbs_loader.rb', line 617

def add_virtual_rbs(env, virtual_rbs)
  return if virtual_rbs.nil? || virtual_rbs.empty?

  virtual_rbs.each do |filename, content|
    next if content.nil? || content.empty?
    # Same pre-parser guard as {.parse_signature_file}: a synthesizer echoing project bytes can carry
    # invalid UTF-8, which pre-4.1 rbs lexers could hang on — and a hang escapes the rescue below.
    next if invalid_encoding?(content.to_s)

    buffer = ::RBS::Buffer.new(name: filename.to_s, content: content.to_s)
    _, directives, decls = ::RBS::Parser.parse_signature(buffer)
    add_parsed_decls(env, buffer, directives, decls)
  rescue ::RBS::BaseError
    # WD6 fail-soft: a single broken virtual RBS contribution does not pull the whole env down — for
    # a parse error, skipping the entry is enough. But `RBS::Environment#add_source` appends to
    # `env.sources` BEFORE inserting decls, so when the raise is a mid-insert
    # `RBS::DuplicatedDeclarationError` (the entry declares a constant the project's own `sig/`
    # already declares — the expected state for a project migrating between `sig/` and inline
    # annotations, not an authoring error), the POISONED SOURCE is left behind, and
    # `resolve_type_names` — which rebuilds the env from `sources` — re-raises the same error outside
    # this rescue and collapses the WHOLE env to nil (measured on herb: 1,490 classes → 0, `require`
    # itself stopped resolving, 74 false `call.unresolved-toplevel`). Make the skip transactional:
    # drop the poisoned source. The explicit `.rbs` declaration wins — the spec keeps standalone
    # `.rbs` files "the preferred place for complete type definitions" (`overview.md`) — and
    # {#warn_about_virtual_rbs_collisions} names the dropped file. `sources` is the rbs 4.x shape;
    # under the 3.x API this degrades to today's behaviour.
    env.sources.reject! { |source| source.buffer.name == buffer.name } if env.respond_to?(:sources)
  end
end

.append_stub_declarations(base_env, missing) ⇒ Object

Adds empty stub declarations for the missing referenced types (and any enclosing namespace they need) to the pre-resolve env, tagged with SYNTHETIC_STUB_BUFFER. Returns true when at least one declaration landed, so stub_missing_referenced_types can stop on a pass that made no progress.

Each name gets the declaration kind its own syntax requires (stub_declaration_for), and each declaration is validated ALONE before it joins the buffer. Declaring every name class — the shape before #237 — made a dangling interface (_Foo) or type-alias (foo) reference unparseable, and since the batch shares one buffer the RBS::BaseError rescue below then discarded every stub in it, well-formed ones included. Measured on herb, whose 74 missing names are all dangling type aliases: the pass synthesized nothing at all, leaving the project's 48 signature files inert, while still paying for the detection sweep. A per-declaration check costs one small parse per name and bounds the damage of one bad name to itself.

Names already declared in base_env are skipped — exactly the declared.include? guard collect_missing_namespaces applies. Without it, stubbing a nested reference (Foo::Bar::Baz) re-emits its enclosing prefix (Foo::Bar) as a module, and when that prefix is already a class in the project's own sig/ the kind mismatch makes resolve_type_names raise RBS::DuplicatedDeclarationError, collapsing the WHOLE env to nil (every type-of query then degrades to Dynamic[Top]). One malformed .rbs must not disproportionately blind the analysis: a subclass sig that references an inherited nested type (class GitAdapter; def x: () -> GitAdapter::Revision) was the real-world trigger — see the 2026-07-04 redmine onboarding note.



553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
# File 'lib/rigor/environment/rbs_loader.rb', line 553

def append_stub_declarations(base_env, missing)
  declared = declared_type_names(base_env)
  names = missing.to_set
  missing.each do |name|
    parts = name.split("::")
    (1...parts.length).each { |i| names << parts[0, i].join("::") }
  end
  names = names.reject { |name| declared.include?(name) }.to_set
  return false if names.empty?

  source = names.sort_by { |n| n.count(":") }.filter_map do |name|
    declaration = stub_declaration_for(name, names)
    declaration if parseable_rbs?(declaration)
  end.join
  return false if source.empty?

  buffer = ::RBS::Buffer.new(name: SYNTHETIC_STUB_BUFFER, content: source)
  _, directives, decls = ::RBS::Parser.parse_signature(buffer)
  add_parsed_decls(base_env, buffer, directives, decls)
  true
rescue ::RBS::BaseError
  false
end

.bigdecimal_library_declares_big_math?Boolean

Resolves library: "bigdecimal" on a THROWAWAY loader (never the one building the env) and reports whether any directory it resolves to ships big_math.rbs. Fails soft to false: an unresolvable library must not take the whole environment build down, and keeping both entries is the pre-existing behaviour.

Returns:

  • (Boolean)


129
130
131
132
133
134
135
136
137
138
139
# File 'lib/rigor/environment/rbs_loader.rb', line 129

def bigdecimal_library_declares_big_math?
  probe = ::RBS::EnvironmentLoader.new(core_root: nil)
  return false unless probe.has_library?(library: BIGDECIMAL_LIBRARY, version: nil)

  probe.add(library: BIGDECIMAL_LIBRARY, version: nil)
  shadowed = false
  probe.each_dir { |_source, dir| shadowed ||= dir.join(BIG_MATH_SIG_BASENAME).file? }
  shadowed
rescue StandardError
  false
end

.build_env_for(libraries:, signature_paths:, virtual_rbs: []) ⇒ Object

Builds an RBS::Environment from explicit libraries and signature_paths. Stateless surface so the v0.0.9 Cache::RbsEnvironment producer can build an env on cache miss without holding a loader instance, and the instance-side #build_env delegates here so the implementation stays single-rooted.

Vendored gem stubs (data/vendored_gem_sigs/<gem>/) are loaded on top of signature_paths so the per-gem RBS bundled with Rigor itself is in scope for every analysis run. The gem stubs are intentionally read-only and appended LAST so user-supplied signature_paths win on name conflicts.

Parameters:

  • libraries: (Array[String])
  • signature_paths: (Array[String | _ToPath])

Returns:

  • (Object)


66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/rigor/environment/rbs_loader.rb', line 66

def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
  rbs_loader = RBS::EnvironmentLoader.new
  libraries = libraries_without_shadowed_bigdecimal_math(libraries)
  loaded_libraries = libraries.select do |library|
    rbs_loader.has_library?(library: library, version: nil)
  end
  loaded_libraries.each do |library|
    rbs_loader.add(library: library, version: nil)
  end
  # Project `signature_paths:` are loaded per-file by {.add_project_signatures} AFTER `from_loader`,
  # NOT added to the loader here: `RBS::Environment.from_loader` parses every added file all-or-nothing,
  # so one unparseable user `.rbs` raises `RBS::ParsingError` and collapses the WHOLE env to nil (every
  # type-of query then degrades to `Dynamic[top]` — the "sig looks harmful" failure of the 2026-07-06
  # mastodon coverage note). Per-file loading quarantines the broken file instead. Vendored / core-overlay
  # sigs are Rigor-shipped and trusted, so they stay on the loader's fast batch path.
  add_bundled_signatures(rbs_loader, loaded_libraries.to_set(&:to_s))
  env = RBS::Environment.from_loader(rbs_loader)
  add_project_signatures(env, signature_paths)
  add_virtual_rbs(env, virtual_rbs)
  synthesize_missing_namespaces(env)
  env, resolved = resolve_quarantining_virtual_collisions(env, virtual_rbs)
  stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
end

.collect_declaration_references(env, decl, missing, checked) ⇒ Object

Decl-level references the builder validates: a super class's / module self type's / mixin's type ARGUMENTS, plus every member. super_class is a Declarations::Class accessor and self_types a Declarations::Module one, so both are reached behind a shape check.



400
401
402
403
404
405
406
407
408
409
410
# File 'lib/rigor/environment/rbs_loader.rb', line 400

def collect_declaration_references(env, decl, missing, checked)
  if decl.respond_to?(:super_class)
    decl.super_class&.args&.each { |arg| collect_type_references(env, arg, missing, checked) }
  end
  if decl.respond_to?(:self_types)
    decl.self_types&.each do |self_type|
      self_type.args.each { |arg| collect_type_references(env, arg, missing, checked) }
    end
  end
  collect_member_references(env, decl.members, missing, checked)
end

.collect_member_references(env, members, missing, checked) ⇒ Object

Member-level references. initialize and the singleton side are skipped because validate_type_params never reaches them (see unresolved_referenced_types); :singleton_instance (def self?.x) defines the instance side too, so it is NOT skipped.



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/rigor/environment/rbs_loader.rb', line 415

def collect_member_references(env, members, missing, checked)
  members.each do |member|
    next if member.respond_to?(:kind) && member.kind == :singleton

    case member
    when ::RBS::AST::Members::MethodDefinition
      next if member.name == :initialize

      member.overloads.each do |overload|
        collect_type_references(env, overload.method_type, missing, checked)
      end
    when ::RBS::AST::Members::AttrReader, ::RBS::AST::Members::AttrWriter,
         ::RBS::AST::Members::AttrAccessor
      collect_type_references(env, member.type, missing, checked)
    when ::RBS::AST::Members::Include, ::RBS::AST::Members::Extend,
         ::RBS::AST::Members::Prepend
      member.args.each { |arg| collect_type_references(env, arg, missing, checked) }
    end
  end
end

.collect_missing_namespaces(env) ⇒ Object

Returns the ::-stripped names of every enclosing namespace that some declaration references but no declaration defines, shallowest-first so the synthesized source declares Foo before Foo::Bar.



254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/rigor/environment/rbs_loader.rb', line 254

def collect_missing_namespaces(env)
  declared = env.class_decls.keys.to_set
  missing = {}
  env.class_decls.each_key do |type_name|
    path = type_name.namespace.path
    path.each_index do |i|
      prefix = path[0..i]
      full = ::RBS::TypeName.parse("::#{prefix.join('::')}")
      missing[prefix.join("::")] = prefix.length unless declared.include?(full)
    end
  end
  missing.sort_by { |_name, depth| depth }.map(&:first)
end

.collect_type_references(env, type, missing, checked) ⇒ Object

Walks one type (or method type) and appends the names no declaration provides. Only the three node classes VarianceCalculator#type raises for carry a checkable name; everything else is traversed for the types nested inside it.



439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# File 'lib/rigor/environment/rbs_loader.rb', line 439

def collect_type_references(env, type, missing, checked)
  return if type.nil?

  case type
  when ::RBS::Types::ClassInstance, ::RBS::Types::Interface, ::RBS::Types::Alias
    name = type.name
    name = name.absolute! unless name.absolute?
    missing[name.to_s.sub(/\A::/, "")] = true unless declared_reference?(env, name, checked)
  end
  return unless type.respond_to?(:each_type)

  type.each_type do |nested|
    collect_type_references(env, nested, missing, checked)
  end
end

.core_overlay_sig_pathsObject



667
668
669
670
671
# File 'lib/rigor/environment/rbs_loader.rb', line 667

def core_overlay_sig_paths
  return [] unless File.directory?(CORE_OVERLAY_SIGS_ROOT)

  [Pathname(CORE_OVERLAY_SIGS_ROOT)]
end

.declared_reference?(env, name, checked) ⇒ Boolean

DefinitionBuilder#validate_type_name's membership test, memoised per env walk (a project's method signatures name the same handful of types over and over). A name whose normalization raises is treated as declared: repairing it is not this pass's job, and a wrong stub is the worse failure.

Returns:

  • (Boolean)


458
459
460
461
462
463
464
465
466
467
# File 'lib/rigor/environment/rbs_loader.rb', line 458

def declared_reference?(env, name, checked)
  key = name.to_s
  return checked[key] if checked.key?(key)

  checked[key] = begin
    env.type_name?(env.normalize_type_name(name))
  rescue StandardError
    true
  end
end

.declared_type_names(env) ⇒ Object

The ::-stripped names of every class / module / class-alias declaration already present in env, so the synthesis paths never re-declare (and thereby duplicate) a real declaration.



604
605
606
607
608
609
610
# File 'lib/rigor/environment/rbs_loader.rb', line 604

def declared_type_names(env)
  names = env.class_decls.keys.map { |n| n.to_s.sub(/\A::/, "") }
  if env.respond_to?(:class_alias_decls)
    names.concat(env.class_alias_decls.keys.map { |n| n.to_s.sub(/\A::/, "") })
  end
  names.to_set
end

.defaultRbsLoader

Returns:



47
48
49
# File 'lib/rigor/environment/rbs_loader.rb', line 47

def default
  @default ||= new.freeze
end

.entry_declarations(entry) ⇒ Object

Collects the AST declaration nodes behind a class_decls entry across the supported RBS range (`rbs

3.0, < 5.0). RBS 4's ModuleEntry/ClassEntryexposeeach_decl` yielding bare AST

declarations; RBS 3.x exposes decls, an array of MultiEntry::D wrappers whose #decl is the AST declaration. The single-decl shape is handled defensively so the loader survives an rbs-gem minor bump. Class-side because both the env-build detection walk and the instance-side #names_synthesized_in need it, and the guard must stay single-rooted.



489
490
491
492
493
494
495
496
497
498
499
# File 'lib/rigor/environment/rbs_loader.rb', line 489

def entry_declarations(entry)
  if entry.respond_to?(:each_decl)
    [].tap { |acc| entry.each_decl { |decl| acc << decl } }
  elsif entry.respond_to?(:decls)
    entry.decls.map { |d| d.respond_to?(:decl) ? d.decl : d }
  elsif entry.respond_to?(:decl)
    [entry.decl]
  else
    []
  end
end

.gem_overlay_sig_paths(gem_names) ⇒ Array<Pathname>

Returns the bundled overlay directory for each gem that ships one; empty when none match or the overlay root is absent.

Parameters:

  • gem_names (Enumerable<String>)

    overlay-eligible Gemfile.lock gem names (the caller filters to the :missing-coverage, no-conflicting-plugin set).

Returns:

  • (Array<Pathname>)

    the bundled overlay directory for each gem that ships one; empty when none match or the overlay root is absent.



752
753
754
755
756
757
758
759
# File 'lib/rigor/environment/rbs_loader.rb', line 752

def gem_overlay_sig_paths(gem_names)
  return [] unless File.directory?(GEM_OVERLAY_SIGS_ROOT)

  gem_names.filter_map do |name|
    dir = File.join(GEM_OVERLAY_SIGS_ROOT, name.to_s)
    Pathname(dir) if File.directory?(dir)
  end
end

.invalid_encoding?(content) ⇒ Boolean

Pre-parser guard for content Rigor hands to RBS::Parser. On rbs 4.1+ an invalid UTF-8 byte is a clean ParsingError (ruby/rbs#2983), but on the older releases the gemspec supports (>= 3.0, < 5.0) the C lexer could infinite-loop or abort on it (fixed upstream in ruby/rbs#2973) — a hang no rescue can catch, and the one failure mode the quarantine's fail-soft rescues cannot absorb. So the check runs before the parser on every rbs version: uniform behaviour, and the quarantine note stays actionable ("fix the file's encoding") rather than version-dependent.

Returns:

  • (Boolean)


311
312
313
# File 'lib/rigor/environment/rbs_loader.rb', line 311

def invalid_encoding?(content)
  !content.valid_encoding?
end

.libraries_without_shadowed_bigdecimal_math(libraries) ⇒ Array<String>

Returns the same list, minus bigdecimal-math when bigdecimal already brings BigMath in.

Parameters:

  • libraries (Array<String>)

    the resolved library list, DEFAULT_LIBRARIES included.

Returns:

  • (Array<String>)

    the same list, minus bigdecimal-math when bigdecimal already brings BigMath in.



118
119
120
121
122
123
# File 'lib/rigor/environment/rbs_loader.rb', line 118

def libraries_without_shadowed_bigdecimal_math(libraries)
  return libraries unless libraries.include?(BIGDECIMAL_LIBRARY) && libraries.include?(BIGDECIMAL_MATH_LIBRARY)
  return libraries unless bigdecimal_library_declares_big_math?

  libraries - [BIGDECIMAL_MATH_LIBRARY]
end

.parse_signature_file(file) ⇒ Object

Parse one project .rbs into [buffer, directives, decls], or nil when it is unparseable / unreadable / not valid UTF-8. Mirrors RBS::EnvironmentLoader#each_signature's per-file parse so the decls register identically to the loader's batch path.



318
319
320
321
322
323
324
325
326
327
# File 'lib/rigor/environment/rbs_loader.rb', line 318

def parse_signature_file(file)
  content = File.read(file, encoding: "UTF-8")
  return nil if invalid_encoding?(content)

  buffer = ::RBS::Buffer.new(name: file, content: content)
  _buffer, directives, decls = ::RBS::Parser.parse_signature(buffer)
  [buffer, directives, decls]
rescue ::RBS::ParsingError, Errno::ENOENT, Errno::EISDIR, Errno::EACCES
  nil
end

.parseable_rbs?(content) ⇒ Boolean

True when content parses as an RBS signature. #virtual_rbs_collision_quarantined uses this to tell a collision-dropped virtual entry (parses, but absent from the env) from a parse-failed one (the synthesizer's own WD6 skip, reported separately).

Returns:

  • (Boolean)


144
145
146
147
148
149
150
151
152
153
154
# File 'lib/rigor/environment/rbs_loader.rb', line 144

def parseable_rbs?(content)
  # Pre-parser encoding guard ({.invalid_encoding?}): invalid UTF-8 raises `ArgumentError` (not
  # `ParsingError`) out of `RBS::Parser.magic_comment`'s regex on rbs 4.1, escaping the rescue below,
  # and could hang the C lexer outright on the older releases the gemspec supports.
  return false if invalid_encoding?(content)

  ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor: virtual parse check)", content: content))
  true
rescue ::RBS::BaseError
  false
end

.primary_decl_for(entry) ⇒ Object

Normalises a class_decls entry's representative declaration across the gemspec's supported RBS range (rbs >= 3.0, < 5.0). RBS 4.x exposes it as entry.primary_decl (the AST declaration directly); RBS 3.x exposes entry.primary (a wrapper whose #decl is the AST declaration). Returns the AST declaration, or nil when neither accessor is present. Without this guard, class_decl_paths crashed under RBS 3.x with undefined method 'primary_decl'.



474
475
476
477
478
479
480
481
# File 'lib/rigor/environment/rbs_loader.rb', line 474

def primary_decl_for(entry)
  if entry.respond_to?(:primary_decl)
    entry.primary_decl
  elsif entry.respond_to?(:primary)
    primary = entry.primary
    primary.respond_to?(:decl) ? primary.decl : primary
  end
end

.project_entry?(entry, project_files) ⇒ Boolean

True when a class_decls entry was declared in one of the project's own signature files (by declaration location), so the sweep skips the bundled stdlib / vendored universe.

Returns:

  • (Boolean)


523
524
525
526
527
528
529
530
# File 'lib/rigor/environment/rbs_loader.rb', line 523

def project_entry?(entry, project_files)
  decl = primary_decl_for(entry)
  location = decl&.location
  buffer_name = location&.buffer&.name
  return false unless buffer_name

  project_files.include?(File.expand_path(buffer_name.to_s))
end

.project_sig_files(signature_paths) ⇒ Object

The absolute paths of every .rbs file under the project's signature_paths: (NOT vendored / stdlib RBS — those are well-formed, so attempting to build them would only waste time). Used to scope the referenced-type build sweep.



271
272
273
274
275
276
277
278
# File 'lib/rigor/environment/rbs_loader.rb', line 271

def project_sig_files(signature_paths)
  signature_paths.flat_map do |path|
    path = Pathname(path) unless path.is_a?(Pathname)
    next [] unless path.directory?

    Dir.glob(path.join("**", "*.rbs")).map { |p| File.expand_path(p) }
  end.to_set
end

.quarantined_project_signatures(signature_paths) ⇒ Object

The project signature_paths: files that FAIL to parse (or are not valid UTF-8), as [absolute_path, first_error_line] pairs (sorted, deterministic). Detection is independent of add_project_signatures so the warning fires even on a cache hit (where the env was already built with the file quarantined). Cheap: it only re-parses the user's own (usually small) sig/ set, and returns empty immediately when there is no signature_paths:.



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/rigor/environment/rbs_loader.rb', line 334

def quarantined_project_signatures(signature_paths)
  project_sig_files(signature_paths).sort.filter_map do |file|
    # The note carries the path itself because the warn composer prints only this element — a
    # `ParsingError` message embeds its `path:line:` prefix, so the composer never adds one.
    content = File.read(file, encoding: "UTF-8")
    next [file, "#{file}: #{INVALID_ENCODING_NOTE}"] if invalid_encoding?(content)

    buffer = ::RBS::Buffer.new(name: file, content: content)
    ::RBS::Parser.parse_signature(buffer)
    nil
  rescue ::RBS::ParsingError => e
    [file, e.message.to_s.lines.first.to_s.strip]
  rescue Errno::ENOENT, Errno::EISDIR, Errno::EACCES
    nil
  end
end

.reset_default!void

This method returns an undefined value.

Used by tests to discard the cached default loader; production code MUST NOT call this. The shared loader holds a several-MB RBS::Environment, so dropping it during a normal run wastes the cost of rebuilding it.



54
55
56
# File 'lib/rigor/environment/rbs_loader.rb', line 54

def reset_default!
  @default = nil
end

.resolve_quarantining_virtual_collisions(env, virtual_rbs) ⇒ Object

Backstop for a virtual-vs-anything RBS::DuplicatedDeclarationError that only materialises at resolve_type_names (which rebuilds the env from sources). add_virtual_rbs's transactional rescue already handles the add-time case — empirically everything on rbs 4.x — but the rbs gemspec range spans >= 3.0, < 5.0 (ADR-79) and WHERE duplicate detection fires is an rbs-internal choice this code must not depend on. Resolution rule is the same as the add-time path: the explicit signature wins, the colliding VIRTUAL buffer is dropped whole (RBS::Environment#unload) and resolution retries; every pass removes at least one virtual buffer, so the loop is bounded by the virtual-entry count. A duplicate involving no virtual buffer (sig-vs-sig), or an env without #unload (rbs 3.x), re-raises into the existing one-warning degrade path.

The dropped set is not returned: consumers recover it from the built env via #virtual_rbs_collision_quarantined, which also works on a cache HIT where this build never ran.



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/rigor/environment/rbs_loader.rb', line 168

def resolve_quarantining_virtual_collisions(env, virtual_rbs)
  virtual_names = virtual_rbs.to_set { |name, _content| name.to_s }
  (virtual_names.size + 1).times do
    return [env, env.resolve_type_names]
  rescue ::RBS::DuplicatedDeclarationError => e
    raise unless env.respond_to?(:unload)

    culprits = e.decls.filter_map { |decl| decl.location&.buffer&.name }
                      .uniq.select { |name| virtual_names.include?(name) }
    raise if culprits.empty?

    env = env.unload(culprits)
  end
  [env, env.resolve_type_names]
end

.stub_declaration_for(name, names) ⇒ Object

The declaration one stubbed name needs, keyed on the syntax of its leaf:

  • a name other stubbed names nest inside is a namespace, so module;
  • an RBS interface name (_Foo) may only be declared interface;
  • a type-alias name (foo) may only be declared type, and aliases untyped so a value of that type reads as Dynamic[Top] — the honest answer for a type Rigor invented;
  • anything else is class (referenced types appear in instance position far more often than as mixins).

The interface stub is FP-safe without joining #synthesized_type_names: RbsTypeTranslator maps every interface type to untyped, and the nil / non-nil acceptance guards in Analysis::CheckRules treat a method-less interface as accepting everything.



589
590
591
592
593
594
595
596
597
598
599
600
# File 'lib/rigor/environment/rbs_loader.rb', line 589

def stub_declaration_for(name, names)
  leaf = name.split("::").last.to_s
  if names.any? { |other| other != name && other.start_with?("#{name}::") }
    "module #{name}\nend\n"
  elsif leaf.start_with?("_")
    "interface #{name}\nend\n"
  elsif leaf.match?(/\A[a-z]/)
    "type #{name} = untyped\n"
  else
    "class #{name}\nend\n"
  end
end

.stub_missing_referenced_types(base_env, resolved, project_files) ⇒ Object



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/rigor/environment/rbs_loader.rb', line 206

def stub_missing_referenced_types(base_env, resolved, project_files)
  return resolved if project_files.empty?

  previous = nil
  MAX_STUB_PASSES.times do
    missing = unresolved_referenced_types(resolved, project_files)
    break if missing.empty?

    # Bound the fixpoint by PROGRESS, not by the cap alone. A pass that appends no declaration, or
    # that re-detects the set it already saw, cannot converge — and before this guard the cap was the
    # only stop, so the pathological input paid the full detection sweep five times over (measured on
    # herb: five passes, nothing synthesized). The cap stays as the backstop for a genuinely deepening
    # chain of references.
    current = missing.to_set
    break if current == previous || !append_stub_declarations(base_env, missing)

    previous = current
    resolved = base_env.resolve_type_names
  end
  resolved
end

.supplement_dependency_loaded?(supplements, path, loaded_library_names) ⇒ Boolean

Returns true when path carries no library dependency, or its library loaded.

Parameters:

  • supplements (Hash{String => String})

    basename → gating library map.

  • path (Pathname)

    the vendored directory or overlay file to test.

  • loaded_library_names (Set<String>)

    libraries that actually resolved on this loader.

Returns:

  • (Boolean)

    true when path carries no library dependency, or its library loaded.



733
734
735
736
# File 'lib/rigor/environment/rbs_loader.rb', line 733

def supplement_dependency_loaded?(supplements, path, loaded_library_names)
  library = supplements[path.basename.to_s]
  library.nil? || loaded_library_names.include?(library)
end

.synthesize_missing_namespaces(env) ⇒ Object

Robustness (ADR-5): a project whose RBS declares qualified names (class Foo::Bar) without ever declaring the enclosing namespace (module Foo) is invalid by upstream RBS rules — RBS::DefinitionBuilder#build_instance raises NoTypeFoundError: Could not find ::Foo, which the loader's fail-soft rescue turns into a silent dispatch miss (every method on every such class degrades to Dynamic[Top]). This is a common authoring mistake (e.g. shugo/textbringer ships a sig/ that rbs validate itself rejects). Rather than let an otherwise-usable signature set contribute nothing, synthesize an empty module declaration for each undeclared enclosing namespace so the definitions build. We only ever add names that are absent — a genuinely-declared namespace (module or class, here or in a loaded gem) is left untouched.



237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/rigor/environment/rbs_loader.rb', line 237

def synthesize_missing_namespaces(env)
  missing = collect_missing_namespaces(env)
  return if missing.empty?

  source = missing.map { |name| "module #{name}\nend\n" }.join
  buffer = ::RBS::Buffer.new(name: SYNTHETIC_NAMESPACE_BUFFER, content: source)
  _, directives, decls = ::RBS::Parser.parse_signature(buffer)
  add_parsed_decls(env, buffer, directives, decls)
rescue ::RBS::BaseError
  # Fail-soft: synthesis is an opportunistic uplift, never a hard requirement. A parse failure here
  # just leaves the env as it was (dispatch misses on the affected classes).
  nil
end

.unresolved_referenced_types(env, project_files) ⇒ Object

The ::-stripped names of every type a PROJECT signature references that no loaded declaration provides — the input to append_stub_declarations.

Detection READS the declarations; it does not build them. Until #207 it built every project class instance- and singleton-side with a throwaway RBS::DefinitionBuilder and recovered the name from the raised NoTypeFoundError — correct by construction, and 7.84M allocations, a third of a cold check lib on Rigor's own tree, to find nothing once sig/ is self-consistent. The walk below costs ~21k.

It mirrors rbs's raise sites, so the answer is the builder's:

  • DefinitionBuilder#validate_type_presence over the ARGS of a super class, a module self type, and a mixin. The name itself is deliberately NOT checked: a missing super class or mixin raises NoSuperclassFoundError / NoMixinFoundError, which this pass has never stubbed.
  • VarianceCalculator#type over every method type validate_type_params reaches, which raises for ClassInstance / Interface / Alias only. It skips initialize, and it is not called for the singleton side — so a name reachable only through def initialize: or def self.x: is not reported here, exactly as the builder did not report it. Stubbing those names anyway cost allocations on the corpus and changed no diagnostic.

Membership is decided with the builder's own predicate (declared_reference?), so a name reported here is one no declaration in the env provides. A resolvable name can never be reported, which is what keeps the stub safe: ADR-5 tier 2 trades precision for a fail-soft, and a stub for a name that WOULD have resolved would shadow a real type instead.

One reduction in scope. The builder also walked each project class's ANCESTORS, so a dangling reference inside a gem's signature reachable from a project class was reported too; this walk reads project declarations only. No such name occurs across the eight RBS-shipping projects the change was measured on, and the cost of missing one is the fail-soft Dynamic ADR-5 tier 2 already accepts — not a false diagnostic. A project INTERFACE's own dangling reference is likewise not reported, and likewise was not by the builder (validate_type_params does not variance-walk the methods a class imports from an interface) — pinned by spec so the two stay together.

Equivalence with the builder sweep is pinned by spec, which keeps the sweep as its oracle. Full evaluation: docs/notes/20260730-stub-pass1-static-detection-evaluation.md.



386
387
388
389
390
391
392
393
394
395
# File 'lib/rigor/environment/rbs_loader.rb', line 386

def unresolved_referenced_types(env, project_files)
  missing = {}
  checked = {}
  env.class_decls.each_value do |entry|
    next unless project_entry?(entry, project_files)

    entry_declarations(entry).each { |decl| collect_declaration_references(env, decl, missing, checked) }
  end
  missing.keys
end

.vendored_gem_namesArray[String]

Gem names whose RBS ships under data/vendored_gem_sigs/<gem>/. The directory walk is the source of truth (the README.md sibling is not a gem and is excluded). Callers building the RBS env use this set to drop the matching rbs collection install directory before it double-declares against the vendored copy — the same hazard DEFAULT_LIBRARIES creates for stdlib-extracted gems. See RbsCollectionDiscovery's skip_gem_names:.

Returns:

  • (Array[String])


774
775
776
777
778
779
780
# File 'lib/rigor/environment/rbs_loader.rb', line 774

def vendored_gem_names
  return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)

  Dir.children(VENDORED_GEM_SIGS_ROOT).reject do |child|
    File.file?(File.join(VENDORED_GEM_SIGS_ROOT, child))
  end
end

.vendored_gem_sig_pathsArray[Pathname]

Returns:

  • (Array[Pathname])


761
762
763
764
765
766
767
# File 'lib/rigor/environment/rbs_loader.rb', line 761

def vendored_gem_sig_paths
  return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)

  Dir.children(VENDORED_GEM_SIGS_ROOT).map do |gem_dir|
    Pathname(File.join(VENDORED_GEM_SIGS_ROOT, gem_dir))
  end
end

Instance Method Details

#class_decl_pathsObject

Returns a frozen Hash<String, String> mapping each loaded class / module name (top-level prefixed) to the file path of its FIRST declaration's RBS source. Used by Analysis::RunStats to attribute the type universe between "project sig/" (paths under the configured signature_paths) and "bundled" (everything else — RBS core, stdlib libraries, gem-bundled RBS). Each value is a frozen String so the whole result is Ractor.shareable? — the Phase 4b worker pool ships a snapshot back to the coordinator on the first :prepare message.



1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
# File 'lib/rigor/environment/rbs_loader.rb', line 1002

def class_decl_paths
  return {}.freeze if env.nil?

  result = {}
  env.class_decls.each do |rbs_name, entry|
    decl = self.class.primary_decl_for(entry)
    next if decl.nil?

    location = decl.location
    next if location.nil?

    buffer = location.buffer
    name = buffer.respond_to?(:name) ? buffer.name : nil
    next if name.nil?

    result[rbs_name.to_s.dup.freeze] = name.to_s.dup.freeze
  end
  result.freeze
rescue ::RBS::BaseError
  {}.freeze
end

#class_known?(name) ⇒ Boolean

Returns true when an RBS class or module declaration with the given name is loaded. Accepts unprefixed or top-level-prefixed names ("Integer" or "::Integer"). Memoized per-name (positive and negative results both cache).

When cache_store is set, the loader fetches the entire set of known class / module / alias names once (per process) through Cache::RbsKnownClassNames.fetch and answers class_known? from the in-memory Set. Cold runs pay a single env walk and persist the result; warm runs (and a separate loader sharing the same Store) skip the env walk entirely.

Parameters:

  • name (String, Symbol)

Returns:

  • (Boolean)


908
909
910
911
912
913
914
915
916
917
# File 'lib/rigor/environment/rbs_loader.rb', line 908

def class_known?(name)
  key = name.to_s
  return @class_known_cache[key] if @class_known_cache.key?(key)

  @class_known_cache[key] = if cache_store
                              cached_class_known(name)
                            else
                              compute_class_known(name)
                            end
end

#class_ordering(lhs, rhs) ⇒ ordering

Parameters:

  • lhs (String, Symbol)
  • rhs (String, Symbol)

Returns:

  • (ordering)


1149
1150
1151
# File 'lib/rigor/environment/rbs_loader.rb', line 1149

def class_ordering(lhs, rhs)
  @hierarchy.class_ordering(lhs, rhs)
end

#class_type_param_names(class_name) ⇒ Array[Symbol]

Slice 4 phase 2d. Returns the class's declared type-parameter names as Symbols (e.g., [:Elem] for Array, [:K, :V] for Hash). Used by the dispatcher to build the substitution map from receiver type_args into the method's return type. The instance definition is the canonical source because singleton methods (e.g., Array.new) parameterize over the same Elem as instance methods.

Returns an empty array for non-generic classes and for unknown names (the loader stays fail-soft). NOTE: in the rbs gem, RBS::Definition#type_params returns Array<Symbol> directly, not the AST TypeParam object (those live on the AST level).

When cache_store is set, the loader fetches the entire type-parameter-name table once (per process) through Cache::RbsClassTypeParamNames.fetch and answers point lookups from it. Cold runs build the table once and persist it; warm runs (and a separate loader sharing the same Store) skip the env walk entirely.

Parameters:

  • class_name (String, Symbol)

Returns:

  • (Array[Symbol])


1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
# File 'lib/rigor/environment/rbs_loader.rb', line 1137

def class_type_param_names(class_name)
  if cache_store
    key = class_name.to_s.delete_prefix("::")
    return type_param_names_table.fetch(key, []).dup
  end

  definition = instance_definition(class_name)
  return [] unless definition

  definition.type_params.dup
end

#constant_namesArray<String>

Returns every RBS-declared constant name (top-level prefixed, e.g., "::Math::PI") currently loaded into the environment. Used by the cache producer that materialises the constant-type table; ordinary callers should keep using #constant_type for point lookups.

Returns:

  • (Array<String>)

    every RBS-declared constant name (top-level prefixed, e.g., "::Math::PI") currently loaded into the environment. Used by the cache producer that materialises the constant-type table; ordinary callers should keep using #constant_type for point lookups.



1156
1157
1158
1159
1160
1161
1162
# File 'lib/rigor/environment/rbs_loader.rb', line 1156

def constant_names
  return [] if env.nil?

  env.constant_decls.keys.map(&:to_s)
rescue ::RBS::BaseError
  []
end

#constant_type(name) ⇒ Type::t?

Slice A constant-value lookup. Returns the translated Rigor::Type for a non-class constant declaration (BUCKETS: Array[Symbol], DEFAULT_PATH: String, ...) or nil when no constant entry exists for name in the loaded RBS environment. Callers MUST treat the return value as authoritative when present and as "unknown" when nil; the loader does NOT consult the class declarations here — class objects are still resolved through #class_known? and Environment#singleton_for_name.

When cache_store is set, the loader fetches the entire translated constant table once (per process) through Cache::RbsConstantTable.fetch and answers point lookups from it. Cold runs pay the translation cost up-front and write the result to disk; warm runs skip the translation entirely and pay only a Marshal.load of the table.

Parameters:

  • name (String)

Returns:

  • (Type::t, nil)


1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
# File 'lib/rigor/environment/rbs_loader.rb', line 1188

def constant_type(name)
  rbs_name = parse_type_name(name)
  return nil unless rbs_name

  if cache_store
    constant_type_table[rbs_name.to_s]
  else
    translate_constant_decl(rbs_name)
  end
rescue ::RBS::BaseError
  nil
end

#each_class_decl_annotationObject

ADR-20 slice 2e — iterates over every %a{...} annotation attached to a class- or module-level declaration in the loaded RBS environment, yielding (annotation_string, source_location) pairs. Used by Inference::HktRegistry.scan_rbs_loader to find rigor:v1:hkt_register / rigor:v1:hkt_define directives in user-authored overlays and merge them into the per-Environment HKT registry. Yields nothing when the env failed to build (fail-soft, same shape as #each_known_class_name).



960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
# File 'lib/rigor/environment/rbs_loader.rb', line 960

def each_class_decl_annotation
  return enum_for(:each_class_decl_annotation) unless block_given?
  return if env.nil?

  env.class_decls.each_value do |entry|
    entry.each_decl do |decl|
      next unless decl.respond_to?(:annotations)

      decl.annotations.each { |a| yield a.string, a.location }
    end
  end
rescue ::RBS::BaseError, ::Ractor::IsolationError
  # fail-soft: matches each_known_class_name's policy. Ractor::IsolationError surfaces when the scan
  # is invoked from a non-main Ractor pool worker before ADR-15's full deep-freeze migration completes
  # — the worker falls back to the base (builtins-only) registry rather than crashing.
end

#each_class_decl_annotation_with_nameObject

Like #each_class_decl_annotation, but also yields the owning class / module's RBS name as the first block argument: (class_name, annotation_string, location). Used by RbsExtended::ConformanceChecker to resolve a rigor:v1:conforms-to directive back to the class it annotates. Same fail-soft policy as the un-named variant.



981
982
983
984
985
986
987
988
989
990
991
992
993
994
# File 'lib/rigor/environment/rbs_loader.rb', line 981

def each_class_decl_annotation_with_name
  return enum_for(:each_class_decl_annotation_with_name) unless block_given?
  return if env.nil?

  env.class_decls.each do |rbs_name, entry|
    entry.each_decl do |decl|
      next unless decl.respond_to?(:annotations)

      decl.annotations.each { |a| yield rbs_name.to_s, a.string, a.location }
    end
  end
rescue ::RBS::BaseError, ::Ractor::IsolationError
  # fail-soft: see #each_class_decl_annotation.
end

#each_constant_declObject

Yields (name, entry) for every RBS constant declaration currently loaded into the environment. The cache producer uses this to materialise the constant-type table without going back through #constant_type (which would recurse back into the cache when cache_store is set).



1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
# File 'lib/rigor/environment/rbs_loader.rb', line 1167

def each_constant_decl
  return enum_for(:each_constant_decl) unless block_given?
  return if env.nil?

  env.constant_decls.each do |rbs_name, entry|
    yield rbs_name.to_s, entry
  end
rescue ::RBS::BaseError
  # fail-soft: a broken RBS environment yields no entries.
end

#each_known_class_name {|name| ... } ⇒ Object

Yields every known class / module / alias name (top-level prefixed) currently loaded into the environment. The cache producer that materialises the known-name set uses this so it never recurses back through #class_known?.

Yields:

Yield Parameters:

  • name (String)

Yield Returns:

  • (void)

Returns:

  • (Object)


942
943
944
945
946
947
948
949
950
951
952
# File 'lib/rigor/environment/rbs_loader.rb', line 942

def each_known_class_name
  return enum_for(:each_known_class_name) unless block_given?
  return if env.nil?

  env.class_decls.each_key { |rbs_name| yield rbs_name.to_s }
  env.class_alias_decls.each_key { |rbs_name| yield rbs_name.to_s }
rescue ::RBS::BaseError
  # fail-soft: a broken RBS environment yields no names. Analyzer-internal errors (NameError,
  # NoMethodError, LoadError) are NOT swallowed — those are bugs and must surface so they don't hide
  # silently the way the v0.0.9 cache `Cache::Descriptor` regression did.
end

#env_build_failureArray(String, String, Array<String>)?

The total RBS-environment build failure captured this run, or nil when the env built. Unlike #quarantined_signatures — which the env survives, one file lighter, and which is re-derived by re-parsing so a cache HIT reports it too — a total failure (typically RBS::DuplicatedDeclarationError: a signature_paths: entry redeclaring a constant/class Rigor's bundled RBS already ships) collapses the WHOLE env to nil. A failed build produces no cached success to hide behind (nothing is persisted, so every run re-attempts and re-raises), so this is captured directly in #env's rescue rather than re-derived. Forcing env (any query does) populates it.

Returns:

  • (Array(String, String, Array<String>), nil)

    [error_class_name, first_error_line, conflicting_buffer_names], or nil when the environment built successfully.



851
852
853
854
# File 'lib/rigor/environment/rbs_loader.rb', line 851

def env_build_failure
  env unless @state[:env_loaded]
  @state[:env_build_failure]
end

#expand_type_alias(rbs_alias) ⇒ RBS::Types::t?

Returns the alias's aliased type one level out, with type arguments substituted for a generic alias (string::String | ::_ToStr; range[int?]::Range[int?] | ::_Range[int?]), or nil for an unresolved name. Lets a caller see through the alias that Inference::RbsTypeTranslator otherwise degrades to untyped, which is why an interface/alias parameter does not reject nil. expand_alias2 handles the (rarer) generic case — a range[T] param previously fell back to "admits", which suppressed e.g. MatchData#[](nil).

Parameters:

  • rbs_alias (RBS::Types::Alias)

    a type-alias reference (string, int, range[int?], …) appearing in a method signature.

Returns:

  • (RBS::Types::t, nil)

    the alias's aliased type one level out, with type arguments substituted for a generic alias (string::String | ::_ToStr; range[int?]::Range[int?] | ::_Range[int?]), or nil for an unresolved name. Lets a caller see through the alias that Inference::RbsTypeTranslator otherwise degrades to untyped, which is why an interface/alias parameter does not reject nil. expand_alias2 handles the (rarer) generic case — a range[T] param previously fell back to "admits", which suppressed e.g. MatchData#[](nil).



1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
# File 'lib/rigor/environment/rbs_loader.rb', line 1087

def expand_type_alias(rbs_alias)
  return nil if env.nil?

  name = rbs_alias.name
  name = name.absolute! unless name.absolute?
  return nil unless env.type_alias_decls.key?(name)

  builder.expand_alias2(name, rbs_alias.args)
rescue ::RBS::BaseError, StandardError
  nil
end

#instance_definition(class_name) ⇒ RBS::Definition?

Built on demand from the (possibly cache-loaded) env; the in-memory @instance_definition_cache keeps the per-process short-circuit. ADR-54 WD1 retired the definitions disk blob: given a cached env, Marshal.load-ing every definition was measurably slower (and allocation-heavier) than rebuilding the ones a run actually touches.

Parameters:

  • class_name (String, Symbol)

Returns:

  • (RBS::Definition, nil)

    the resolved instance definition for class_name, or nil when the class is unknown or its definition cannot be built (RBS may raise on broken hierarchies; we fail-soft and return nil so the caller can fall back).



1032
1033
1034
1035
1036
1037
# File 'lib/rigor/environment/rbs_loader.rb', line 1032

def instance_definition(class_name)
  key = class_name.to_s
  return @instance_definition_cache[key] if @instance_definition_cache.key?(key)

  @instance_definition_cache[key] = build_instance_definition(class_name)
end

#instance_method(class_name:, method_name:) ⇒ RBS::Definition::Method?

Parameters:

  • class_name: (String, Symbol)
  • method_name: (String, Symbol)

Returns:

  • (RBS::Definition::Method, nil)


1040
1041
1042
1043
1044
1045
# File 'lib/rigor/environment/rbs_loader.rb', line 1040

def instance_method(class_name:, method_name:)
  definition = instance_definition(class_name)
  return nil unless definition

  definition.methods[method_name.to_sym]
end

#instance_method_names(class_name) ⇒ Array<Symbol>?

Returns every instance-method name on class_name — own, inherited, and included — as resolved by RBS::DefinitionBuilder. Returns nil (NOT []) when the class definition cannot be built so callers can tell "no methods" apart from "unknown class". Used by the rigor:v1:conforms-to presence check (RbsExtended::ConformanceChecker).

Returns:

  • (Array<Symbol>, nil)

    every instance-method name on class_name — own, inherited, and included — as resolved by RBS::DefinitionBuilder. Returns nil (NOT []) when the class definition cannot be built so callers can tell "no methods" apart from "unknown class". Used by the rigor:v1:conforms-to presence check (RbsExtended::ConformanceChecker).



1051
1052
1053
1054
1055
1056
# File 'lib/rigor/environment/rbs_loader.rb', line 1051

def instance_method_names(class_name)
  definition = instance_definition(class_name)
  return nil unless definition

  definition.methods.keys
end

#interface_definition(interface_name) ⇒ RBS::Definition?

Returns the built definition for the RBS interface interface_name (_RewindableStream), whose .methods are the required members (including interface-ancestor members). Returns nil when the name does not resolve to a loaded interface (a typo, or the defining library / sig set is not on the load path). Fail-soft on RBS build errors.

Returns:

  • (RBS::Definition, nil)

    the built definition for the RBS interface interface_name (_RewindableStream), whose .methods are the required members (including interface-ancestor members). Returns nil when the name does not resolve to a loaded interface (a typo, or the defining library / sig set is not on the load path). Fail-soft on RBS build errors.



1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
# File 'lib/rigor/environment/rbs_loader.rb', line 1062

def interface_definition(interface_name)
  rbs_name = parse_type_name(interface_name)
  return nil unless rbs_name
  return nil if env.nil?
  return nil unless env.interface_decls.key?(rbs_name)

  builder.build_interface(rbs_name)
rescue ::RBS::BaseError
  nil
end

#interface_method_names(interface_name) ⇒ Array<Symbol>?

Returns every method name required by the RBS interface interface_name, or nil when it does not resolve. Thin accessor over #interface_definition for the presence check.

Returns:

  • (Array<Symbol>, nil)

    every method name required by the RBS interface interface_name, or nil when it does not resolve. Thin accessor over #interface_definition for the presence check.



1075
1076
1077
# File 'lib/rigor/environment/rbs_loader.rb', line 1075

def interface_method_names(interface_name)
  interface_definition(interface_name)&.methods&.keys
end

#prewarmObject

ADR-15 Phase 4b.x — eagerly drives every cached producer (plus the eager definitions tables, computed from the cached env since ADR-54 WD1) so a subsequent worker Ractor can serve all of its RBS queries without ever calling RBS::EnvironmentLoader.new. The loader path that calls EnvironmentLoader.new transitively reads a chain of non-Ractor.shareable? module constants (RBS::EnvironmentLoader::DEFAULT_CORE_ROOT, RBS::Repository::DEFAULT_STDLIB_ROOT, Gem::Requirement::DefaultRequirement, …) and trips Ractor::IsolationError. Pre-warming on the main Ractor — env blob loaded, derived tables built — keeps workers off that chain (RBS::DefinitionBuilder over an already-built env does not touch it).

No-op when cache_store is nil — without a Store the worker has no choice but to build env via the loader, so the caller MUST ensure pool mode runs with caching enabled. Returns self so the call chains cleanly from the Runner pre-spawn hook.



1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
# File 'lib/rigor/environment/rbs_loader.rb', line 1213

def prewarm
  return self if cache_store.nil?

  env
  known_class_names_set
  constant_type_table
  type_param_names_table
  ancestor_names_table
  instance_definitions_table
  singleton_definitions_table
  self
end

#quarantined_signaturesArray<Array(String, String)>

The project signature_paths: files that were QUARANTINED this run (they do not parse, so add_project_signatures skipped them to keep the rest of the env alive), as [absolute_path, first_error_line] pairs. Memoised per loader: the detection re-parses only the user's own sig/ set, but every consumer (the rbs.coverage.quarantined-signature diagnostic, rigor doctor, the stderr banner) reads it, and a cache HIT reaches it too — the env was built with the file already quarantined, so the condition is invisible in the cached env itself.

Returns:

  • (Array<Array(String, String)>)

    empty when every signature_paths: file parses.



837
838
839
# File 'lib/rigor/environment/rbs_loader.rb', line 837

def quarantined_signatures
  @state[:quarantined] ||= self.class.quarantined_project_signatures(@signature_paths).freeze
end

#rbs_cache_descriptorObject

ADR-54 WD4 — the shared cache descriptor for every RBS-derived producer consulting this loader. Building it digests every .rbs file under signature_paths + the vendored gem sigs, and the result is identical across producers, so one build is memoised per loader (on @state, alongside :env — the env itself is loader-lifetime-memoised, so this adds no new staleness class).



1230
1231
1232
1233
1234
1235
# File 'lib/rigor/environment/rbs_loader.rb', line 1230

def rbs_cache_descriptor
  @state[:rbs_cache_descriptor] ||= begin
    require_relative "../cache/rbs_descriptor"
    Cache::RbsDescriptor.build(self)
  end
end

#rbs_module?(name) ⇒ Boolean

Returns true when the named RBS declaration is a Module (RBS::AST::Declarations::Module) rather than a Class. The user_class_fallback_receiver tier consults this to route Nominal[M].some_kernel_method (where M is a module mixin like PP::ObjectMixin) through the Nominal[Object] fallback, because every concrete includer of M sees Kernel / Object instance methods as part of its own ancestor chain.

Returns false for classes, for unknown names, and when the RBS environment failed to build (fail-soft).

Parameters:

  • name (String, Symbol)

Returns:

  • (Boolean)


927
928
929
930
931
932
933
934
935
936
937
# File 'lib/rigor/environment/rbs_loader.rb', line 927

def rbs_module?(name)
  return false if env.nil?

  rbs_name = parse_type_name(name)
  return false if rbs_name.nil?

  entry = env.class_decls[rbs_name]
  entry.is_a?(::RBS::Environment::ModuleEntry)
rescue ::RBS::BaseError
  false
end

#reflectionObject

ADR-15 Phase 2b — return the loader's read-only query surface as a frozen, Ractor.shareable? Rigor::Environment::Reflection value object. Built lazily on first access; the loader memoises so repeated calls return the same instance.

The Reflection consumes the loader's already-warmed cache producers (or, when no cache_store is set, eagerly walks the env). Once constructed, the Reflection carries the derived tables independently and never re-consults the loader — making it safe to share across Ractors while the loader stays per- process / per-Ractor for write-path operations.



1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
# File 'lib/rigor/environment/rbs_loader.rb', line 1245

def reflection
  @state[:reflection] ||= begin
    require_relative "reflection"
    Environment::Reflection.new(
      known_class_names: known_class_names_set,
      instance_definitions: instance_definitions_table,
      singleton_definitions: singleton_definitions_table,
      type_param_names: type_param_names_table,
      constant_types: constant_type_table,
      ancestor_names: ancestor_names_table
    )
  end
end

#singleton_definition(class_name) ⇒ RBS::Definition?

Built on demand from the env with a per-process memo; the same on-demand discipline as #instance_definition (ADR-54 WD1).

Parameters:

  • class_name (String, Symbol)

Returns:

  • (RBS::Definition, nil)

    the resolved singleton (class object) definition for class_name. The methods on this definition are the class methods of class_name, including those inherited from Class and Module for class types. Returns nil for unknown names and on RBS build errors (fail-soft).



1106
1107
1108
1109
1110
1111
# File 'lib/rigor/environment/rbs_loader.rb', line 1106

def singleton_definition(class_name)
  key = class_name.to_s
  return @singleton_definition_cache[key] if @singleton_definition_cache.key?(key)

  @singleton_definition_cache[key] = build_singleton_definition(class_name)
end

#singleton_method(class_name:, method_name:) ⇒ RBS::Definition::Method?

Returns the class method on class_name. For example, singleton_method(class_name: "Integer", method_name: :sqrt) returns the definition for Integer.sqrt, while singleton_method(class_name: "Foo", method_name: :new) returns Class#new for any class type.

Parameters:

  • class_name: (String, Symbol)
  • method_name: (String, Symbol)

Returns:

  • (RBS::Definition::Method, nil)

    the class method on class_name. For example, singleton_method(class_name: "Integer", method_name: :sqrt) returns the definition for Integer.sqrt, while singleton_method(class_name: "Foo", method_name: :new) returns Class#new for any class type.



1117
1118
1119
1120
1121
1122
# File 'lib/rigor/environment/rbs_loader.rb', line 1117

def singleton_method(class_name:, method_name:)
  definition = singleton_definition(class_name)
  return nil unless definition

  definition.methods[method_name.to_sym]
end

#synthesized_namespacesObject

The enclosing namespaces synthesize_missing_namespaces had to invent because the project's signature_paths: RBS declared qualified names (class Foo::Bar) without ever declaring Foo. Recovered by scanning the built env for class/module entries whose every declaration originated from the synthetic buffer, so the answer survives the marshalled-env cache (where no build-time collector would). Returns ::-stripped names, shallowest-first. Empty for a well-formed sig set (the common case) and whenever the env failed to build.



825
826
827
# File 'lib/rigor/environment/rbs_loader.rb', line 825

def synthesized_namespaces
  names_synthesized_in(SYNTHETIC_NAMESPACE_BUFFER)
end

#synthesized_stub_typesObject

The referenced-but-undeclared types stub_missing_referenced_types stubbed so the project classes that mention them could build (e.g. an unavailable DRb::DRbServer, or a stale Textbringer::EditorError). Recovered off the built env like #synthesized_namespaces, so it survives the marshalled-env cache.

Class / module stubs only — the interface and type stubs stub_declaration_for also emits live in interface_decls / type_alias_decls, not class_decls. That is deliberate: this list exists to feed #synthesized_type_names, whose consumers key on a nominal receiver's class name, and both of those kinds already read as untyped through Inference::RbsTypeTranslator.



887
888
889
# File 'lib/rigor/environment/rbs_loader.rb', line 887

def synthesized_stub_types
  names_synthesized_in(SYNTHETIC_STUB_BUFFER)
end

#synthesized_type_namesObject

Every type name Rigor invented to make an otherwise-inert / unbuildable project signature set resolve — both the namespace stubs and the referenced-type stubs. MethodDispatcher resolves a call whose receiver is one of these (and that no real signature answered) to Dynamic[Top], so the empty stub never mis-fires call.undefined-method. Memoised; empty (and cheap) for the common well-formed sig set.



896
897
898
# File 'lib/rigor/environment/rbs_loader.rb', line 896

def synthesized_type_names
  @state[:synthesized_type_names] ||= (synthesized_namespaces + synthesized_stub_types).to_set
end

#virtual_rbs_collision_quarantinedArray<String>

Virtual (inline-synthesized) contributions dropped by the collision quarantine (resolve_quarantining_virtual_collisions): buffer names absent from the built env even though the entry's content is non-empty and parses (a parse failure is the synthesizer's own WD6 skip, reported through the synthesis reporter instead). Derived from the env rather than recorded during build — the #quarantined_signatures trick — so a cache HIT, which never runs the build, reports the same condition: the marshalled env simply lacks the dropped buffers.

Returns:

  • (Array<String>)

    virtual buffer names (source-file paths) whose contribution was dropped.



864
865
866
867
868
869
870
871
872
873
874
875
876
# File 'lib/rigor/environment/rbs_loader.rb', line 864

def virtual_rbs_collision_quarantined
  @state[:virtual_rbs_collisions] ||= begin
    built = @state[:env]
    if built.nil? || @virtual_rbs.empty?
      [].freeze
    else
      present = built.buffers.to_set(&:name)
      @virtual_rbs.filter_map do |name, content|
        name if !content.empty? && !present.include?(name) && self.class.parseable_rbs?(content)
      end.freeze
    end
  end
end