rigortype 0.4.4 → 0.4.5

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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. metadata +13 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rooted_glob"
4
+
3
5
  module Rigor
4
6
  module Effects
5
7
  # Everything an effect envelope may be written in, as `[buffer name, RBS source]` pairs
@@ -55,7 +57,7 @@ module Rigor
55
57
  # @param signature_paths — the configured roots; empty / nil takes the default.
56
58
  # @param virtual_rbs — the loader's virtual entries.
57
59
  def collect(signature_paths:, virtual_rbs: nil)
58
- files = roots(signature_paths).flat_map { |root| Dir.glob(File.join(root.to_s, "**", "*.rbs")).sort }
60
+ files = roots(signature_paths).flat_map { |root| RootedGlob.glob(root, "**/*.rbs").sort }
59
61
  sources = files.filter_map do |path|
60
62
  [path, File.read(path)]
61
63
  rescue StandardError
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  require_relative "../config_path"
6
+ require_relative "../rooted_glob"
6
7
 
7
8
  module Rigor
8
9
  class Environment
@@ -91,8 +92,8 @@ module Rigor
91
92
  # all (`Bundler::Source::Path#path` is the user's own `path:` directory, used in place), so there is
92
93
  # no bundle-relative location to walk. A `path:`-sourced gem's `sig/` is only reachable by adding it
93
94
  # directly to the project's `signature_paths:` — which already works today and needs no discovery.
94
- all = (Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")) +
95
- Dir.glob(resolved.join("ruby", "*", "bundler", "gems", "*", "sig"))).map { |d| Pathname.new(d) }
95
+ all = (RootedGlob.glob(resolved, "ruby/*/gems/*/sig") +
96
+ RootedGlob.glob(resolved, "ruby/*/bundler/gems/*/sig")).map { |d| Pathname.new(d) }
96
97
  filtered = all.reject { |sig_dir| skip_gems.include?(gem_name_from_sig_path(sig_dir)) }
97
98
  return filtered if locked_gems.nil? || locked_gems.empty?
98
99
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbs"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -15,7 +16,7 @@ module Rigor
15
16
  # @param dir — the vendored directory (Pathname).
16
17
  # @return the absolute-less names (`"Prime::PseudoPrimeGenerator"`) of every class and module it declares.
17
18
  def type_names(dir)
18
- Dir.glob(File.join(dir.to_s, "*.rbs")).flat_map do |file|
19
+ RootedGlob.glob(dir, "*.rbs").flat_map do |file|
19
20
  _, _, decls = RBS::Parser.parse_signature(File.read(file))
20
21
  names(decls, nil)
21
22
  rescue RBS::BaseError, SystemCallError
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbs"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -132,7 +133,7 @@ module Rigor
132
133
  end
133
134
 
134
135
  def signature_files(roots)
135
- roots.flat_map { |root| Dir.glob(File.join(root.to_s, "*.rbs")).sort }
136
+ roots.flat_map { |root| RootedGlob.glob(root, "*.rbs").sort }
136
137
  end
137
138
 
138
139
  def shell_buffer
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "lockfile_resolver"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -47,7 +48,7 @@ module Rigor
47
48
  base = Pathname.new(bundle_path)
48
49
  return {} unless base.directory?
49
50
 
50
- Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
51
+ RootedGlob.glob(base, "ruby/*/gems/*").each_with_object({}) do |dir, acc|
51
52
  name, version, platform = split_install_dirname(File.basename(dir))
52
53
  next unless name
53
54
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -71,7 +72,7 @@ module Rigor
71
72
  base = Pathname.new(bundle_path)
72
73
  return {} unless base.directory?
73
74
 
74
- Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
75
+ RootedGlob.glob(base, "ruby/*/gems/*").each_with_object({}) do |dir, acc|
75
76
  acc[File.basename(dir)] ||= dir
76
77
  end
77
78
  end
@@ -114,7 +115,7 @@ module Rigor
114
115
  base = Pathname.new(bundle_path)
115
116
  return {} unless base.directory?
116
117
 
117
- Dir.glob(base.join("ruby", "*", "bundler", "gems", "*")).each_with_object({}) do |dir, acc|
118
+ RootedGlob.glob(base, "ruby/*/bundler/gems/*").each_with_object({}) do |dir, acc|
118
119
  name = gemspec_gem_name(dir)
119
120
  acc[name] ||= dir if name
120
121
  end
@@ -124,7 +125,7 @@ module Rigor
124
125
  # exactly one. Bundler requires a git-sourced gem to ship one, so "not exactly one" means this is not
125
126
  # the shape we think it is — and a guess from the directory basename is what this exists to avoid.
126
127
  def gemspec_gem_name(dir)
127
- specs = Dir.glob(File.join(dir, "*.gemspec"))
128
+ specs = RootedGlob.glob(dir, "*.gemspec")
128
129
  return nil unless specs.size == 1
129
130
 
130
131
  File.basename(specs.first, ".gemspec")
@@ -10,6 +10,7 @@ require_relative "member_consistency"
10
10
  require_relative "required_features"
11
11
  require_relative "gated_signature_guard"
12
12
  require_relative "gated_signature_plan"
13
+ require_relative "../rooted_glob"
13
14
 
14
15
  module Rigor
15
16
  class Environment
@@ -549,7 +550,7 @@ module Rigor
549
550
  path = Pathname(path) unless path.is_a?(Pathname)
550
551
  next [] unless path.directory?
551
552
 
552
- Dir.glob(path.join("**", "*.rbs")).map { |p| File.expand_path(p) }
553
+ RootedGlob.glob(path, "**/*.rbs").map { |p| File.expand_path(p) }
553
554
  end.to_set
554
555
  end
555
556
 
@@ -1916,6 +1917,25 @@ module Rigor
1916
1917
  (@state[:definition_build_failures] || []).dup.freeze
1917
1918
  end
1918
1919
 
1920
+ # Issue #1929 — re-ask for `class_name`'s definition on behalf of a PREVIOUS run's analysis, which
1921
+ # demanded it and found it failing. An `--incremental` recheck replays that run's failures, and one
1922
+ # whose conflicting buffer is a file the recheck re-synthesized cannot be replayed as it stands (the
1923
+ # edit may have fixed it), while the file that demanded the class may sit outside the recheck's
1924
+ # closure and never ask again. Re-asking here re-derives the answer over the current signatures: a
1925
+ # class that still fails is reported with its current tuple, one that now builds is not.
1926
+ #
1927
+ # A real demand, not {#during_internal_demand}: it stands in for the analysis's own, which is what
1928
+ # #696 binds the reported set to. Both sides are asked, since the replayed tuple does not record which
1929
+ # side the analysis demanded ({#report_definition_build_failure} builds both anyway).
1930
+ #
1931
+ # @return the class's current tuple, or nil when it now builds.
1932
+ def redemand_definition(class_name)
1933
+ key = class_name.to_s.delete_prefix("::")
1934
+ instance_definition(key)
1935
+ singleton_definition(key)
1936
+ definition_build_failures.find { |failure| failure.first == key }
1937
+ end
1938
+
1919
1939
  # Virtual (inline-synthesized) contributions dropped by the collision quarantine
1920
1940
  # ({.resolve_quarantining_virtual_collisions}): buffer names absent from the built env even though the
1921
1941
  # entry's content is non-empty and parses (a parse failure is the synthesizer's own WD6 skip, reported
@@ -72,6 +72,12 @@ module Rigor
72
72
 
73
73
  module_function
74
74
 
75
+ # Whether `call_node` is spelled to reach the private `Kernel#loop` (see {.loop_may_complete?}), which runs its
76
+ # block until it breaks or raises.
77
+ def kernel_loop?(call_node)
78
+ call_node.name == :loop && kernel_spelled_receiver?(call_node.receiver)
79
+ end
80
+
75
81
  # Issue #1107 — whether a `loop` call can complete normally although its declared return is `bot`.
76
82
  #
77
83
  # `Kernel#loop` is `() { () -> void } -> bot` in core RBS, yet it rescues a `StopIteration` its block
@@ -86,7 +92,7 @@ module Rigor
86
92
  # promise. A project redefinition of `loop` itself is not excluded: widening its `bot` costs precision,
87
93
  # never a false positive.
88
94
  def loop_may_complete?(call_node)
89
- return false unless call_node.name == :loop && kernel_spelled_receiver?(call_node.receiver)
95
+ return false unless kernel_loop?(call_node)
90
96
 
91
97
  block = call_node.block
92
98
  return false if block.nil?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require_relative "../../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -49,7 +50,7 @@ module Rigor
49
50
  end
50
51
 
51
52
  # Every catalogue file under {DATA_ROOT}, in a stable order.
52
- def self.topic_paths = Dir[File.join(DATA_ROOT, "*.yml")]
53
+ def self.topic_paths = RootedGlob.glob(DATA_ROOT, "*.yml")
53
54
 
54
55
  def initialize(path:, mutating_selectors: {})
55
56
  @path = path
@@ -6,6 +6,7 @@ require_relative "../source/node_walker"
6
6
  require_relative "block_parameter_binder"
7
7
  require_relative "element_read_widening"
8
8
  require_relative "index_write_widening"
9
+ require_relative "intervening_ivar_reset"
9
10
  require_relative "mutation_widening"
10
11
  require_relative "optimistic_origin"
11
12
  require_relative "receiver_alias"
@@ -109,16 +110,22 @@ module Rigor
109
110
  # `base_scope`), except that one still on its class-wide binding does not: ADR-58's declaration seed is
110
111
  # the union of every write in the class, this body's included, so nothing the body stores can move it,
111
112
  # and rebinding it would only drop the declaration mark that keeps its nil from being diagnostic fuel.
112
- # A nested block that rebinds `self` (`o.instance_eval`) writes another object's ivar, and a nested
113
- # `def` runs only when called; both still count, because an `instance_eval` without a receiver, or a
114
- # call to that `def` inside the body, does write this one.
113
+ # Issue #1402 — an instance variable written in a nested block that runs on an object provably not `self`
114
+ # (`Other.class_eval { @x = 1 }`, {InterveningIvarReset.foreign_self_block?}, its receiver typed in
115
+ # `base_scope`) is that object's, and does not count; one on a receiver that may be `self` does. A class
116
+ # variable or global written there counts as anywhere else: Ruby resolves a class variable lexically, and
117
+ # a global is the same one whatever `self` is. A nested `def` runs only when called; it still counts,
118
+ # because a call to it inside the body writes this one.
119
+ # @param foreign_self — the block itself runs on an object provably not `self`
120
+ # ({InterveningIvarReset.foreign_self_block?} of its call): no instance variable it writes is the caller's.
121
+ # Its locals, class variables and globals still count.
115
122
  # @param non_locals — the per-element fold's superset of `ivars`: the class variables and globals the body
116
123
  # rebinds count too, on the same terms, and so does an attribute setter called on `self` (`self.w = v`)
117
124
  # as a rebind of the instance variable it is named after, `@w`: the `attr_writer` / `attr_accessor`
118
125
  # convention stores there, and no write node shows it. A hand-written setter storing elsewhere is not
119
126
  # seen.
120
127
  # @return the captured names the body writes, each once, in first-write order.
121
- def writes(block_node, base_scope, ivars: false, non_locals: false)
128
+ def writes(block_node, base_scope, ivars: false, non_locals: false, foreign_self: false)
122
129
  body = block_node.body
123
130
  return NO_NAMES if body.nil?
124
131
 
@@ -126,20 +133,44 @@ module Rigor
126
133
  names = nil
127
134
  outliving = non_locals || ivars
128
135
  Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
129
- name =
130
- if LOCAL_WRITE_NODES.include?(descendant.class)
131
- captured_local_write(descendant, ancestors, base_scope) { introduced ||= introduced_locals(block_node) }
132
- elsif outliving
133
- if ReflectiveIvarWrite.unknown_target?(descendant)
134
- (names ||= []).concat(reflective_unknown_ivars(base_scope))
135
- end
136
- rebindable_outliving_write(descendant, base_scope, non_locals)
136
+ if LOCAL_WRITE_NODES.include?(descendant.class)
137
+ name = captured_local_write(descendant, ancestors, base_scope) do
138
+ introduced ||= introduced_locals(block_node)
137
139
  end
138
- (names ||= []) << name if name
140
+ (names ||= []) << name if name
141
+ elsif outliving
142
+ found = outliving_writes(descendant, ancestors, base_scope, non_locals, foreign_self)
143
+ (names ||= []).concat(found) unless found.empty?
144
+ end
139
145
  end
140
146
  names ? names.uniq : NO_NAMES
141
147
  end
142
148
 
149
+ # The non-local names `node` writes ({.reflective_unknown_ivars} for an `instance_variable_set` on `self` whose
150
+ # target no literal names, then {.rebindable_outliving_write}), less the instance variables when the write
151
+ # runs on an object provably not `self` (`foreign_self`, or a nested block {.under_foreign_self_block?}).
152
+ def outliving_writes(node, ancestors, base_scope, non_locals, foreign_self)
153
+ found = ReflectiveIvarWrite.unknown_target?(node) ? reflective_unknown_ivars(base_scope) : NO_NAMES
154
+ written = rebindable_outliving_write(node, base_scope, non_locals)
155
+ return found if written.nil? && found.empty?
156
+
157
+ names = written ? found + [written] : found
158
+ return names unless foreign_self || under_foreign_self_block?(ancestors, base_scope)
159
+
160
+ names.reject { |name| ivar_name?(name) }
161
+ end
162
+
163
+ # Issue #1402 — whether a block on the path `ancestors` runs on an object provably not `self`: its call is a
164
+ # {InterveningIvarReset.foreign_self_block?}, the receiver typed in `base_scope`.
165
+ def under_foreign_self_block?(ancestors, base_scope)
166
+ (1...ancestors.size).any? do |index|
167
+ child = ancestors[index]
168
+ parent = ancestors[index - 1]
169
+ child.is_a?(Prism::BlockNode) && parent.is_a?(Prism::CallNode) && parent.block.equal?(child) &&
170
+ InterveningIvarReset.foreign_self_block?(parent, base_scope)
171
+ end
172
+ end
173
+
143
174
  # Issue #1889 — every ivar `base_scope` binds: an `instance_variable_set` on `self` whose target no literal
144
175
  # names ({ReflectiveIvarWrite.unknown_target?}) may write any of them. One on its ADR-58 declaration seed
145
176
  # counts too, since the census behind the seed sees no reflective write; {.bind} keeps its mark.
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/node_children"
6
+ require_relative "../type"
7
+ require_relative "captured_locals"
8
+
9
+ module Rigor
10
+ module Inference
11
+ # Issues #1231 and #1839 — a rescue clause runs after the `begin` body raised from any point in it, and an `ensure`
12
+ # clause after the body, its `else` or a rescue clause raised, so each local or instance variable those wrote may
13
+ # hold any value one of their writes gave it, or the one it entered with. The engine does not track where the body
14
+ # raises; a clause enters with such a name rebound to `Dynamic[top]` instead ({.widen}).
15
+ #
16
+ # Not every name the body writes: one the clause mentions, whose binding the clause reads, and one the body
17
+ # writes more than once, which can hold a value neither the entry nor the body's exit binds. A name written once
18
+ # and never mentioned leaves the clause at its entry binding, which the join past the `begin` already pairs with
19
+ # the body's exit binding, the value of that one write (a write in a block or loop body included: the scope a
20
+ # block call or loop leaves joins every iteration's).
21
+ module ClauseWidening
22
+ WRITE_NODES = Set[
23
+ *CapturedLocals::LOCAL_WRITE_NODES,
24
+ Prism::InstanceVariableWriteNode,
25
+ Prism::InstanceVariableOperatorWriteNode,
26
+ Prism::InstanceVariableOrWriteNode,
27
+ Prism::InstanceVariableAndWriteNode,
28
+ Prism::InstanceVariableTargetNode
29
+ ].freeze
30
+
31
+ MENTION_NODES = Set[Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, *WRITE_NODES].freeze
32
+
33
+ SCOPE_BODY_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
34
+
35
+ # The names `sources` write: `names` all of them, `repeated` those written more than once, a nested block's
36
+ # included (it may write an outer local). A `def` or class body writes nothing here.
37
+ Writes = Data.define(:names, :repeated)
38
+
39
+ module_function
40
+
41
+ # The {Writes} of `sources`, or nil for none.
42
+ def writes(sources)
43
+ counts = nil
44
+ sources.each { |source| counts = count_writes(source, counts) if source }
45
+ return nil if counts.nil?
46
+
47
+ Writes.new(names: counts.keys.to_set, repeated: counts.filter_map { |name, count| name if count > 1 }.to_set)
48
+ end
49
+
50
+ # `scope` with each name of `writes` that `clause` mentions, or that is repeated, rebound to `Dynamic[top]` where
51
+ # `scope` binds it, or nil when none changes. `clause` may be nil (an empty rescue clause).
52
+ def widen(scope, writes, clause)
53
+ return nil if writes.nil?
54
+
55
+ names = mentions(clause, writes.names, writes.repeated.dup)
56
+ untyped = Type::Combinator.untyped
57
+ widened = names.reduce(scope) do |acc, name|
58
+ ivar = name.start_with?("@")
59
+ current = ivar ? scope.ivar(name) : scope.local(name)
60
+ next acc if current.nil? || current == untyped
61
+
62
+ ivar ? acc.with_ivar(name, untyped).without_ivar_guard(name) : acc.with_local(name, untyped)
63
+ end
64
+ widened.equal?(scope) ? nil : widened
65
+ end
66
+
67
+ # `counts` with each write under `node` counted.
68
+ def count_writes(node, counts)
69
+ return counts if SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
70
+
71
+ if WRITE_NODES.include?(node.class)
72
+ counts ||= Hash.new(0)
73
+ counts[node.name] += 1
74
+ end
75
+ node.rigor_each_child { |child| counts = count_writes(child, counts) }
76
+ counts
77
+ end
78
+
79
+ # `found` with each name of `among` that `node` mentions added.
80
+ def mentions(node, among, found)
81
+ return found if node.nil? || SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
82
+
83
+ found << node.name if MENTION_NODES.include?(node.class) && among.include?(node.name)
84
+ node.rigor_each_child { |child| found = mentions(child, among, found) }
85
+ found
86
+ end
87
+ private_class_method :count_writes, :mentions
88
+ end
89
+ end
90
+ end
@@ -61,7 +61,7 @@ module Rigor
61
61
  flavor = question == :arity ? :arity : :methods
62
62
  chain = Scope::ResolutionChain.for(scope, class_name.to_s, side, flavor)
63
63
 
64
- reads_visibility = VISIBILITY_QUESTIONS.include?(question)
64
+ reads_visibility = VISIBILITY_QUESTIONS.include?(question) ? question : nil
65
65
  hits = candidates(scope, chain, method_name, from, answer_in, reads_visibility)
66
66
  return UNKNOWN if hits.nil?
67
67
 
@@ -159,23 +159,39 @@ module Rigor
159
159
  end
160
160
 
161
161
  # The implicit tail after a chain's last project superclass: `Object` (with `Kernel` and `BasicObject`) on the
162
- # instance side, `#<Class:Object>` through `Class`, `Module` and `Kernel` on the singleton side.
162
+ # instance side, `#<Class:Object>` through `Class`, `Module` and `Kernel` on the singleton side. It may answer
163
+ # where RBS declares the name there, where the last class's superclass is not a constant the tables can follow
164
+ # (#1624, {Scope::ResolutionChain#opaque_tail?}: `class C < Struct.new(:a)`, `C = Class.new(Base)`), and where
165
+ # the project itself reopens a tail class with the name (`class Object; def self.x`), which is no entry on the
166
+ # chain because the class names no superclass.
163
167
  def implicit_object_answers?(scope, chain, method_name)
164
- !external_lacks?(scope, ["Object"], chain.side, method_name)
168
+ !external_lacks?(scope, ["Object"], chain.side, method_name) || chain.opaque_tail?(scope) ||
169
+ project_tail_defines?(scope, chain.side, method_name)
170
+ end
171
+
172
+ # The implicit tail's classes and the side each is read on, per chain side: a class object reaches the instance
173
+ # methods of `Class`, `Module`, `Object`, `Kernel` and `BasicObject` and the singleton methods of `Object` and
174
+ # `BasicObject`.
175
+ CLASS_OBJECT_TAIL = %w[Class Module Object Kernel BasicObject].product([:instance]) +
176
+ %w[Object BasicObject].product([:singleton])
177
+ IMPLICIT_TAIL = Ractor.make_shareable({ instance: %w[Object Kernel BasicObject].product([:instance]),
178
+ singleton: CLASS_OBJECT_TAIL })
179
+ private_constant :CLASS_OBJECT_TAIL, :IMPLICIT_TAIL
180
+
181
+ # Whether a project reopening of a tail class records the name: a `def`, whose read files the method edge, or
182
+ # an `attr_*` / `define_method` the existence table holds.
183
+ def project_tail_defines?(scope, side, method_name)
184
+ IMPLICIT_TAIL.fetch(side).any? do |name, kind|
185
+ (kind == :singleton ? scope.singleton_def_for(name, method_name) : scope.user_def_for(name, method_name)) ||
186
+ scope.discovered_method?(name, method_name, kind)
187
+ end
165
188
  end
166
189
 
167
190
  # True only for an ancestor RBS knows whose declaration lacks the name, on the side the entry stands for: an
168
191
  # external superclass on a singleton chain (`:singleton`, its whole tail) is asked for a singleton method, an
169
192
  # extended module (`:instance`) and every instance-side entry for an instance method.
170
193
  def external_lacks?(scope, candidates, side, method_name)
171
- return Scope::ResolutionChain::Relevance.external_lacks?(scope, candidates, method_name) if side == :instance
172
-
173
- known = candidates.find { |candidate| Rigor::Reflection.rbs_class_known?(candidate, scope: scope) }
174
- return false if known.nil?
175
-
176
- Rigor::Reflection.singleton_method_definition(known, method_name, scope: scope).nil?
177
- rescue StandardError
178
- false
194
+ Scope::ResolutionChain::Relevance.external_lacks_on?(scope, candidates, side, method_name)
179
195
  end
180
196
 
181
197
  # What a candidate set says, for {Scope::ResolutionChain#settle} to compare across worlds.
@@ -198,14 +214,23 @@ module Rigor
198
214
  # that reads the visibility (`reads_visibility`): a slot is contested there for a visibility call the walk
199
215
  # cannot place (an uncertain toggle, `private(*names)`, `private def self.x`), which leaves the definer and its
200
216
  # `def` certain; a possible `def` or `attr_*` call contests its def-node slot or its name's `possible` side too.
217
+ #
218
+ # `reads_visibility` is the question when it reads one. The `:visibility` question does not read a contested
219
+ # instance def-node slot (#1989): it answers from the visibility slot and the name's existence, not from the
220
+ # `def` body, and a possible `def` that contests the slot also makes its name `possible`. The slot is contested
221
+ # alone where a later `attr_*` replaced the `def`: the attr writes the name's visibility slot, so the reader's
222
+ # visibility is certain. A `define_method` or alias that replaced it contests the visibility slot as well, which
223
+ # this question does read.
201
224
  def possible?(scope, hit, method_name, reads_visibility)
202
225
  discovery = scope.discovery
203
226
  owner = hit.owner
204
227
  kind = hit.side == :singleton ? :singleton : :instance
205
228
  nodes = kind == :singleton ? :discovered_singleton_def_nodes : :discovered_def_nodes
206
229
  discovery.possible_method?(owner, method_name, kind) ||
207
- discovery.contested?(nodes, [owner, method_name.to_sym]) ||
208
- (reads_visibility && discovery.contested?(:discovered_method_visibilities, [owner, method_name.to_sym])) ||
230
+ (!(reads_visibility == :visibility && kind == :instance) &&
231
+ discovery.contested?(nodes, [owner, method_name.to_sym])) ||
232
+ (reads_visibility && discovery.contested?(:discovered_method_visibilities,
233
+ [owner, visibility_slot(method_name, kind)])) ||
209
234
  discovery.contested?(:discovered_parameter_envelopes, [owner, [kind, method_name.to_sym]])
210
235
  end
211
236
 
@@ -235,20 +260,61 @@ module Rigor
235
260
 
236
261
  # The first project entry that records the name (a discovered method, an instance `def` the def table
237
262
  # holds, or a visibility change such as `private :foo` recorded for it), with its visibility (`:public` where
238
- # none was stated).
263
+ # none was stated). Issue #1984 — a singleton-side entry reads its class's singleton slot
264
+ # (`private_class_method :new` records `new` there with no `def`), never the instance one.
239
265
  def visibility_answer(scope, method_name)
240
266
  lambda do |chain, from|
241
267
  first_hit(chain, scope, from) do |entry|
242
268
  kind = entry.side == :singleton ? :singleton : :instance
243
- next unless scope.discovered_method?(entry.name, method_name, kind) ||
244
- (kind == :instance && (scope.user_def_for(entry.name, method_name) ||
245
- scope.discovered_method_visibility(entry.name, method_name)))
269
+ recorded = side_visibility(scope, entry.name, method_name, kind)
270
+ next unless scope.discovered_method?(entry.name, method_name, kind) || recorded ||
271
+ (kind == :instance && scope.user_def_for(entry.name, method_name))
272
+
273
+ recorded || :public
274
+ end
275
+ end
276
+ end
277
+
278
+ def side_visibility(scope, owner, method_name, kind)
279
+ if kind == :singleton
280
+ scope.discovered_method_visibilities.dig(owner.to_s, visibility_slot(method_name, :singleton)) ||
281
+ self_extended_copy_visibility(scope, owner, method_name)
282
+ else
283
+ scope.discovered_method_visibility(owner, method_name)
284
+ end
285
+ end
246
286
 
247
- scope.discovered_method_visibility(entry.name, method_name) || :public
287
+ # Issue #2059 — a module that `extend`s itself reaches its own instance method from the singleton side, and the
288
+ # extends fold copies that receiverless `def` onto the module's singleton entry, where no singleton slot records
289
+ # it: the instance slot's visibility is what Ruby applies (`module ES; extend self; private def x` makes `ES.x`
290
+ # private). A module function's singleton copy is public whatever its instance copy is, so a `module_function`
291
+ # row for the name in any file answers nil, as does a contested instance slot.
292
+ def self_extended_copy_visibility(scope, owner, method_name)
293
+ node = scope.singleton_def_for(owner, method_name)
294
+ return nil if node.nil? || !node.receiver.nil?
295
+ return nil unless scope.discovered_extends[owner.to_s]&.include?(owner.to_s)
296
+ return nil if scope.discovery.contested?(:discovered_method_visibilities, [owner.to_s, method_name.to_sym])
297
+ return nil if module_function_row?(scope, owner, method_name)
298
+
299
+ scope.discovered_method_visibility(owner, method_name)
300
+ end
301
+
302
+ # Whether any file's deferred ranges carry a `module_function` row (`:both` or `:singleton`) for the name on
303
+ # `owner` (`ModuleFunctionState`).
304
+ def module_function_row?(scope, owner, method_name)
305
+ name = method_name.to_sym
306
+ scope.discovered_deferred_ranges.each_value.any? do |rows|
307
+ rows.any? do |(_start, _finish, row_name, kind, row_owner)|
308
+ row_name == name && row_owner == owner.to_s && kind != :instance
248
309
  end
249
310
  end
250
311
  end
251
312
 
313
+ # The key of a name's slot in the visibility table on `kind`'s side (`ScopeIndexer#visibility_slot`).
314
+ def visibility_slot(method_name, kind)
315
+ kind == :singleton ? [:singleton, method_name.to_sym] : method_name.to_sym
316
+ end
317
+
252
318
  def first_hit(chain, scope, from)
253
319
  index = from - 1
254
320
  found = nil
@@ -265,9 +331,10 @@ module Rigor
265
331
  end
266
332
 
267
333
  private_class_method :override_answer, :retro_position, :candidates, :past_fold_copies, :decided,
268
- :external_may_answer?, :implicit_object_answers?, :external_lacks?,
334
+ :external_may_answer?, :implicit_object_answers?, :project_tail_defines?, :external_lacks?,
269
335
  :outcomes, :collapse, :possible?,
270
- :default_answer, :definer_answer, :visibility_answer, :first_hit
336
+ :default_answer, :definer_answer, :visibility_answer, :side_visibility,
337
+ :self_extended_copy_visibility, :module_function_row?, :visibility_slot, :first_hit
271
338
  end
272
339
  end
273
340
  end
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../type"
6
6
  require_relative "../reflection"
7
7
  require_relative "../ast"
8
+ require_relative "clause_widening"
8
9
  require_relative "../source/constant_path"
9
10
  require_relative "../source/node_children"
10
11
  require_relative "../source/node_walker"
@@ -41,6 +42,7 @@ require_relative "receiver_alias"
41
42
  require_relative "repeated_or_writes"
42
43
  require_relative "singleton_object_constant"
43
44
  require_relative "refined_dispatch"
45
+ require_relative "singleton_external_ahead"
44
46
  require_relative "stored_block_call"
45
47
  require_relative "struct_fold_safety"
46
48
  require_relative "unknown_store_widening"
@@ -1208,17 +1210,22 @@ module Rigor
1208
1210
  Type::Combinator.constant_of(nil)
1209
1211
  end
1210
1212
 
1211
- rescue_types = rescue_chain_types(rescue_clause)
1213
+ rescue_types = rescue_chain_types(rescue_clause, node.statements)
1212
1214
  Type::Combinator.union(primary_type, *rescue_types)
1213
1215
  end
1214
1216
 
1215
1217
  # Issue #1360 — the evaluator binds `$!` in a rescue clause it enters; a clause typed here, such as one in a
1216
1218
  # `begin` passed as an argument, reads `$!`, `$@` and `$?` unbound rather than an enclosing clause's.
1217
- def rescue_chain_types(rescue_node)
1218
- arm_typer = typer_under(rescue_arm_scope)
1219
+ #
1220
+ # Issue #1839 — it also reads each name the `body` writes as `Dynamic[top]` ({ClauseWidening}), as the evaluator's
1221
+ # rescue clause does: `[begin; conn = make; work; rescue; conn; end]` holds the connection, not the entry's `nil`.
1222
+ def rescue_chain_types(rescue_node, body)
1223
+ arm_scope = rescue_arm_scope
1224
+ writes = ClauseWidening.writes([body])
1219
1225
  types = []
1220
1226
  current = rescue_node
1221
1227
  while current
1228
+ arm_typer = typer_under(ClauseWidening.widen(arm_scope, writes, current.statements) || arm_scope)
1222
1229
  types << arm_typer.send(:statements_or_nil, current.statements)
1223
1230
  current = current.subsequent
1224
1231
  end
@@ -1279,11 +1286,23 @@ module Rigor
1279
1286
  def type_of_range(node)
1280
1287
  left_static, left = static_range_endpoint(node.left)
1281
1288
  right_static, right = static_range_endpoint(node.right)
1282
- return Type::Combinator.constant_of(Range.new(left, right, node.exclude_end?)) if left_static && right_static
1289
+ if left_static && right_static
1290
+ range = static_range_value(left, right, node.exclude_end?)
1291
+ return Type::Combinator.constant_of(range) if range
1292
+ end
1283
1293
 
1284
1294
  nominal_range_for_endpoints(node.left, node.right)
1285
1295
  end
1286
1296
 
1297
+ # The `Range` the static endpoints denote, or nil when Ruby would refuse to build it (`1.."a"`,
1298
+ # `"a"..1` raise `ArgumentError: bad value for range`). The literal is a runtime error,
1299
+ # not a constant, so the fold declines and the caller types it as a plain Range.
1300
+ def static_range_value(left, right, exclude_end)
1301
+ Range.new(left, right, exclude_end)
1302
+ rescue ArgumentError
1303
+ nil
1304
+ end
1305
+
1287
1306
  # Derives `Nominal[Range, [T]]` from the endpoint expression types when at least one endpoint is
1288
1307
  # statically typeable. The element parameter is the union of the endpoint types (lifted from
1289
1308
  # `Constant<v>` to `Nominal<v.class>` so the carrier matches what `Range#each` would yield). Falls back
@@ -2583,6 +2602,10 @@ module Rigor
2583
2602
 
2584
2603
  def_node, owner = resolve_singleton_def_with_owner(receiver.class_name, method_name)
2585
2604
  return nil if def_node.nil?
2605
+ # Issue #1936 — a contested slot holds a `def` a later definer may have replaced
2606
+ # (`singleton_class.alias_method :x, :y` after `def self.x`), or one that may not have run: its body is not
2607
+ # proven to be the method Ruby calls, so the call falls through to `Dynamic`.
2608
+ return nil if scope.discovery.contested?(:discovered_singleton_def_nodes, [owner, method_name.to_sym])
2586
2609
 
2587
2610
  result = infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
2588
2611
  return result if result.nil?
@@ -2597,6 +2620,10 @@ module Rigor
2597
2620
  # `discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`, and this walk reads a
2598
2621
  # fourth table those three do not imply. Keying on what the answer actually depends on is the #682
2599
2622
  # lesson — a memo that outlives its inputs serves one scope's answer to another.
2623
+ #
2624
+ # It is the TYPING read only, so it also withholds (`[nil, nil]`, the call types `Dynamic`) where an external
2625
+ # ancestor may answer ahead of the definer found (#1572: `extend M; extend Enumerable` makes `C.to_a`
2626
+ # `Enumerable#to_a`, which the reader passes over). The existence reads keep the reader's own answer.
2600
2627
  def resolve_singleton_def_with_owner(class_name, method_name)
2601
2628
  by_singleton = (class_graph_buckets[:singleton_def] ||= {}.compare_by_identity)
2602
2629
  cache = (by_singleton[scope.discovered_singleton_def_nodes] ||= {})
@@ -2604,7 +2631,10 @@ module Rigor
2604
2631
  key = method_name.to_sym
2605
2632
  return table[key] if table.key?(key)
2606
2633
 
2607
- table[key] = scope.singleton_def_through_ancestors(class_name, method_name)
2634
+ found = scope.singleton_def_through_ancestors(class_name, method_name)
2635
+ owner = found.last
2636
+ found = [nil, nil] if owner && SingletonExternalAhead.withhold?(scope, class_name, method_name, owner)
2637
+ table[key] = found
2608
2638
  end
2609
2639
 
2610
2640
  # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`