rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "rule_walk"
6
+ require_relative "../../scope/resolution_chain"
6
7
  require_relative "../../source/constant_path"
7
8
  require_relative "../../source/node_children"
8
9
 
@@ -37,36 +38,54 @@ module Rigor
37
38
  # `def` that sits directly in a class / module body, never one nested in another `def`. On the
38
39
  # shared full DFS that prune becomes the `:inside_def` gate; the enclosing class / module name stack
39
40
  # the legacy walk threaded as `qualified_prefix` is now `context.qualified_prefix`.
40
- NODE_CLASSES = [Prism::DefNode].freeze
41
+ #
42
+ # ADR-125 — the walk also visits class-body `CallNode`s (a `def` body is gated off), to record the
43
+ # macro calls whose literal arguments name a method (`before_action :load_roles`).
44
+ NODE_CLASSES = [Prism::DefNode, Prism::CallNode].freeze
41
45
  RULE_WALK_GATES = %i[inside_def detached_ivar_facet].freeze
42
46
 
43
- # Returns `Hash[class_name (String) => Hash[ivar_name (Symbol) => Array<{node:, type:, declared:}>]]`.
44
- # Empty when the tree has no qualifying writes. `declared` is the translated type of the class's RBS
45
- # declaration of the ivar — an `@x: T` member or the slot an `attr_*` member implies, own or
46
- # inherited — and nil when the RBS declares none.
47
+ # Returns `Hash[class_name (String) => Hash[ivar_name (Symbol) => Array<{node:, type:, declared:,
48
+ # method_name:, visibility:, per_invocation:, action:}>]]`. Empty when the tree has no qualifying writes.
49
+ # `declared` is the translated type of the class's RBS declaration of the ivar — an `@x: T` member or
50
+ # the slot an `attr_*` member implies, own or inherited — and nil when the RBS declares none.
51
+ #
52
+ # ADR-125 — `method_name` is the enclosing `def`'s name and `visibility` its instance visibility
53
+ # (`:public` / `:protected` / `:private`, or nil when the discovery table has no settled answer).
54
+ # `per_invocation` is whether the class is, or descends from, a class a loaded plugin declares in
55
+ # `per_invocation_classes:`. `action` is whether the enclosing method is one of that class's entry
56
+ # points: public, and named by none of the declaring plugin's `per_invocation_non_action_macros:` in a
57
+ # class body of this file (`before_action :load_roles`, `helper_method :current_roles`).
47
58
  def initialize(scope_index)
48
59
  @scope_index = scope_index
49
60
  @accumulator = {}
61
+ @per_invocation = {}
62
+ @macro_arguments = {}
50
63
  end
51
64
 
52
65
  # Legacy single-collector walk — kept as the oracle the ADR-53 Track B equivalence harness compares
53
66
  # {RuleWalk} against; deleted when Track B completes.
54
67
  def collect(root)
55
68
  walk(root, [])
56
- @accumulator.transform_values(&:freeze).freeze
69
+ results
57
70
  end
58
71
 
59
72
  # {RuleWalk} entry point: the legacy walk's per-`def` logic, invoked at each class-/module-body `def`
60
73
  # under the shared traversal contract. `collect_def_writes`' verbatim body reads the enclosing class
61
74
  # prefix from `context.qualified_prefix`.
62
- def visit(def_node, context)
63
- collect_def_writes(def_node, context.qualified_prefix)
75
+ def visit(node, context)
76
+ if node.is_a?(Prism::CallNode)
77
+ record_macro_call(node, context.qualified_prefix)
78
+ else
79
+ collect_def_writes(node, context.qualified_prefix)
80
+ end
64
81
  end
65
82
 
66
83
  # The accumulated result, frozen the same way `#collect` returns it — used by {RuleWalk}-driven
67
84
  # callers after the walk completes.
68
85
  def results
69
- @accumulator.transform_values(&:freeze).freeze
86
+ @accumulator.to_h do |class_name, writes_by_ivar|
87
+ [class_name, writes_by_ivar.transform_values { |writes| with_actions(class_name, writes) }.freeze]
88
+ end.freeze
70
89
  end
71
90
 
72
91
  private
@@ -87,29 +106,71 @@ module Rigor
87
106
  when Prism::DefNode
88
107
  collect_def_writes(node, qualified_prefix)
89
108
  return
109
+ when Prism::CallNode
110
+ record_macro_call(node, qualified_prefix)
90
111
  end
91
112
 
92
113
  node.rigor_each_child { |child| walk(child, qualified_prefix) }
93
114
  end
94
115
 
116
+ # A receiverless (or `self.`) call in a class body, with the method names its literal Symbol / String
117
+ # arguments spell. Kept for every such call: which macro names count is the registry's answer, read
118
+ # once per class when the writes are finalised.
119
+ def record_macro_call(call_node, qualified_prefix)
120
+ return if qualified_prefix.empty?
121
+ return unless call_node.receiver.nil? || call_node.receiver.is_a?(Prism::SelfNode)
122
+
123
+ names = (call_node.arguments&.arguments || []).filter_map { |argument| literal_method_name(argument) }
124
+ return if names.empty?
125
+
126
+ ((@macro_arguments[qualified_prefix.join("::")] ||= {})[call_node.name] ||= []).concat(names)
127
+ end
128
+
129
+ # A Symbol / String literal's name, or the name of a `def` passed inline (`helper_method def
130
+ # current_user … end`). A literal with bytes no Symbol can hold (`before_action "\xff"`, #1821) names no
131
+ # method, so it answers nil.
132
+ def literal_method_name(argument)
133
+ return argument.name if argument.is_a?(Prism::DefNode) && argument.receiver.nil?
134
+ return nil unless argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
135
+
136
+ text = argument.unescaped
137
+ text.valid_encoding? ? text.to_sym : nil
138
+ end
139
+
140
+ # Each write with `action` set (see {#initialize}). A frozen copy, so `#results` stays repeatable.
141
+ def with_actions(class_name, writes)
142
+ macros = @per_invocation[class_name]
143
+ excluded = macros ? non_action_names(class_name, macros) : nil
144
+ writes.map do |write|
145
+ action = !excluded.nil? && write[:visibility] == :public && !excluded.include?(write[:method_name])
146
+ write.merge(action: action).freeze
147
+ end.freeze
148
+ end
149
+
150
+ def non_action_names(class_name, macros)
151
+ (@macro_arguments[class_name] || {}).each_with_object(Set.new) do |(macro, names), set|
152
+ set.merge(names) if macros.include?(macro)
153
+ end
154
+ end
155
+
95
156
  def collect_def_writes(def_node, qualified_prefix)
96
157
  return if def_node.body.nil? || qualified_prefix.empty?
97
158
  return if def_node.receiver.is_a?(Prism::SelfNode)
98
159
 
99
160
  class_name = qualified_prefix.join("::")
100
- gather_writes(def_node.body, class_name)
161
+ gather_writes(def_node.body, class_name, def_node.name)
101
162
  end
102
163
 
103
- def gather_writes(node, class_name)
164
+ def gather_writes(node, class_name, method_name)
104
165
  return unless node.is_a?(Prism::Node)
105
166
 
106
- record_write(node, class_name) if node.is_a?(Prism::InstanceVariableWriteNode)
167
+ record_write(node, class_name, method_name) if node.is_a?(Prism::InstanceVariableWriteNode)
107
168
  return if BARRIER_NODES.any? { |klass| node.is_a?(klass) }
108
169
 
109
- node.rigor_each_child { |child| gather_writes(child, class_name) }
170
+ node.rigor_each_child { |child| gather_writes(child, class_name, method_name) }
110
171
  end
111
172
 
112
- def record_write(node, class_name)
173
+ def record_write(node, class_name, method_name)
113
174
  scope = @scope_index[node]
114
175
  return if scope.nil?
115
176
 
@@ -117,7 +178,73 @@ module Rigor
117
178
  declared = declared_ivar(scope, class_name, node.name)
118
179
  @accumulator[class_name] ||= {}
119
180
  @accumulator[class_name][node.name] ||= []
120
- @accumulator[class_name][node.name] << { node: node, type: rvalue_type, declared: declared }
181
+ @accumulator[class_name][node.name] << {
182
+ node: node, type: rvalue_type, declared: declared, method_name: method_name,
183
+ visibility: method_visibility(scope, class_name, method_name),
184
+ per_invocation: !per_invocation_macros(scope, class_name).nil?
185
+ }
186
+ end
187
+
188
+ # The enclosing `def`'s instance visibility, or nil when it is not a settled fact: a slot the
189
+ # discovery table does not hold, or one a later `define_method` / alias / unplaceable write contests
190
+ # (#2013). `initialize` and the other lifecycle hooks are private whatever the table says.
191
+ def method_visibility(scope, class_name, method_name)
192
+ return :private if CheckRules::IMPLICITLY_PRIVATE_METHODS.include?(method_name)
193
+ return nil if scope.discovery.contested?(:discovered_method_visibilities, [class_name, method_name])
194
+
195
+ scope.discovered_method_visibility(class_name, method_name)
196
+ end
197
+
198
+ # ADR-125 — when `class_name` is, or descends from, a class a loaded plugin declares per-invocation, the
199
+ # non-action macros its declaring manifests list (a Set, possibly empty); nil otherwise. Read off the
200
+ # class's instance {Scope::ResolutionChain}, so a project superclass chain
201
+ # (`UsersController < ApplicationController < ActionController::Base`) is followed as Ruby resolves it.
202
+ # A project entry matches by name. An external superclass matches through the first of its candidate
203
+ # spellings the environment knows, by name or by an RBS-known `:equal` / `:subclass` ordering. When the
204
+ # environment knows none of them (rigor-actionpack leaves `ActionController::Base` undeclared on
205
+ # purpose), a candidate that is literally a declared name matches, unless some candidate names a class
206
+ # the project itself declares: then the spelling may resolve there instead. An ambiguous spelling (no
207
+ # candidates) and a truncated chain answer nil: the carve-out only ever withholds a finding, so an
208
+ # unproved descent keeps the rule firing. Memoised per class.
209
+ def per_invocation_macros(scope, class_name)
210
+ return @per_invocation[class_name] if @per_invocation.key?(class_name)
211
+
212
+ @per_invocation[class_name] = matched_per_invocation_macros(scope, class_name)
213
+ end
214
+
215
+ def matched_per_invocation_macros(scope, class_name)
216
+ environment = scope.environment
217
+ registry = environment&.plugin_registry
218
+ return nil if registry.nil? || registry.per_invocation_classes.empty?
219
+
220
+ chain = Scope::ResolutionChain.for(scope, class_name, :instance, :methods)
221
+ return nil if chain.truncated?
222
+
223
+ declared = chain.search(scope) { |entry| matched_declarations(entry, registry, scope) }
224
+ declared&.each_with_object(Set.new) { |name, set| set.merge(registry.per_invocation_non_action_macros(name)) }
225
+ end
226
+
227
+ # The declared per-invocation names `entry` matches, or nil (so {ResolutionChain#search} moves on).
228
+ def matched_declarations(entry, registry, scope)
229
+ return (registry.per_invocation_class?(entry.name.to_s) ? [entry.name.to_s] : nil) unless entry.external?
230
+ return nil unless entry.superclass_edge
231
+
232
+ environment = scope.environment
233
+ names = entry.candidates.map { |name| name.to_s.delete_prefix("::") }
234
+ known = names.find { |name| environment.class_known?(name) }
235
+ return literal_declarations(names, registry, scope) if known.nil?
236
+
237
+ matched = registry.per_invocation_classes.select do |declared|
238
+ %i[equal subclass].include?(environment.class_ordering(known, declared))
239
+ end
240
+ matched.empty? ? nil : matched
241
+ end
242
+
243
+ def literal_declarations(names, registry, scope)
244
+ return nil if names.any? { |name| scope.discovered_classes.key?(name) }
245
+
246
+ matched = names.select { |name| registry.per_invocation_class?(name) }
247
+ matched.empty? ? nil : matched
121
248
  end
122
249
 
123
250
  def declared_ivar(scope, class_name, ivar_name)
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../../inference/scope_indexer"
6
6
  require_relative "../../source/node_children"
7
+ require_relative "rebinding_block_callees"
7
8
 
8
9
  module Rigor
9
10
  module Analysis
@@ -41,6 +42,9 @@ module Rigor
41
42
 
42
43
  def refinements = (@refinements ||= Inference::InEffectRefinements.new(@root))
43
44
 
45
+ # Issue #1817 — the file's block-passing calls, for `call.unresolved-toplevel`'s rebinding-callee decline.
46
+ def block_calls = (@block_calls ||= RebindingBlockCallees::Sites.new(@root))
47
+
44
48
  # Does a `def <local>.<name>` for this call's local receiver and method name — or a call that gives the
45
49
  # local's object methods Rigor does not enumerate (issue #1722) — sit in the scope the call is in?
46
50
  def singleton_local_def?(call_node)
@@ -3,6 +3,7 @@
3
3
  require_relative "../../reflection"
4
4
  require_relative "../dependency_recorder"
5
5
  require_relative "../../inference/singleton_hook_decline"
6
+ require_relative "../../inference/object_mixins"
6
7
 
7
8
  module Rigor
8
9
  module Analysis
@@ -68,6 +69,43 @@ module Rigor
68
69
  end
69
70
  end
70
71
 
72
+ # Issue #2094 — the singleton side's twin of {.ancestor_may_answer?}: a module the class or an RBS superclass
73
+ # `extend`s (in its body, as `Float.extend(Ext)`, or as `Float.singleton_class.include(Ext)`) may answer
74
+ # `method_name` on the class object, read as {.mixin_defines?} / {.external_may_answer?} read an instance-side
75
+ # mixin. Only each level's own singleton segment is read: the class's further singleton ancestors are the
76
+ # RBS superclasses this walk visits in turn, and `Class` / `Module` are {.singleton_hook?}'s.
77
+ def singleton_mixin_may_answer?(scope, class_name, method_name)
78
+ singleton_class_names(scope, class_name).any? do |name|
79
+ DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
80
+
81
+ extends_answer?(scope, name, method_name, open_mixins: !OBJECT_ROOTS.include?(name))
82
+ end
83
+ end
84
+
85
+ def singleton_class_names(scope, class_name)
86
+ loader = scope.environment&.rbs_loader
87
+ pairs = loader ? loader.singleton_ancestors_for(class_name.to_s) : []
88
+ names = pairs.filter_map { |kind, name| name if kind == :singleton }
89
+ names.empty? ? [class_name.to_s] : names
90
+ end
91
+
92
+ def extends_answer?(scope, name, method_name, open_mixins:)
93
+ chain = Scope::ResolutionChain.for(scope, name, :singleton, :methods)
94
+ return true if chain.truncated?
95
+ # A mixin call the indexer could not record (`Integer.extend(m)`, `Symbol.singleton_class.send(:include,
96
+ # M)`) leaves the side tainted: some module the tables do not name may answer any name.
97
+ return true if open_mixins && chain.wildcard_mixin?
98
+
99
+ entries = chain.level_count.zero? ? chain.entries : chain.level_entries(0)
100
+ entries.any? do |entry|
101
+ if entry.external?
102
+ external_may_answer?(scope, entry, method_name, open_mixins)
103
+ else
104
+ entry.name != name && mixin_defines?(scope, entry, method_name, open_mixins)
105
+ end
106
+ end
107
+ end
108
+
71
109
  # Whether a project subclass of `class_name`, at any depth, defines `method_name` (through its own
72
110
  # ancestry, which reaches its project mixins) or a `method_missing`. Its parent is read as written and
73
111
  # resolved the way the class header resolves it: the first project class the spelling can denote, else the
@@ -132,19 +170,44 @@ module Rigor
132
170
  if Rigor::Reflection.rbs_class_known?(owner, scope: scope)
133
171
  scope.discovered_method?(owner, method_name, :instance)
134
172
  else
135
- project_defines?(scope, owner, method_name) ||
136
- (open_mixins && Inference::SingletonHookDecline.entry_hook_capable?(scope, entry))
173
+ project_defines?(scope, owner, method_name) || macro_names?(scope, owner, method_name) ||
174
+ (open_mixins && (Inference::SingletonHookDecline.entry_hook_capable?(scope, entry) ||
175
+ open_extend?(scope, owner)))
137
176
  end
138
177
  end
139
178
 
179
+ # Issue #2094 — a class-body macro of the module names the method ({Inference::ObjectMixins.macro_named?}).
180
+ # Answers the named method only, so it holds on `Object`, `Kernel` and `BasicObject` too, where a module
181
+ # that defines the name answers.
182
+ def macro_names?(scope, owner, method_name)
183
+ Inference::ObjectMixins.macro_named?(scope, owner, method_name)
184
+ end
185
+
186
+ # Issue #2094 — the module `extend`s a module whose surface is open: one neither RBS nor the project declares
187
+ # (a gem's DSL with no signatures), or a project module {Inference::SingletonHookDecline} reads as
188
+ # hook-capable. Any macro the module's body calls may come from it and define any name, and its `extended`
189
+ # hook may too, so the module answers any name, the reading an undeclared `include` already gets. A chain cut
190
+ # at its budget may hold such a module past the cut.
191
+ def open_extend?(scope, owner)
192
+ chain = Scope::ResolutionChain.for(scope, owner, :singleton, :methods)
193
+ return true if chain.truncated?
194
+
195
+ entries = chain.level_count.zero? ? chain.entries : chain.level_entries(0)
196
+ entries.any? { |extended| Inference::SingletonHookDecline.entry_hook_capable?(scope, extended) }
197
+ end
198
+
140
199
  # An external the project declares (a module with no `def`, which the chain does not hold as a project entry)
141
- # is known, and answers only through RBS.
200
+ # is known, and answers through RBS, or through what its own body's macros and `extend`s may define (issue
201
+ # #2094: {.macro_names?} and {.open_extend?}, asked of each candidate the project declares).
142
202
  def external_may_answer?(scope, entry, method_name, open_mixins)
143
203
  candidates = entry.candidates
144
204
  return open_mixins if candidates.empty?
145
205
  return true if candidates.any? do |candidate|
146
206
  Rigor::Reflection.instance_method_definition(candidate, method_name, scope: scope)
147
207
  end
208
+ return true if candidates.any? do |candidate|
209
+ macro_names?(scope, candidate, method_name) || (open_mixins && open_extend?(scope, candidate))
210
+ end
148
211
 
149
212
  open_mixins && candidates.none? { |candidate| declared?(scope, candidate) }
150
213
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ module RebindingBlockCallees
9
+ # Which method a block call reaches, and on what receiver: `callee_may_rebind?` reads the callee name, a
10
+ # callable receiver, and whether Ruby can reach `method_missing` from here.
11
+ module Dispatch
12
+ # Issue #2186 — a block given to `call` / `bind_call` (or `yield` / `===` / `[]`) on a literal `method(:x)`
13
+ # chain goes to the method it names: `Ext.method(:me).call { … }` is `Ext.me { … }`. On a receiver typed as
14
+ # one of CALLABLE_CLASSES it goes to a callable Rigor cannot follow; on any other receiver it is a call by
15
+ # that name.
16
+ METHOD_OBJECT_CALLS = %i[call yield === [] bind_call].freeze
17
+ METHOD_READERS = %i[method public_method singleton_method instance_method public_instance_method].freeze
18
+ # The readers whose receiver is the object the named method runs on.
19
+ BOUND_METHOD_READERS = %i[method public_method singleton_method].freeze
20
+ METHOD_WRAPPERS = %i[bind unbind to_proc curry].freeze
21
+ CALLABLE_CLASSES = %w[Method UnboundMethod Proc].freeze
22
+ private_constant :METHOD_READERS, :BOUND_METHOD_READERS, :METHOD_WRAPPERS, :CALLABLE_CLASSES
23
+
24
+ module_function
25
+
26
+ # `[name, receiver]` of the method the block reaches: the call's own name, the literal name a `send` family
27
+ # call dispatches to, or the method a literal `method(:name)` chain names. The receiver is a node, `:implicit`
28
+ # for a receiverless call, or nil where Rigor cannot say (an `instance_method` chain). The name is nil when a
29
+ # `send` names its method with anything but a valid literal.
30
+ def dispatch(call)
31
+ reader = method_reader(call.receiver) if METHOD_OBJECT_CALLS.include?(call.name)
32
+ if reader
33
+ receiver = reader.receiver || :implicit if BOUND_METHOD_READERS.include?(reader.name)
34
+ return [RebindingBlockCallees.literal_name(reader.arguments&.arguments&.first), receiver]
35
+ end
36
+ [RebindingBlockCallees.send_target(call), call.receiver || :implicit]
37
+ end
38
+
39
+ def method_reader(receiver)
40
+ receiver = receiver.receiver while wrapper?(receiver)
41
+ receiver if receiver.is_a?(Prism::CallNode) && METHOD_READERS.include?(receiver.name)
42
+ end
43
+
44
+ # Whether `receiver` may be a Method, UnboundMethod or Proc, whose `call` runs a callable Rigor cannot follow.
45
+ def callable?(receiver, scope, scope_index)
46
+ return false unless receiver.is_a?(Prism::Node)
47
+
48
+ type = receiver_type(receiver, scope, scope_index)
49
+ members = type.is_a?(Type::Union) ? type.members : [type]
50
+ members.any? do |member|
51
+ member.is_a?(Type::BoundMethod) ||
52
+ (member.is_a?(Type::Nominal) && CALLABLE_CLASSES.include?(member.class_name.to_s))
53
+ end
54
+ end
55
+
56
+ def wrapper?(node) = node.is_a?(Prism::CallNode) && METHOD_WRAPPERS.include?(node.name)
57
+
58
+ # A `call` (or the like) on a receiver that may be a Method, UnboundMethod or Proc.
59
+ def callable_call?(name, receiver, scope, scope_index)
60
+ METHOD_OBJECT_CALLS.include?(name) && callable?(receiver, scope, scope_index)
61
+ end
62
+
63
+ def receiver_type(receiver, scope, scope_index)
64
+ (scope_index&.[](receiver) || scope).type_of(receiver)
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ # Whether the receiver's type names a class whose RBS or project table gives it `name`, on every member of a
70
+ # union. A receiverless call at the top level asks `Object`, `main`'s class. Any other type cannot say.
71
+ def resolves_on_receiver?(receiver, name, scope, scope_index)
72
+ return scope.toplevel? && class_defines?("Object", :instance, name, scope) if receiver == :implicit
73
+ return false if receiver.nil?
74
+
75
+ type = receiver_type(receiver, scope, scope_index)
76
+ !type.nil? && type_resolves?(type, name, scope)
77
+ rescue StandardError
78
+ false
79
+ end
80
+
81
+ def type_resolves?(type, name, scope)
82
+ case type
83
+ when Type::Union then type.members.all? { |member| type_resolves?(member, name, scope) }
84
+ when Type::Singleton then class_defines?(type.class_name, :singleton, name, scope)
85
+ else
86
+ class_name = receiver_class_name(type)
87
+ !class_name.nil? && class_defines?(class_name, :instance, name, scope)
88
+ end
89
+ end
90
+
91
+ def receiver_class_name(type)
92
+ case type
93
+ when Type::Nominal, Type::StructInstance, Type::DataInstance then type.class_name&.to_s
94
+ when Type::Tuple then "Array"
95
+ when Type::HashShape then "Hash"
96
+ when Type::Constant then type.value.class.name
97
+ end
98
+ end
99
+
100
+ def class_defines?(class_name, side, name, scope)
101
+ rbs = if side == :singleton
102
+ Rigor::Reflection.singleton_method_definition(class_name, name, scope: scope)
103
+ else
104
+ Rigor::Reflection.instance_method_definition(class_name, name, scope: scope)
105
+ end
106
+ !rbs.nil? || scope.discovered_method?(class_name, name, side)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end