rigortype 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -35,11 +35,12 @@ module Rigor
35
35
  found.freeze
36
36
  end
37
37
 
38
- # `<bundle>/ruby/X.Y.Z/gems/<name>-<version>/` — the same pure-filesystem layout
38
+ # `<bundle>/ruby/X.Y.Z/gems/<name>-<version>[-<platform>]/` — the same pure-filesystem layout
39
39
  # {MissingGemConstantIndex.bundle_gem_dirs} walks, preferred over the host's gems so a project that
40
40
  # vendored its bundle without committing the lockfile is read from its own tree. A platform-tagged
41
- # directory (`ffi-1.17.4-aarch64-linux-gnu`) does not split into a name/version pair and is skipped:
42
- # those gems ship native code, not the pure-Ruby constants this feeds.
41
+ # directory (`commonmarker-2.10.0-arm64-darwin`) is kept with its platform as a variant: those builds
42
+ # ship the same `lib/<name>.rb` and top-level namespace as the `ruby` one, and the index looks the
43
+ # directory up by that variant. A name installed more than once keeps every directory as a variant.
43
44
  def from_bundle_tree(bundle_path)
44
45
  return {} if bundle_path.nil?
45
46
 
@@ -47,10 +48,15 @@ module Rigor
47
48
  return {} unless base.directory?
48
49
 
49
50
  Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
50
- name, version = split_name_version(File.basename(dir))
51
+ name, version, platform = split_install_dirname(File.basename(dir))
51
52
  next unless name
52
53
 
53
- acc[name] ||= locked_gem(name, version)
54
+ prior = acc[name]
55
+ acc[name] = if prior
56
+ prior.with(variants: [*prior.variants, [version, platform]])
57
+ else
58
+ locked_gem(name, version, platform)
59
+ end
54
60
  end
55
61
  end
56
62
 
@@ -70,15 +76,36 @@ module Rigor
70
76
  {}
71
77
  end
72
78
 
73
- def locked_gem(name, version)
74
- LockfileResolver::LockedGem.new(name: name, version: version, platform: "ruby")
79
+ def locked_gem(name, version, platform = "ruby")
80
+ LockfileResolver::LockedGem.new(name: name, version: version, platform: platform)
75
81
  end
76
82
 
77
- # Splits a gem install directory's basename. The version tail is anchored on a leading digit and must
78
- # carry no further hyphen, which is what excludes the platform-tagged variants above.
79
- def split_name_version(basename)
83
+ # Splits a gem install directory's basename into `[name, version, platform]`, or nil. The version
84
+ # always starts with a digit and carries no hyphen; a platform tail is accepted only when RubyGems
85
+ # recognises its OS (`arm64-darwin`, `x86_64-linux-gnu`, `java`), so `foo-2fa-1.0` stays the plain gem
86
+ # `foo-2fa`. The platform split is tried first, because a plain split of `foo-1.0-universal-darwin-23`
87
+ # would claim the name `foo-1.0-universal-darwin`; and from the rightmost version-shaped segment,
88
+ # because RubyGems also reads `1.0-x86_64-linux` as a linux platform, which would cut
89
+ # `foo-2fa-1.0-x86_64-linux` at `foo`.
90
+ def split_install_dirname(basename)
91
+ platform_split(basename) || plain_split(basename)
92
+ end
93
+
94
+ def platform_split(basename)
95
+ cuts = []
96
+ basename.scan(/-(\d[^-]*)(?=-.)/) { cuts << Regexp.last_match }
97
+ cuts.reverse_each do |match|
98
+ platform = match.post_match.delete_prefix("-")
99
+ # `current` makes `Gem::Platform.new` read the host's platform, which a non-main Ractor may not touch.
100
+ next if platform == "current"
101
+ return [match.pre_match, match[1], platform] unless Gem::Platform.new(platform).os == "unknown"
102
+ end
103
+ nil
104
+ end
105
+
106
+ def plain_split(basename)
80
107
  match = /\A(.+)-(\d[^-]*)\z/.match(basename)
81
- match ? [match[1], match[2]] : nil
108
+ match ? [match[1], match[2], "ruby"] : nil
82
109
  end
83
110
  end
84
111
  end
@@ -30,13 +30,29 @@ module Rigor
30
30
  # dependency that moved from a `git:` source to a released version without a `bundle clean`, so its
31
31
  # git-installed directory is still sitting there under the OLD source's name). Defaults `false` so
32
32
  # existing callers that construct a `LockedGem` without an opinion stay conservative.
33
- LockedGem = Data.define(:name, :version, :platform, :git_source) do
34
- def initialize(name:, version:, platform:, git_source: false)
33
+ #
34
+ # `variants` is every `[version, platform]` pair the lockfile locks under this name, in Bundler's parsed
35
+ # order. A multi-platform lockfile (`bundle lock --add-platform`) lists one spec per platform — `ffi
36
+ # (1.17.4)`, `ffi (1.17.4-arm64-darwin)`, … — and Bundler installs whichever matches the host, so
37
+ # {BundleSigDiscovery} must admit all of them (#1844). `version` / `platform` name one representative
38
+ # variant for the readers that want a single version (coverage report, doctor): the `ruby` one when the
39
+ # lockfile has it, otherwise the last parsed. Defaults to the single `[version, platform]` pair, and the
40
+ # pair must be one of `variants`.
41
+ LockedGem = Data.define(:name, :version, :platform, :git_source, :variants) do
42
+ def initialize(name:, version:, platform:, git_source: false, variants: nil)
43
+ version = -version.to_s
44
+ platform = -platform.to_s
45
+ variants = (variants || [[version, platform]]).map { |v, p| [-v.to_s, -p.to_s].freeze }.uniq.freeze
46
+ unless variants.include?([version, platform])
47
+ raise ArgumentError, "LockedGem #{name}: [#{version}, #{platform}] is not one of its variants"
48
+ end
49
+
35
50
  super(
36
51
  name: -name.to_s,
37
- version: -version.to_s,
38
- platform: -platform.to_s,
39
- git_source: git_source ? true : false
52
+ version: version,
53
+ platform: platform,
54
+ git_source: git_source ? true : false,
55
+ variants: variants
40
56
  )
41
57
  end
42
58
  end
@@ -162,10 +178,13 @@ module Rigor
162
178
  # `#source` is the `Bundler::Source::*` this spec resolved from — `Git` for a `GIT` lockfile
163
179
  # stanza, `Rubygems` for a `GEM` one, `Path`/`Gemspec` for `path:`. Only `Git` sources land under
164
180
  # {BundleSigDiscovery}'s `bundler/gems/` walk.
165
- h[spec.name.to_s] = LockedGem.new(
181
+ # A multi-platform lockfile repeats the name once per platform; fold the repeats into `variants`
182
+ # instead of letting the last one overwrite the rest (#1844).
183
+ entry = LockedGem.new(
166
184
  name: spec.name, version: spec.version.to_s, platform: platform,
167
185
  git_source: spec.source.is_a?(Bundler::Source::Git)
168
186
  )
187
+ h[entry.name] = (prior = h[entry.name]) ? merge_variant(prior, entry) : entry
169
188
  end
170
189
  locked.freeze
171
190
  rescue ::Ractor::IsolationError => e
@@ -177,6 +196,18 @@ module Rigor
177
196
  end
178
197
  private_class_method :do_parse
179
198
 
199
+ # Folds a further platform variant of an already-parsed name into it. The representative keeps a `ruby`
200
+ # variant once seen; otherwise the last variant wins, as it did before variants were kept.
201
+ def self.merge_variant(prior, entry)
202
+ rep = prior.platform == "ruby" ? prior : entry
203
+ prior.with(
204
+ variants: [*prior.variants, [entry.version, entry.platform]],
205
+ version: rep.version, platform: rep.platform,
206
+ git_source: prior.git_source || entry.git_source
207
+ )
208
+ end
209
+ private_class_method :merge_variant
210
+
180
211
  # #1064 — Bundler's lockfile parser reads RubyGems module state a non-main Ractor may not touch, so the
181
212
  # parse raises `Ractor::IsolationError` there whatever the lockfile says. That is an engine constraint, not
182
213
  # a malformed lockfile, and the warning must not send the user to fix a file that is fine. The Ractor pool
@@ -37,8 +37,8 @@ module Rigor
37
37
  module MissingGemConstantIndex
38
38
  module_function
39
39
 
40
- # @param gems — `[gem_name, version]` pairs (the `:missing` rows of
41
- # {RbsCoverageReport}).
40
+ # @param gems — `[gem_name, version, variants]` entries (the `:missing` rows of {RbsCoverageReport},
41
+ # with the gem's `LockedGem#variants`); `variants` may be omitted or nil, meaning the `ruby` one.
42
42
  # @param bundle_path — the target's resolved bundler install root, or nil.
43
43
  # @param spec_resolver — `(name, version) -> String?` fallback dir resolver. Injectable for
44
44
  # specs; the default is the RubyGems-metadata lookup (no code load).
@@ -49,8 +49,9 @@ module Rigor
49
49
  bundle_dirs = bundle_gem_dirs(bundle_path)
50
50
  git_dirs = git_gem_dirs(bundle_path)
51
51
  index = {}
52
- gems.each do |gem_name, version|
53
- dir = bundle_dirs["#{gem_name}-#{version}"] || git_dirs[gem_name] || spec_resolver.call(gem_name, version)
52
+ gems.each do |gem_name, version, variants|
53
+ dir = bundle_dir(bundle_dirs, gem_name, version, variants) ||
54
+ git_dirs[gem_name] || spec_resolver.call(gem_name, version)
54
55
  next unless dir
55
56
 
56
57
  entry_files(dir, gem_name).each do |file|
@@ -62,9 +63,8 @@ module Rigor
62
63
 
63
64
  # `{"<name>-<version>" => gem_dir}` for the target's bundle, or `{}` when no bundle is resolvable. The
64
65
  # glob covers the RUBYGEMS-sourced layout only, `<bundle>/ruby/X.Y.Z/gems/<name>-<version>/`; the
65
- # git-sourced layout is {.git_gem_dirs}'s. Keyed on the dir basename so a platform-tagged variant
66
- # (`ffi-1.17.4-aarch64-linux-gnu`) simply doesn't match a `<name>-<version>` lookup — those gems ship
67
- # native code, not the pure-Ruby constants this indexes.
66
+ # git-sourced layout is {.git_gem_dirs}'s. Keyed on the dir basename, which {.bundle_dir} reconstructs
67
+ # per locked variant.
68
68
  def bundle_gem_dirs(bundle_path)
69
69
  return {} if bundle_path.nil?
70
70
 
@@ -76,6 +76,21 @@ module Rigor
76
76
  end
77
77
  end
78
78
 
79
+ # The installed directory of one of the gem's locked variants: `<name>-<version>` for the `ruby` one,
80
+ # `<name>-<version>-<platform>` for a platform-tagged one. Bundler installs only the variant matching
81
+ # the host, and a multi-platform lockfile's representative is usually the `ruby` one, so every variant
82
+ # is tried. A platform-tagged gem is not a native-only gem: the commonmarker, nokogiri and sqlite3 builds
83
+ # each ship `lib/<name>.rb` declaring the gem's top-level namespace, exactly as the `ruby` build does.
84
+ def bundle_dir(bundle_dirs, gem_name, version, variants)
85
+ [[version, "ruby"], *variants].each do |variant_version, platform|
86
+ basename = "#{gem_name}-#{variant_version}"
87
+ basename = "#{basename}-#{platform}" unless platform == "ruby"
88
+ dir = bundle_dirs[basename]
89
+ return dir if dir
90
+ end
91
+ nil
92
+ end
93
+
79
94
  # Issue #763 — `{gem_name => gem_dir}` for the target's GIT-sourced gems,
80
95
  # `<bundle>/ruby/X.Y.Z/bundler/gems/<repo>-<12-hex-revision>/`. Before this, a `git:`-sourced gem was
81
96
  # invisible to the index, so every constant reaching into it recorded the generic engine-gap cause
@@ -66,6 +66,15 @@ module Rigor
66
66
  QUARANTINE_WARN_LIMIT = 10
67
67
 
68
68
  class << self
69
+ # Issue #1902 — the fixed total order that picks ONE detail tuple for a class out of several: its two
70
+ # sides in {#report_definition_build_failure}, or a fresh and a replayed record in
71
+ # {Analysis::Runner::PoolCoordinator}. Content-only (the tuple carries no side), so both places choose
72
+ # the same tuple from the same candidates, whatever order those candidates arrived in.
73
+ def definition_build_detail_order(detail)
74
+ _, error_class, member, buffers, unresolved = detail
75
+ [error_class.to_s, member.to_s, Array(buffers).map(&:to_s), Array(unresolved).map(&:to_s)]
76
+ end
77
+
69
78
  def default
70
79
  @default ||= new.freeze
71
80
  end
@@ -2321,6 +2330,39 @@ module Rigor
2321
2330
  [].freeze
2322
2331
  end
2323
2332
 
2333
+ # Issue #1752 — the lookup of `class_name`'s class object as RBS declares it, the singleton twin of
2334
+ # {#ancestor_names_for}: `[:singleton, name]` for each singleton class on it (`C`'s, then each superclass's)
2335
+ # and `[:instance, name]` for each module or class whose instance methods it reaches (the declared `extend`s,
2336
+ # then `Class`, `Module`, `Object`, `Kernel` and `BasicObject` for a class, `Module` onwards for a module), in
2337
+ # lookup order. A refinement of `Object` or `Module` answers a call on a class object through the second kind.
2338
+ #
2339
+ # Marked as an internal demand, as {#ancestor_names_for} is (issue #696). Memoised per name: the environment is
2340
+ # immutable for the loader's lifetime.
2341
+ #
2342
+ # @return `::`-stripped pairs, or `[]` for an unknown class and on any RBS build error.
2343
+ def singleton_ancestors_for(class_name)
2344
+ key = class_name.to_s.delete_prefix("::")
2345
+ memo = (@state[:singleton_ancestors] ||= {})
2346
+ return memo[key] if memo.key?(key)
2347
+
2348
+ memo[key] = build_singleton_ancestors(key)
2349
+ end
2350
+
2351
+ def build_singleton_ancestors(key)
2352
+ during_internal_demand do
2353
+ definition = singleton_definition(key)
2354
+ next [].freeze if definition.nil?
2355
+
2356
+ definition.ancestors.ancestors.map do |ancestor|
2357
+ kind = ancestor.is_a?(::RBS::Definition::Ancestor::Singleton) ? :singleton : :instance
2358
+ [kind, ancestor.name.to_s.delete_prefix("::")].freeze
2359
+ end.uniq.freeze
2360
+ end
2361
+ rescue ::RBS::BaseError, StandardError
2362
+ [].freeze
2363
+ end
2364
+ private :build_singleton_ancestors
2365
+
2324
2366
  # @return the class method on `class_name`. For example,
2325
2367
  # `singleton_method(class_name: "Integer", method_name: :sqrt)` returns the definition for
2326
2368
  # `Integer.sqrt`, while `singleton_method(class_name: "Foo", method_name: :new)` returns Class#new
@@ -2438,6 +2480,18 @@ module Rigor
2438
2480
  # fail-soft: a broken RBS environment yields no entries.
2439
2481
  end
2440
2482
 
2483
+ # ADR-113 — the RBS declaration of the constant `name` (qualified) as `[type, buffer name]`, or nil when no
2484
+ # signature declares it. `rigor lens` reports the declared type with the provenance the buffer names.
2485
+ def constant_declaration(name)
2486
+ rbs_name = parse_type_name(name)
2487
+ entry = rbs_name && env&.constant_decls&.[](rbs_name)
2488
+ return nil unless entry
2489
+
2490
+ [entry.decl.type.to_s, entry.decl.location&.buffer&.name.to_s]
2491
+ rescue ::RBS::BaseError
2492
+ nil
2493
+ end
2494
+
2441
2495
  # Slice A constant-value lookup. Returns the translated `Rigor::Type` for a non-class constant
2442
2496
  # declaration (`BUCKETS: Array[Symbol]`, `DEFAULT_PATH: String`, ...) or `nil` when no constant entry
2443
2497
  # exists for `name` in the loaded RBS environment. Callers MUST treat the return value as authoritative
@@ -2871,15 +2925,16 @@ module Rigor
2871
2925
  #
2872
2926
  # So the rescue only remembers, keyed by the `::`-stripped name (the eager walk spells it `::Acme`,
2873
2927
  # a dispatch site spells it `Acme`, and they are one class — the {#synthesized_namespaces}
2874
- # convention), and {#report_definition_build_failure} decides. First writer wins: the instance and
2875
- # singleton sides fail for the same underlying collision, and the first is the one a caller hit.
2928
+ # convention), and {#report_definition_build_failure} decides. Every side's detail is kept, not the
2929
+ # first one written (issue #1902): the instance and singleton sides can fail for DIFFERENT reasons
2930
+ # (a duplicated `def self.s` and a duplicated `def i` on one class), and which side a run demands first
2931
+ # depends on which files it analysed, so first-writer-wins made the reported tuple — and with it the
2932
+ # message's "First failure", advice and file list — differ between a cold run and a warm recheck.
2876
2933
  def store_definition_build_detail(class_name, error)
2877
2934
  key = class_name.to_s.delete_prefix("::")
2878
- details = (@state[:definition_build_details] ||= {})
2879
- return if details.key?(key)
2880
-
2881
- details[key] = [key, error.class.name.to_s, definition_build_member(error),
2882
- definition_build_conflict_buffers(error), unresolved_type_name_detail(error)].freeze
2935
+ details = ((@state[:definition_build_details] ||= {})[key] ||= [])
2936
+ details << [key, error.class.name.to_s, definition_build_member(error),
2937
+ definition_build_conflict_buffers(error), unresolved_type_name_detail(error)].freeze
2883
2938
  end
2884
2939
 
2885
2940
  # Issue #997 — the `RBS::NoTypeFoundError` twin of {#definition_build_member}: `nil` for every other
@@ -2954,14 +3009,28 @@ module Rigor
2954
3009
  return if details.nil?
2955
3010
 
2956
3011
  key = class_name.to_s.delete_prefix("::")
2957
- detail = details[key]
2958
- return if detail.nil?
3012
+ return if details[key].nil?
2959
3013
 
2960
3014
  reported = (@state[:definition_build_reported] ||= {})
2961
3015
  return if reported[key]
2962
3016
 
2963
3017
  reported[key] = true
2964
- (@state[:definition_build_failures] ||= []) << detail
3018
+ attempt_both_definition_sides(class_name)
3019
+ canonical = details[key].min_by { |candidate| self.class.definition_build_detail_order(candidate) }
3020
+ (@state[:definition_build_failures] ||= []) << canonical
3021
+ end
3022
+
3023
+ # Issue #1902 — a reported class's tuple is chosen from BOTH sides' failures, so the side this demand
3024
+ # did not ask for is built here too. Without it, the candidate set would be whatever sides the run
3025
+ # happened to demand, and a recheck whose closure touches only `Berry.s` would report the singleton
3026
+ # side's reason where the full run, which also demanded `Berry.new.i`, reported the instance side's.
3027
+ # An internal demand: it decides WHICH reason is shown for a class the analysis already demanded, never
3028
+ # WHETHER a class is reported, so the set #696 binds to the analysis's own demand is unchanged.
3029
+ def attempt_both_definition_sides(class_name)
3030
+ during_internal_demand do
3031
+ instance_definition(class_name)
3032
+ singleton_definition(class_name)
3033
+ end
2965
3034
  end
2966
3035
 
2967
3036
  # Marks a definition demand that is RIGOR'S OWN, not the analysed program's, so
@@ -51,7 +51,8 @@ module Rigor
51
51
  #
52
52
  # @param sources — path => source text, read in place of that path's file (an editor buffer).
53
53
  def for_configuration(configuration, extra_files = [], sources: {})
54
- files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns)
54
+ files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
55
+ base: configuration.exclude_base)
55
56
  scan(files + Array(extra_files).map(&:to_s), sources: sources)
56
57
  rescue StandardError
57
58
  []
@@ -77,7 +78,8 @@ module Rigor
77
78
  # @return path (absolute) => the features that file requires, for every file under the configured paths
78
79
  # that requires any. A long-lived reader (the language server) keeps this and overlays its open buffers.
79
80
  def feature_files(configuration)
80
- listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns)
81
+ listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
82
+ base: configuration.exclude_base)
81
83
  files = listed.map { |path| File.expand_path(path.to_s) }
82
84
  result = files.each_with_object({}) do |path, acc|
83
85
  features = memoized_features_in(path)
@@ -97,7 +97,7 @@ module Rigor
97
97
  # for the overlay, instead of aborting the run outright (see {#hkt_registry}).
98
98
  @hkt_scan_failure = FailureSlot.new
99
99
  @constant_type_cache = ConstantTypeCacheHolder.new
100
- # ADR-82 WD9 — `[gem_name, version]` pairs for the locked gems with no resolvable RBS. The
100
+ # ADR-82 WD9 — `[gem_name, version, variants]` entries for the locked gems with no resolvable RBS. The
101
101
  # root-constant ownership index over them is built lazily (first unresolved constant read) so runs
102
102
  # whose constants all resolve never pay the entry-file scan.
103
103
  @missing_rbs_gems = missing_rbs_gems.freeze
@@ -444,8 +444,8 @@ module Rigor
444
444
  end
445
445
 
446
446
  # The `:missing` classification (locked gems with no RBS through any resolution path), computed once
447
- # and consumed twice: as `[gem_name, version]` pairs for the ADR-82 WD9 constant-ownership index, and
448
- # as the eligible-gem set for the ADR-72 overlay resolution. Returns `[pairs, overlay_paths]`.
447
+ # and consumed twice: as `[gem_name, version, variants]` entries for the ADR-82 WD9 constant-ownership
448
+ # index, and as the eligible-gem set for the ADR-72 overlay resolution. Returns `[entries, overlay_paths]`.
449
449
  def missing_gems_and_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
450
450
  rbs_collection_paths:, plugin_registry:, signature_paths: [],
451
451
  bundle_root: nil)
@@ -458,7 +458,7 @@ module Rigor
458
458
  missing_gem_names: rows.map(&:gem_name), plugin_registry: plugin_registry,
459
459
  signature_paths: signature_paths
460
460
  )
461
- [rows.map { |row| [row.gem_name, row.version] }, overlays]
461
+ [index_entries(rows, locked), overlays]
462
462
  end
463
463
 
464
464
  # Issue #530 — a project with no `Gemfile.lock` still has gems on disk, and before this it handed the
@@ -472,7 +472,14 @@ module Rigor
472
472
  return [[], []] if installed.empty?
473
473
 
474
474
  rows = missing_rows(installed, default_libraries, bundle_sig_paths, rbs_collection_paths)
475
- [rows.map { |row| [row.gem_name, row.version] }, []]
475
+ [index_entries(rows, installed), []]
476
+ end
477
+
478
+ # The constant-ownership index's input: each `:missing` row with every `[version, platform]` variant its
479
+ # gem was locked (or found installed) under, because Bundler installs the variant matching the host —
480
+ # `commonmarker-2.10.0-arm64-darwin/` under a lockfile whose representative is the `ruby` one (#1844).
481
+ def index_entries(rows, gems)
482
+ rows.map { |row| [row.gem_name, row.version, gems[row.gem_name]&.variants] }
476
483
  end
477
484
 
478
485
  def missing_rows(gems, default_libraries, bundle_sig_paths, rbs_collection_paths)
@@ -165,7 +165,12 @@ module Rigor
165
165
  # nothing for names this rare, and the block-return pass is no independent check here: it types a
166
166
  # self-call to an overridden `raise` as Kernel's `bot` too, so a missed override is a wrong `bot`.
167
167
  # {LastLine.reads_line?} declines on it the same way for `gets` and `readline`.
168
+ #
169
+ # Issue #1459 — the answer reads every project file's tables whole, so it records a `defines:<name>` edge
170
+ # whichever way it answers: an edit that makes a file start or stop defining the name re-checks the reader
171
+ # (`Incremental.changed_defined_names`). A `pre_eval:` patch moves the snapshot's own fingerprint.
168
172
  def project_defines_anywhere?(method_name, scope)
173
+ Analysis::DependencyRecorder.read_missing(:defines, method_name) if Analysis::DependencyRecorder.active?
169
174
  return true if scope.top_level_def_for(method_name)
170
175
  # `discovered_methods` withholds a plain cross-file `def` (the ADR-17 monkey-patch contract); the
171
176
  # def-node tables still carry it, on both sides.
@@ -16,6 +16,9 @@ module Rigor
16
16
  # (`Integer#quo` and `File#to_path`, whose CRuby owners are `Numeric` and `IO`), and declares a few CRuby no
17
17
  # longer defines (`Process::Status#&`). A class no catalogue covers answers false, as does a method its
18
18
  # catalogue does not list: the caller reads both as "not proven". The catalogue is one CRuby snapshot.
19
+ #
20
+ # Issue #1752 — the singleton side reads the catalogue's `singleton_methods` (each `rb_define_singleton_method`
21
+ # on the class), the record that `Integer.sqrt` is CRuby's own method of `Integer`'s singleton class.
19
22
  module CRubyDefiners
20
23
  module_function
21
24
 
@@ -25,7 +28,15 @@ module Rigor
25
28
  !names.nil? && names.include?(method_name.to_s)
26
29
  end
27
30
 
28
- def build_index
31
+ # Does the catalogue prove CRuby defines `method_name` on `class_name`'s singleton class itself?
32
+ def singleton_defines?(class_name, method_name)
33
+ names = SINGLETON_INDEX[class_name.to_s.delete_prefix("::")]
34
+ !names.nil? && names.include?(method_name.to_s)
35
+ end
36
+
37
+ # `{class name => Set[method name]}` from each catalogued class's instance methods and aliases, or from its
38
+ # `singleton_methods` when `side` is `:singleton`.
39
+ def build_index(side = :instance)
29
40
  index = Hash.new { |hash, key| hash[key] = Set.new }
30
41
  MethodCatalog.topic_paths.each do |path|
31
42
  data = YAML.safe_load_file(path, permitted_classes: [Symbol])
@@ -35,9 +46,7 @@ module Rigor
35
46
  classes.each do |name, klass|
36
47
  next if klass["rdoc_only"]
37
48
 
38
- names = index[name]
39
- names.merge(defined_names(klass["instance_methods"]))
40
- names.merge(defined_names(klass["aliases"]))
49
+ merge_names(index[name], klass, side)
41
50
  end
42
51
  end
43
52
  Ractor.make_shareable(index.to_h { |name, names| [name, names.freeze] })
@@ -45,6 +54,13 @@ module Rigor
45
54
  Ractor.make_shareable({})
46
55
  end
47
56
 
57
+ def merge_names(names, klass, side)
58
+ return names.merge(defined_names(klass["singleton_methods"])) if side == :singleton
59
+
60
+ names.merge(defined_names(klass["instance_methods"]))
61
+ names.merge(defined_names(klass["aliases"]))
62
+ end
63
+
48
64
  # The names of `entries` CRuby defines at runtime: one the catalogue marks `rdoc_only` was registered only for
49
65
  # RDoc, inside an `#if 0` block.
50
66
  def defined_names(entries)
@@ -54,7 +70,8 @@ module Rigor
54
70
  end
55
71
 
56
72
  INDEX = build_index
57
- private_constant :INDEX
73
+ SINGLETON_INDEX = build_index(:singleton)
74
+ private_constant :INDEX, :SINGLETON_INDEX
58
75
  end
59
76
  end
60
77
  end
@@ -9,6 +9,7 @@ require_relative "index_write_widening"
9
9
  require_relative "mutation_widening"
10
10
  require_relative "optimistic_origin"
11
11
  require_relative "receiver_alias"
12
+ require_relative "reflective_ivar_write"
12
13
  require_relative "unknown_store_widening"
13
14
 
14
15
  module Rigor
@@ -129,6 +130,9 @@ module Rigor
129
130
  if LOCAL_WRITE_NODES.include?(descendant.class)
130
131
  captured_local_write(descendant, ancestors, base_scope) { introduced ||= introduced_locals(block_node) }
131
132
  elsif outliving
133
+ if ReflectiveIvarWrite.unknown_target?(descendant)
134
+ (names ||= []).concat(reflective_unknown_ivars(base_scope))
135
+ end
132
136
  rebindable_outliving_write(descendant, base_scope, non_locals)
133
137
  end
134
138
  (names ||= []) << name if name
@@ -136,6 +140,13 @@ module Rigor
136
140
  names ? names.uniq : NO_NAMES
137
141
  end
138
142
 
143
+ # Issue #1889 — every ivar `base_scope` binds: an `instance_variable_set` on `self` whose target no literal
144
+ # names ({ReflectiveIvarWrite.unknown_target?}) may write any of them. One on its ADR-58 declaration seed
145
+ # counts too, since the census behind the seed sees no reflective write; {.bind} keeps its mark.
146
+ def reflective_unknown_ivars(base_scope)
147
+ base_scope.ivars.keys
148
+ end
149
+
139
150
  def rebindable_outliving_write(node, base_scope, non_locals)
140
151
  name = outliving_write_name(node, non_locals)
141
152
  name if name && rebindable_non_local?(base_scope, name)
@@ -151,8 +162,12 @@ module Rigor
151
162
  yield.include?(name) ? nil : name
152
163
  end
153
164
 
154
- # The non-local `node` rebinds under `non_locals:` — or, without it, the instance variable only.
165
+ # The non-local `node` rebinds under `non_locals:` — or, without it, the instance variable only. A literal-name
166
+ # `instance_variable_set` on `self` ({ReflectiveIvarWrite.literal_name}) is a write of that ivar under either.
155
167
  def outliving_write_name(node, non_locals)
168
+ reflective = ReflectiveIvarWrite.literal_name(node)
169
+ return reflective if reflective
170
+
156
171
  name = non_local_write_name(node)
157
172
  non_locals || (name && ivar_name?(name) && NON_LOCAL_WRITE_NODES.include?(node.class)) ? name : nil
158
173
  end
@@ -201,13 +216,21 @@ module Rigor
201
216
  # it the miss answer the mark records (issue #1302).
202
217
  def bind(scope, name, type, optimistic: nil)
203
218
  case variable_kind(name)
204
- when :ivar then OptimisticOrigin.carry_ivar_mark(scope, scope.with_ivar(name, type), name, optimistic)
219
+ when :ivar then bind_ivar(scope, name, type, optimistic)
205
220
  when :cvar then scope.with_cvar(name, type)
206
221
  when :global then scope.with_global(name, type)
207
222
  else OptimisticOrigin.carry_local_mark(scope, scope.with_local(name, type), name, optimistic)
208
223
  end
209
224
  end
210
225
 
226
+ # An ivar on its ADR-58 declaration seed reaches {.bind} through {.reflective_unknown_ivars}, and through
227
+ # `ExpressionTyper#prefix_answered_scope`; rebinding it re-stamps the declaration mark, as
228
+ # `ReflectiveIvarWrite.apply` does, so a nil in the seed fuels no nil-receiver diagnostic.
229
+ def bind_ivar(scope, name, type, optimistic)
230
+ bound = OptimisticOrigin.carry_ivar_mark(scope, scope.with_ivar(name, type), name, optimistic)
231
+ scope.declaration_sourced?(:ivar, name) ? bound.seed_declaration_sourced_ivar(name, type) : bound
232
+ end
233
+
211
234
  # The optimistic nil-freeness mark `scope` holds for a name from {.writes}, or nil.
212
235
  def optimistic_mark(scope, name)
213
236
  case variable_kind(name)
@@ -290,7 +313,7 @@ module Rigor
290
313
  receiver = node.receiver
291
314
  return true if receiver && MutationWidening::SHAPE_MUTATORS.include?(node.name)
292
315
 
293
- callee_call?(node)
316
+ ReflectiveIvarWrite.self_write?(node) || callee_call?(node)
294
317
  end
295
318
 
296
319
  # Issue #1412 — {.content_mutations} for a `while` / `until` / `for` body: the locals bound in `base_scope`
@@ -310,23 +333,63 @@ module Rigor
310
333
 
311
334
  # The mutation sites under `body` whose reads name a variable {.captured_target?} accepts, keyed by name.
312
335
  # The block yields the names the enclosing construct introduces, asked only when a local site is found.
336
+ #
337
+ # A site whose receiver is a body local that aliases a capture (`l2 = log; l2 << e`) mutates that capture's
338
+ # object, so it is attributed to the capture as well ({.aliased_captures}): left out, the next iteration
339
+ # read `log` at its entry `[]` and `[1, 2].inject(0) { |a, e| r = a + log.size; l2 = log; l2 << e; r }`
340
+ # folded to `0` where Ruby answers `1` (issue #1332).
313
341
  def body_mutation_sites(body, base_scope, non_locals, &)
314
342
  sites = nil
315
343
  evaluator = nil
344
+ aliases = nil
316
345
  Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
317
346
  # A callee is resolved in the call-site scope, as the straight-line callee floor resolves it.
318
347
  site = mutation_site(descendant) { evaluator ||= StatementEvaluator.new(scope: base_scope) }
319
348
  next if site.nil?
320
349
 
321
350
  site_reads(site).each do |read|
322
- next unless captured_target?(read, ancestors, base_scope, non_locals, &)
323
-
324
- ((sites ||= {})[read.name] ||= []) << site
351
+ targets = captured_target?(read, ancestors, base_scope, non_locals, &) ? [read] : []
352
+ if read.is_a?(Prism::LocalVariableReadNode)
353
+ aliases ||= local_aliases(body)
354
+ targets.concat(aliased_captures(read.name, aliases, base_scope, non_locals, Set.new, &))
355
+ end
356
+ targets.each { |target| ((sites ||= {})[target.name] ||= []) << site }
325
357
  end
326
358
  end
327
359
  sites || NO_SITES
328
360
  end
329
361
 
362
+ # `{ name => [[read, ancestors], ...] }`: every variable read a local write under `body` may store
363
+ # ({ReceiverAlias.candidates} of its value), with the ancestors {.captured_target?} resolves it against.
364
+ def local_aliases(body)
365
+ aliases = {}
366
+ Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
367
+ next unless node.is_a?(Prism::LocalVariableWriteNode) || node.is_a?(Prism::LocalVariableOrWriteNode)
368
+
369
+ ReceiverAlias.candidates(node.value).each do |read|
370
+ (aliases[node.name] ||= []) << [read, ancestors + [node]]
371
+ end
372
+ end
373
+ aliases
374
+ end
375
+
376
+ # The captured reads the local `name` may alias, followed through local-to-local copies (`l3 = l2 = log`).
377
+ # It over-approximates — a write in any branch, or in a nested scope reusing the name, counts — which only
378
+ # widens a capture's binding.
379
+ def aliased_captures(name, aliases, base_scope, non_locals, visited, &)
380
+ return [] unless visited.add?(name)
381
+
382
+ aliases.fetch(name, NO_NAMES).flat_map do |read, ancestors|
383
+ if captured_target?(read, ancestors, base_scope, non_locals, &)
384
+ [read]
385
+ elsif read.is_a?(Prism::LocalVariableReadNode)
386
+ aliased_captures(read.name, aliases, base_scope, non_locals, visited, &)
387
+ else
388
+ NO_NAMES
389
+ end
390
+ end
391
+ end
392
+
330
393
  # The mutation site `node` is — the node itself, or a {UnknownStoreWidening::CalleeStore} for a self-call
331
394
  # whose callee content-mutates a local it is passed — or nil. A self-call is resolved by the
332
395
  # `StatementEvaluator` the block yields, which the caller builds only when one is needed. A mutator name