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
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../dependency_recorder"
6
+ require_relative "../../inference/scope_indexer"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ module CheckRules
11
+ # Issue #1817 — `call.unresolved-toplevel` declines inside a block handed to a project method that may run it
12
+ # under another `self`. `def self.define(&blk) = module_eval(&blk)` makes `Ext.define { refine(String) { … } }`
13
+ # call `refine` on `Ext`, not on `main`, so the call is not a top-level one even though the block sits at the
14
+ # top of the file.
15
+ #
16
+ # The reading is by method NAME across every project definition, never by the receiver's type: any project
17
+ # `def` of the block's callee name that may hand the block to something else declines the rule. That
18
+ # over-reads (an unrelated class's same-named method declines too), which only loses a report; resolving the
19
+ # receiver would read one class and could miss the definition Ruby actually dispatches to, which reports on
20
+ # correct code. A definition "may hand the block on" unless every use of it is provably a plain call:
21
+ #
22
+ # - its body only `yield`s, or reads the block parameter only as the receiver of `call` / `()` / `[]` /
23
+ # `yield` / `===` / `arity` / `lambda?` / `parameters` / `nil?` / `!`, or as an `if` / `unless` / `while` /
24
+ # `until` predicate or the left side of `&&` — each of which runs the block with its own `self` or not at
25
+ # all;
26
+ # - any other read (`module_eval(&blk)`, `other(&blk)`, `@blk = blk`, `return blk`), an anonymous `&` or a
27
+ # `...` forwarded anywhere, a `super` that passes the block on implicitly, or a `binding` / `eval` that
28
+ # could reach it, may rebind `self`. Where in the body it sits does not matter: an eval under an `if` may
29
+ # run.
30
+ #
31
+ # A callee name the project gives a method without a readable `def` (`attr_*`, `alias_method`,
32
+ # `define_method`, `define_singleton_method` on any receiver, a `def` written inside a block), or patches
33
+ # through `pre_eval:`, cannot be read and declines too. So does a `send` / `__send__` / `public_send` whose
34
+ # method name is not a literal. Issue #2184 — a project `method_missing` that may hand its block on answers
35
+ # for every name, since Ruby reaches it for any name the receiver lacks; and the block of a `define_method` /
36
+ # `define_singleton_method` call is a method body, run under whatever receives the method. A callee no project
37
+ # file defines keeps the rule's old answer otherwise: Rigor reads only the project's own bodies here.
38
+ module RebindingBlockCallees
39
+ SEND_CALLS = %i[send __send__ public_send].freeze
40
+ # Reading the block through these runs it with its own `self`, or does not run it.
41
+ PLAIN_PROC_CALLS = %i[call yield [] === ! arity lambda? parameters nil?].freeze
42
+ # Calls, on any receiver, that can reach a local by name: a `Binding`, a string `eval`, and the string forms of
43
+ # the `*_eval` family (with an argument), whose code runs in the caller's scope.
44
+ BINDING_CALLS = %i[binding eval local_variable_get].freeze
45
+ STRING_EVAL_CALLS = %i[instance_eval class_eval module_eval].freeze
46
+ private_constant :STRING_EVAL_CALLS
47
+ PREDICATE_PARENTS = [Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode].freeze
48
+ # `proc { … }`, `lambda { … }` and `Proc.new { … }` passed with `&` are the block of the call they go to.
49
+ PROC_LITERAL_CALLS = %i[proc lambda].freeze
50
+ # Core methods that run their block under another `self`: as a method body, or under the receiver (#2186:
51
+ # also when the block is a proc passed through a local, `Ext.instance_exec(&blk)`).
52
+ REBINDING_CORE_CALLS = %i[
53
+ define_method define_singleton_method
54
+ instance_eval instance_exec class_eval class_exec module_eval module_exec
55
+ ].freeze
56
+ private_constant :REBINDING_CORE_CALLS
57
+ private_constant :SEND_CALLS, :PLAIN_PROC_CALLS, :BINDING_CALLS, :PREDICATE_PARENTS, :PROC_LITERAL_CALLS
58
+
59
+ # The calls in one file that pass a literal block, each with the offsets that block's body spans, plus the
60
+ # file's memo of callee answers. Built on the first ask, so a file whose would-fire calls all decline earlier
61
+ # never walks. One file is one ADR-46 consumer, so an answer's dependency edges, recorded on its first ask,
62
+ # hold for every later call in the file that reuses it.
63
+ #
64
+ # Issue #2186 — a proc literal that is a value rather than a call's block (`blk = proc { … }`) adds a row for
65
+ # each call that receives it as `&blk`, when every other read of that local only calls or tests it. A literal
66
+ # called in place (`proc { … }.call`) runs with its own `self` and adds none. Any other proc value (stored,
67
+ # passed positionally, read another way, or in a method (or the code around it) that may read locals by name
68
+ # through `binding` / `eval` / `local_variable_get` or a computed `send`) cannot be followed to the call that
69
+ # runs it, so its row is {UNKNOWN}, which declines. A heredoc that opens inside a block adds a row over its
70
+ # body: Prism puts the body after the block's closing brace, but Ruby runs its interpolation inside the block.
71
+ class Sites
72
+ # The target of a row whose block Rigor cannot follow to the call that runs it.
73
+ UNKNOWN = :unknown
74
+
75
+ attr_reader :answers
76
+
77
+ def initialize(root)
78
+ @root = root
79
+ @rows = nil
80
+ @answers = {}
81
+ @def_rows = nil
82
+ end
83
+
84
+ # The block-passing calls (or {UNKNOWN}) whose block contains `node`. Rows are sorted by start, so the scan
85
+ # stops at the first block that starts after `node`.
86
+ def enclosing(node)
87
+ build
88
+ start = node.location.start_offset
89
+ stop = node.location.end_offset
90
+ found = []
91
+ @rows.each do |from, to, call|
92
+ break if from > start
93
+
94
+ found << call if stop <= to
95
+ end
96
+ found
97
+ end
98
+
99
+ # `{name => {[owner, side] => rows}}` over the project's named `def` rows, built once per file.
100
+ def def_rows(scope)
101
+ @def_rows ||= RebindingBlockCallees.def_row_counts(scope)
102
+ end
103
+
104
+ private
105
+
106
+ def build
107
+ return if @rows
108
+
109
+ @rows = []
110
+ @bodies = []
111
+ @heredocs = []
112
+ @proc_values = []
113
+ @local_reads = Hash.new { |hash, name| hash[name] = [] }
114
+ @block_passes = Hash.new { |hash, name| hash[name] = [] }
115
+ @scopes_reading_locals = Set.new.compare_by_identity
116
+ walk unless @root.nil?
117
+ # A block call chained on another (`m { }.then { }`) is met after the call whose block starts later.
118
+ @rows.sort_by!(&:first)
119
+ end
120
+
121
+ def walk
122
+ collect(@root, nil, @root)
123
+ @proc_values.each do |body, parent, local_scope|
124
+ value_targets(parent, local_scope).each { |target| @bodies << [body, target] }
125
+ end
126
+ @heredocs.sort_by!(&:first)
127
+ @bodies.each { |body, target| add_rows(body, target) }
128
+ end
129
+
130
+ def collect(node, parent, local_scope)
131
+ heredoc = RebindingBlockCallees.heredoc_range(node)
132
+ @heredocs << heredoc if heredoc
133
+ note(node, parent, local_scope)
134
+ # A `def` opens a new local-variable scope; blocks and lambdas share the enclosing one. A class body is
135
+ # read as part of the scope around it, which only widens the set a `binding` there may reach.
136
+ inner = node.is_a?(Prism::DefNode) ? node : local_scope
137
+ node.compact_child_nodes.each { |child| collect(child, node, inner) }
138
+ end
139
+
140
+ def note(node, parent, local_scope)
141
+ case node
142
+ when Prism::CallNode then note_call(node, parent, local_scope)
143
+ when Prism::LambdaNode then note_value(node, node, parent, local_scope)
144
+ when Prism::LocalVariableReadNode
145
+ @local_reads[node.name] << [node, parent] unless parent.is_a?(Prism::BlockArgumentNode)
146
+ end
147
+ end
148
+
149
+ def note_call(node, parent, local_scope)
150
+ block = node.block
151
+ body = RebindingBlockCallees.block_body(block)
152
+ @bodies << [body, node] if body
153
+ if block.is_a?(Prism::BlockArgumentNode) && block.expression.is_a?(Prism::LocalVariableReadNode)
154
+ @block_passes[block.expression.name] << node
155
+ end
156
+ value = RebindingBlockCallees.proc_literal(node)
157
+ note_value(node, value, parent, local_scope) if value
158
+ target = RebindingBlockCallees.send_target(node)
159
+ @scopes_reading_locals << local_scope if target.nil? || BINDING_CALLS.include?(target)
160
+ end
161
+
162
+ # `literal` is the proc expression and `body` its block. Passed with `&`, it is that call's block.
163
+ def note_value(literal, body, parent, local_scope)
164
+ return if parent.is_a?(Prism::BlockArgumentNode) || RebindingBlockCallees.plain_read?(literal, parent)
165
+
166
+ @proc_values << [body, parent, local_scope]
167
+ end
168
+
169
+ # The calls a proc value assigned to a local reaches as `&local`, or {UNKNOWN}.
170
+ def value_targets(parent, local_scope)
171
+ return [UNKNOWN] if @scopes_reading_locals.include?(local_scope)
172
+ return [UNKNOWN] unless parent.is_a?(Prism::LocalVariableWriteNode)
173
+
174
+ name = parent.name
175
+ followed = @local_reads[name].all? do |read, read_parent|
176
+ RebindingBlockCallees.plain_read?(read, read_parent)
177
+ end
178
+ followed ? @block_passes[name] : [UNKNOWN]
179
+ end
180
+
181
+ # The block's own row, plus one over the body of each heredoc that opens inside it.
182
+ def add_rows(body, target)
183
+ from = body.location.start_offset
184
+ to = body.location.end_offset
185
+ @rows << [from, to, target]
186
+ index = @heredocs.bsearch_index { |(opening, _start, _stop)| opening >= from }
187
+ return if index.nil?
188
+
189
+ while index < @heredocs.size && @heredocs[index].first < to
190
+ _opening, start, stop = @heredocs[index]
191
+ @rows << [start, stop, target]
192
+ index += 1
193
+ end
194
+ end
195
+ end
196
+
197
+ module_function
198
+
199
+ # Does `call_node` sit inside a block that some project definition of its callee may run under another `self`?
200
+ def inside_rebound_block?(call_node, scope, sites, scope_index = nil)
201
+ sites.enclosing(call_node).any? do |block_call|
202
+ block_call == Sites::UNKNOWN || callee_may_rebind?(block_call, scope, sites, scope_index)
203
+ end
204
+ end
205
+
206
+ # `[opening, body start, end]` offsets of a heredoc `node` opens, or nil.
207
+ def heredoc_range(node)
208
+ opening = node.opening_loc if node.respond_to?(:opening_loc)
209
+ closing = node.closing_loc if node.respond_to?(:closing_loc)
210
+ return nil unless opening&.slice&.start_with?("<<") && closing
211
+
212
+ start = case node
213
+ when Prism::StringNode, Prism::XStringNode then node.content_loc.start_offset
214
+ when Prism::InterpolatedStringNode, Prism::InterpolatedXStringNode
215
+ node.parts.first&.location&.start_offset
216
+ end
217
+ [opening.start_offset, start || closing.start_offset, closing.end_offset]
218
+ end
219
+
220
+ # The node whose span is the block `call` passes: its literal block, or a lambda / proc literal passed with `&`.
221
+ def block_body(block)
222
+ case block
223
+ when Prism::BlockNode then block
224
+ when Prism::BlockArgumentNode then proc_literal(block.expression)
225
+ end
226
+ end
227
+
228
+ def proc_literal(node)
229
+ case node
230
+ when Prism::LambdaNode then node
231
+ when Prism::CallNode
232
+ node.block if node.block.is_a?(Prism::BlockNode) && proc_literal_call?(node)
233
+ end
234
+ end
235
+
236
+ def proc_literal_call?(node)
237
+ return PROC_LITERAL_CALLS.include?(node.name) if node.receiver.nil?
238
+
239
+ node.name == :new && node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :Proc
240
+ end
241
+
242
+ def callee_may_rebind?(block_call, scope, sites, scope_index = nil)
243
+ name, receiver = Dispatch.dispatch(block_call)
244
+ return true if name.nil? || REBINDING_CORE_CALLS.include?(name)
245
+ return true if Dispatch.callable_call?(name, receiver, scope, scope_index)
246
+ return true if answer(name, scope, sites)
247
+ # Ruby reaches `method_missing` only for a name the receiver lacks.
248
+ return false if Dispatch.resolves_on_receiver?(receiver, name, scope, scope_index)
249
+
250
+ answer(:method_missing, scope, sites)
251
+ end
252
+
253
+ def answer(name, scope, sites)
254
+ answers = sites.answers
255
+ return answers[name] if answers.key?(name)
256
+
257
+ answers[name] = project_may_rebind?(name, scope, sites)
258
+ end
259
+
260
+ # The call's own name, or the literal name a `send` family call dispatches to (nil when it is not one).
261
+ def send_target(call)
262
+ return call.name unless SEND_CALLS.include?(call.name)
263
+
264
+ literal_name(call.arguments&.arguments&.first)
265
+ end
266
+
267
+ def literal_name(first)
268
+ case first
269
+ when Prism::SymbolNode, Prism::StringNode
270
+ text = first.unescaped
271
+ text.to_sym if text.valid_encoding?
272
+ end
273
+ end
274
+
275
+ # Reads every project table for `name`, so it records a `defines:<name>` edge whichever way it answers (a file
276
+ # that starts or stops defining the name re-checks this one), plus each read body's own method edge through the
277
+ # scope accessors (an edit of that body re-checks this one).
278
+ def project_may_rebind?(name, scope, sites)
279
+ DependencyRecorder.read_missing(:defines, name) if DependencyRecorder.active?
280
+ return true if patched_anywhere?(name, scope)
281
+
282
+ slots = Set.new
283
+ readers = {
284
+ [scope.discovered_def_nodes, :instance] => ->(owner) { instance_def(scope, owner, name) },
285
+ [scope.discovered_singleton_def_nodes, :singleton] => ->(owner) { scope.singleton_def_for(owner, name) }
286
+ }
287
+ readers.each do |(tables, side), reader|
288
+ tables.each do |owner, table|
289
+ next unless table.key?(name)
290
+
291
+ slots << [owner, side]
292
+ node = reader.call(owner)
293
+ return true if !node.is_a?(Prism::DefNode) || def_may_rebind?(node)
294
+ end
295
+ end
296
+ unread_definition?(sites.def_rows(scope)[name], slots) || bodiless_definition?(name, scope)
297
+ end
298
+
299
+ def instance_def(scope, owner, name)
300
+ return scope.top_level_def_for(name) if owner == Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
301
+
302
+ scope.user_def_for(owner, name)
303
+ end
304
+
305
+ # A def table keeps one body per class and side, so a second `def` of the name for the same slot (in a
306
+ # sibling file, whose order is not load order, or in the other arm of an `if`) is one the tables never
307
+ # showed, and so is a `def` whose class and side the tables hold no slot for. Every `def` outside a deferred
308
+ # body has a named row in the deferred-range table; a `module_function` row stands for both sides. A row with
309
+ # no side (a `def` inside a block, a `define_singleton_method` name) counts under a nil side, which no slot has.
310
+ def unread_definition?(rows, slots)
311
+ return false if rows.nil?
312
+
313
+ rows.any? { |slot, count| count > 1 || !slots.include?(slot) }
314
+ end
315
+
316
+ def def_row_counts(scope)
317
+ counts = Hash.new { |hash, name| hash[name] = Hash.new(0) }
318
+ scope.discovered_deferred_ranges.each_value do |list|
319
+ list.each do |(_start, _stop, name, kind, owner)|
320
+ next if name.nil?
321
+
322
+ key = owner || Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
323
+ sides = kind == :both ? %i[instance singleton] : [kind]
324
+ sides.each { |side| counts[name][[key, side]] += 1 }
325
+ end
326
+ end
327
+ counts.default_proc = nil
328
+ counts
329
+ end
330
+
331
+ # A name some class gives a method with no `def` body Rigor holds (`attr_*`, `alias_method`, `define_method`).
332
+ def bodiless_definition?(name, scope)
333
+ scope.discovered_methods.any? do |owner, table|
334
+ table.key?(name) &&
335
+ !scope.discovered_def_nodes[owner]&.key?(name) &&
336
+ !scope.discovered_singleton_def_nodes[owner]&.key?(name)
337
+ end
338
+ end
339
+
340
+ def patched_anywhere?(name, scope)
341
+ patched = scope.environment&.project_patched_methods
342
+ !patched.nil? && patched.by_key.any? { |(_owner, patched_name, _kind), _entry| patched_name == name }
343
+ end
344
+
345
+ def def_may_rebind?(def_node)
346
+ return false if def_node.body.nil?
347
+
348
+ block = def_node.parameters&.block
349
+ escapes?(def_node.body, nil, block&.name)
350
+ end
351
+
352
+ # `block_name` is the def's named `&` parameter, or nil.
353
+ def escapes?(node, parent, block_name)
354
+ return true if hands_block_on?(node, parent, block_name)
355
+
356
+ node.compact_child_nodes.any? { |child| escapes?(child, node, block_name) }
357
+ end
358
+
359
+ def hands_block_on?(node, parent, block_name)
360
+ case node
361
+ when Prism::ForwardingSuperNode then !node.block.is_a?(Prism::BlockNode)
362
+ when Prism::SuperNode then node.block.nil?
363
+ when Prism::BlockArgumentNode then node.expression.nil?
364
+ when Prism::ForwardingArgumentsNode then true
365
+ when Prism::LocalVariableReadNode then node.name == block_name && !plain_read?(node, parent)
366
+ when Prism::CallNode then reaches_locals?(node)
367
+ else false
368
+ end
369
+ end
370
+
371
+ # Issue #2186 — a call that can reach the block local by name, on any receiver and through `send`:
372
+ # `Kernel.eval("module_eval(&b)")`, `send(:binding).local_variable_get(:b)`, `instance_eval("module_eval(&b)")`.
373
+ # A `send` whose method name is not a literal may be any of them.
374
+ def reaches_locals?(call)
375
+ name = send_target(call)
376
+ return true if name.nil? || BINDING_CALLS.include?(name)
377
+
378
+ STRING_EVAL_CALLS.include?(name) && !call.arguments.nil?
379
+ end
380
+
381
+ def plain_read?(node, parent)
382
+ case parent
383
+ when Prism::CallNode then parent.receiver.equal?(node) && PLAIN_PROC_CALLS.include?(parent.name)
384
+ when Prism::AndNode then parent.left.equal?(node)
385
+ when *PREDICATE_PARENTS then parent.predicate.equal?(node)
386
+ else false
387
+ end
388
+ end
389
+ end
390
+ end
391
+ end
392
+ end
393
+
394
+ require_relative "rebinding_block_callees/dispatch"
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "rule_ids"
6
+ require_relative "../dependency_recorder"
6
7
  require_relative "../diagnostic"
7
8
  require_relative "../../inference/version_guard"
8
9
  require_relative "../../source/constant_path"
@@ -243,7 +244,13 @@ module Rigor
243
244
 
244
245
  # Whether a project or `pre_eval:` file defines a method called `name` anywhere — on any class, on either
245
246
  # side, or at the top level. Asked once per name per file.
247
+ #
248
+ # Issue #2133 — the answer reads every project file's tables whole, so it records a `defines:<name>` edge
249
+ # whichever way it answers, as {Inference::BlockCallTiming.project_defines_anywhere?} does: an edit that makes
250
+ # a file start or stop defining the name re-checks the call. A `pre_eval:` patch moves the snapshot's own
251
+ # fingerprint.
246
252
  def project_defines?(scope, name)
253
+ Analysis::DependencyRecorder.read_missing(:defines, name) if Analysis::DependencyRecorder.active?
247
254
  return true if scope.discovered_methods.any? { |_owner, table| table.key?(name) }
248
255
  return true if scope.discovered_def_nodes.any? { |_owner, table| table.key?(name) }
249
256
 
@@ -53,6 +53,24 @@ module Rigor
53
53
  # `else`-only.
54
54
  DEFENSIVE_EXIT_CALLS = %i[raise fail throw abort exit].freeze
55
55
 
56
+ # Issue #2071 — core classes whose idiomatic stand-ins are not their subclasses: a `StringIO` answers the
57
+ # IO protocol without being an `IO`, and a `Tempfile` delegates to a `File` without being one (or an `IO`).
58
+ # A subject Rigor types as one of these classes (`$stdout` by idiom, ADR-117) may hold such a stand-in,
59
+ # which no `when IO` / `in File` matches, so the `else` still runs for it.
60
+ DUCK_STAND_INS = {
61
+ "IO" => %w[StringIO Tempfile].freeze,
62
+ "File" => %w[Tempfile].freeze
63
+ }.freeze
64
+
65
+ # Each stand-in's ancestors as Ruby 4.0.5 has them (`StringIO.ancestors`, `Tempfile.ancestors` without the
66
+ # anonymous `DelegateClass` entries): a clause naming one matches the stand-in. Written out rather than read
67
+ # from RBS, which declares `class Tempfile < File`; a `Tempfile` is a `Delegator`, so neither `Object` nor
68
+ # `Kernel` matches it.
69
+ STAND_IN_ANCESTORS = {
70
+ "StringIO" => %w[StringIO Enumerable Object Kernel BasicObject].freeze,
71
+ "Tempfile" => %w[Tempfile Delegator BasicObject].freeze
72
+ }.freeze
73
+
56
74
  # @!attribute kind
57
75
  # @return `:disjoint` (this clause's type is disjoint
58
76
  # from the still-possible subject), `:prior_exhaustion` (an
@@ -137,9 +155,42 @@ module Rigor
137
155
  end
138
156
  coverage.record(clause)
139
157
  end
158
+ return if stand_in_reaches_else?(node, entry_type)
159
+
140
160
  collect_else(node.else_clause, subject.name, keyword)
141
161
  end
142
162
 
163
+ # Issue #2071 — whether the subject, as the `case` binds it, has a `Nominal` member of a {DUCK_STAND_INS}
164
+ # class with a stand-in no clause names an ancestor of ({STAND_IN_ANCESTORS}). `io = STDOUT; case io when IO
165
+ # then … else … end` runs the `else` for a `StringIO`, which the type `IO` stands for in idiomatic Ruby; a
166
+ # literal subject is a `Constant`, not a `Nominal`, and keeps its verdict. Asked of the `else` only: a dead
167
+ # `when` has its own evidence rules (#1429, #1485).
168
+ def stand_in_reaches_else?(node, entry_type)
169
+ members = entry_type.is_a?(Type::Union) ? entry_type.members : [entry_type]
170
+ stand_ins = members.flat_map do |member|
171
+ member.is_a?(Type::Nominal) ? DUCK_STAND_INS.fetch(member.class_name.delete_prefix("::"), []) : []
172
+ end
173
+ return false if stand_ins.empty?
174
+
175
+ named = clause_class_names(node)
176
+ stand_ins.any? { |stand_in| named.none? { |name| STAND_IN_ANCESTORS.fetch(stand_in).include?(name) } }
177
+ end
178
+
179
+ # The classes the clauses name as bare constants (`when A, B`, `in A`, `in A => x`).
180
+ def clause_class_names(node)
181
+ node.conditions.flat_map do |clause|
182
+ patterns = clause.is_a?(Prism::WhenNode) ? clause.conditions : [bare_pattern(clause.pattern)]
183
+ patterns.filter_map do |pattern|
184
+ entry = @scope_index[pattern]
185
+ entry && Inference::Narrowing.lexical_class_name(pattern, entry)
186
+ end
187
+ end
188
+ end
189
+
190
+ def bare_pattern(pattern)
191
+ pattern.is_a?(Prism::CapturePatternNode) ? pattern.value : pattern
192
+ end
193
+
143
194
  def entry_subject_type(case_node, subject_name)
144
195
  scope = @scope_index[case_node]
145
196
  scope&.local(subject_name)