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
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../type"
6
6
  require_relative "../source/node_children"
7
7
  require_relative "return_barrier"
8
+ require_relative "optimistic_origin"
8
9
 
9
10
  module Rigor
10
11
  module Inference
@@ -21,10 +22,16 @@ module Rigor
21
22
  #
22
23
  # Extracted from `Rigor::SigGen::Generator#infer_return_type` so `LineTypeCollector` (`rigor annotate`'s
23
24
  # def-line annotator) and the sig-generator share one source of truth.
25
+ #
26
+ # `keep_lookup_nil: true` is the reading a signature needs (#2197). The engine reads a lookup that can miss
27
+ # (`Hash#[]`, `Array#first`, the {OptimisticOrigin} family) as nil-free on purpose, so `if h[k]` stays
28
+ # silent, but a `-> V` written into `sig/` is a contract that every caller's `nil` check is dead code. Each
29
+ # returned expression whose value is such a lookup therefore contributes `nil` beside its type. Only the
30
+ # value positions a lookup's `nil` reaches the return through are followed ({#optimistic_value?}).
24
31
  module DefReturnTyper
25
32
  module_function
26
33
 
27
- def call(def_node, scope_index)
34
+ def call(def_node, scope_index, keep_lookup_nil: false)
28
35
  body = def_node.body
29
36
  return nil if body.nil?
30
37
 
@@ -37,11 +44,152 @@ module Rigor
37
44
  last_type = safe_type_of(inner_scope, last)
38
45
  return nil if last_type.nil?
39
46
 
40
- union_with_explicit_returns(body, last_type, scope_index)
47
+ lookup_context = keep_lookup_nil ? LookupContext.new(scope_index, truthy_guards(body)) : nil
48
+ last_type = with_lookup_nil(last_type, last, inner_scope, lookup_context) if lookup_context
49
+ union_with_explicit_returns(body, last_type, scope_index, lookup_context: lookup_context)
41
50
  rescue StandardError
42
51
  nil
43
52
  end
44
53
 
54
+ # What {#optimistic_value?} reads beside a node's scope: the engine's per-node scopes, and for each lookup
55
+ # under a guard the source of the guarding predicates ({#truthy_guards}).
56
+ LookupContext = Data.define(:scope_index, :guards)
57
+
58
+ def with_lookup_nil(type, node, scope, context = nil)
59
+ return type unless optimistic_value?(node, scope, context)
60
+
61
+ Type::Combinator.union(type, Type::Combinator.constant_of(nil))
62
+ end
63
+
64
+ # Whether `node`'s value can be the `nil` an optimistic lookup's miss returns: the lookup itself (a call
65
+ # the dispatcher marked, or `recv&.m` over one), a local or ivar bound to one, or a branch or operand that
66
+ # hands its value on. `a && b` hands on either operand (a falsy `a`, a missed lookup's `nil` included, is
67
+ # the result); `a || b` hands on `b` only, since `a` is returned only when truthy, so `h[k] || 0` stays
68
+ # nil-free. `x ||= v` and `x &&= v` hand on `v` and, for `&&=`, the binding. A call ON the lookup
69
+ # (`h[k].to_s`) is a different value.
70
+ #
71
+ # Each child is read under the scope the engine recorded for it (`scope_index`), so a branch a guard narrows
72
+ # (`v ? v : "d"`, `return "z" unless v`) reads the binding the narrowing left, not the enclosing one.
73
+ def optimistic_value?(node, scope, context = nil)
74
+ scope = context&.scope_index&.[](node) || scope
75
+ case node
76
+ when Prism::CallNode then optimistic_call?(node, scope, context)
77
+ when Prism::LocalVariableReadNode then !scope.optimistic_local(node.name).nil?
78
+ when Prism::InstanceVariableReadNode then !scope.optimistic_ivar(node.name).nil?
79
+ when Prism::AndNode then [node.left, node.right].any? { optimistic_value?(it, scope, context) }
80
+ when Prism::OrNode then optimistic_value?(node.right, scope, context)
81
+ else optimistic_write?(node, scope, context) || optimistic_branch?(node, scope, context)
82
+ end
83
+ end
84
+
85
+ def optimistic_call?(node, scope, context)
86
+ if scope.optimistic_origins[node]
87
+ return false if guarded_lookup?(node, context) || non_empty_end?(node, scope)
88
+
89
+ return true
90
+ end
91
+ node.safe_navigation? && optimistic_value?(node.receiver, scope, context)
92
+ end
93
+
94
+ # `return h[k] if h[k]`, `h[k] ? h[k] : d`: the same lookup, read again under a predicate that just found it
95
+ # truthy. Only a lookup whose receiver and arguments are plain reads or literals counts, so the second read
96
+ # sees what the first did.
97
+ GUARDABLE_LOOKUPS = %i[[] first last dig].freeze
98
+ private_constant :GUARDABLE_LOOKUPS
99
+
100
+ def guarded_lookup?(node, context)
101
+ return false if context.nil? || !GUARDABLE_LOOKUPS.include?(node.name)
102
+ return false unless [node.receiver, *node.arguments&.arguments].all? { stable_operand?(it) }
103
+
104
+ context.guards.fetch(node, EMPTY_GUARDS).include?(node.slice)
105
+ end
106
+
107
+ EMPTY_GUARDS = [].freeze
108
+ private_constant :EMPTY_GUARDS
109
+
110
+ def stable_operand?(node)
111
+ case node
112
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::SymbolNode, Prism::StringNode,
113
+ Prism::IntegerNode, Prism::SelfNode, Prism::ConstantReadNode then true
114
+ else false
115
+ end
116
+ end
117
+
118
+ # `a.first` / `a.last` on a receiver the engine has narrowed to a non-empty array (`a.empty? ? d : a.first`)
119
+ # cannot miss.
120
+ def non_empty_end?(node, scope)
121
+ return false unless %i[first last].include?(node.name) && node.arguments.nil? && node.receiver
122
+
123
+ receiver = scope.type_of(node.receiver)
124
+ receiver.is_a?(Type::Difference) && receiver.removed == Type::Combinator.tuple_of
125
+ rescue StandardError
126
+ false
127
+ end
128
+
129
+ def optimistic_write?(node, scope, context)
130
+ case node
131
+ when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode,
132
+ Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode
133
+ optimistic_value?(node.value, scope, context)
134
+ when Prism::LocalVariableAndWriteNode
135
+ !scope.optimistic_local(node.name).nil? || optimistic_value?(node.value, scope, context)
136
+ when Prism::InstanceVariableAndWriteNode
137
+ !scope.optimistic_ivar(node.name).nil? || optimistic_value?(node.value, scope, context)
138
+ else false
139
+ end
140
+ end
141
+
142
+ # The value-carrying children of a statement list or a conditional: any of them can be the value returned.
143
+ def optimistic_branch?(node, scope, context)
144
+ value_branches(node).any? { |branch| !branch.nil? && optimistic_value?(branch, scope, context) }
145
+ end
146
+
147
+ # The lookup a truthy arm (the body of an `if` or ternary, the `else` of an `unless`) reads FIRST, mapped to
148
+ # the source of the predicate that just tested it, for {#guarded_lookup?}. Only the first expression, or the
149
+ # value of a first `return`, counts: anything run between the test and the re-read (`h.delete(k)`, a
150
+ # reassignment, a block, `yield`, another call that mutates the receiver) can make the re-read miss.
151
+ def truthy_guards(body)
152
+ guards = {}.compare_by_identity
153
+ collect_truthy_guards(body, guards)
154
+ guards
155
+ end
156
+
157
+ def collect_truthy_guards(node, guards)
158
+ return unless node.is_a?(Prism::Node)
159
+
160
+ predicate, arm = truthy_arm(node)
161
+ first = first_value(arm)
162
+ guards[first] = [predicate.slice] if first
163
+ node.rigor_each_child { collect_truthy_guards(it, guards) }
164
+ end
165
+
166
+ def first_value(arm)
167
+ return nil unless arm.is_a?(Prism::StatementsNode)
168
+
169
+ first = arm.body.first
170
+ args = first.is_a?(Prism::ReturnNode) ? first.arguments&.arguments : nil
171
+ args&.size == 1 ? args.first : first
172
+ end
173
+
174
+ def truthy_arm(node)
175
+ case node
176
+ when Prism::IfNode then [node.predicate, node.statements]
177
+ when Prism::UnlessNode then [node.predicate, node.else_clause&.statements]
178
+ end
179
+ end
180
+
181
+ def value_branches(node)
182
+ case node
183
+ when Prism::ParenthesesNode then [node.body && body_last_expression(node.body)]
184
+ when Prism::StatementsNode, Prism::BeginNode then [body_last_expression(node)]
185
+ when Prism::IfNode then [node.statements, node.subsequent]
186
+ when Prism::UnlessNode then [node.statements, node.else_clause]
187
+ when Prism::ElseNode, Prism::WhenNode, Prism::InNode then [node.statements]
188
+ when Prism::CaseNode, Prism::CaseMatchNode then [*node.conditions, node.else_clause]
189
+ else []
190
+ end
191
+ end
192
+
45
193
  def body_last_expression(body)
46
194
  case body
47
195
  when Prism::StatementsNode then body.body.last
@@ -50,27 +198,27 @@ module Rigor
50
198
  end
51
199
  end
52
200
 
53
- def union_with_explicit_returns(body, last_type, scope_index)
201
+ def union_with_explicit_returns(body, last_type, scope_index, lookup_context: nil)
54
202
  return_types = []
55
- collect_return_types(body, scope_index, return_types)
203
+ collect_return_types(body, scope_index, return_types, lookup_context: lookup_context)
56
204
  return last_type if return_types.empty?
57
205
 
58
206
  Type::Combinator.union(last_type, *return_types)
59
207
  end
60
208
 
61
- def collect_return_types(node, scope_index, out)
209
+ def collect_return_types(node, scope_index, out, lookup_context: nil)
62
210
  return unless node.is_a?(Prism::Node)
63
211
  return if ReturnBarrier.node?(node)
64
212
 
65
- type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
213
+ type_return_node(node, scope_index, out, lookup_context: lookup_context) if node.is_a?(Prism::ReturnNode)
66
214
  # A barrier call's receiver and arguments still run in the method; only its block is cut off.
67
215
  barrier_block = node.block if node.is_a?(Prism::CallNode) && ReturnBarrier.block_call?(node)
68
216
  node.rigor_each_child do |c|
69
- collect_return_types(c, scope_index, out) unless c.equal?(barrier_block)
217
+ collect_return_types(c, scope_index, out, lookup_context: lookup_context) unless c.equal?(barrier_block)
70
218
  end
71
219
  end
72
220
 
73
- def type_return_node(return_node, scope_index, out)
221
+ def type_return_node(return_node, scope_index, out, lookup_context: nil)
74
222
  args = return_node.arguments&.arguments || []
75
223
  if args.empty?
76
224
  out << Type::Combinator.constant_of(nil)
@@ -84,7 +232,9 @@ module Rigor
84
232
  return unless args.size == 1
85
233
 
86
234
  type = safe_type_of(scope, args.first)
87
- out << type unless type.nil?
235
+ return if type.nil?
236
+
237
+ out << (lookup_context ? with_lookup_nil(type, args.first, scope, lookup_context) : type)
88
238
  end
89
239
 
90
240
  def safe_type_of(scope, node)
@@ -221,11 +221,15 @@ module Rigor
221
221
  # alone where a later `attr_*` replaced the `def`: the attr writes the name's visibility slot, so the reader's
222
222
  # visibility is certain. A `define_method` or alias that replaced it contests the visibility slot as well, which
223
223
  # this question does read.
224
+ #
225
+ # Issue #2134 — the contest is a read of the slot, so a question that reads it files `visibility:<owner>#<name>`:
226
+ # an answer block may answer from RBS without reading the slot itself (`nearest_ancestor_method_def`).
224
227
  def possible?(scope, hit, method_name, reads_visibility)
225
228
  discovery = scope.discovery
226
229
  owner = hit.owner
227
230
  kind = hit.side == :singleton ? :singleton : :instance
228
231
  nodes = kind == :singleton ? :discovered_singleton_def_nodes : :discovered_def_nodes
232
+ Analysis::DependencyRecorder.read_visibility(owner, method_name) if reads_visibility
229
233
  discovery.possible_method?(owner, method_name, kind) ||
230
234
  (!(reads_visibility == :visibility && kind == :instance) &&
231
235
  discovery.contested?(nodes, [owner, method_name.to_sym])) ||
@@ -277,7 +281,7 @@ module Rigor
277
281
 
278
282
  def side_visibility(scope, owner, method_name, kind)
279
283
  if kind == :singleton
280
- scope.discovered_method_visibilities.dig(owner.to_s, visibility_slot(method_name, :singleton)) ||
284
+ scope.discovered_method_visibility(owner, method_name, singleton: true) ||
281
285
  self_extended_copy_visibility(scope, owner, method_name)
282
286
  else
283
287
  scope.discovered_method_visibility(owner, method_name)
@@ -301,11 +305,21 @@ module Rigor
301
305
 
302
306
  # Whether any file's deferred ranges carry a `module_function` row (`:both` or `:singleton`) for the name on
303
307
  # `owner` (`ModuleFunctionState`).
308
+ #
309
+ # Issue #2161 — the answer reads every file's rows, so it records `visibility:<owner>#<name>` whichever way it
310
+ # answers: `ScopeIndexer.visibility_entries` carries each file's rows of this shape, so a file that starts or
311
+ # stops writing one fires the key. A swap from `private :x` to `module_function :x` in a reopening leaves the
312
+ # file's visibility table and declared classes as they were, and fires nothing else. The caller's own
313
+ # visibility reads record the same key today; this one keeps the reader's edge where it reads.
314
+ #
315
+ # Issue #2209 — a row named {ModuleFunctionState::ANY_ROW_NAME} (a `module_function` spelling whose names the
316
+ # prescan cannot read) stands for every name of its owner, so it counts too, and its key is read as well
317
+ # ({ModuleFunctionState.row_names}).
304
318
  def module_function_row?(scope, owner, method_name)
305
- name = method_name.to_sym
319
+ names = ModuleFunctionState.row_names(method_name).each { Analysis::DependencyRecorder.read_visibility(owner, it) }
306
320
  scope.discovered_deferred_ranges.each_value.any? do |rows|
307
321
  rows.any? do |(_start, _finish, row_name, kind, row_owner)|
308
- row_name == name && row_owner == owner.to_s && kind != :instance
322
+ names.include?(row_name) && row_owner == owner.to_s && kind != :instance
309
323
  end
310
324
  end
311
325
  end
@@ -53,8 +53,8 @@ module Rigor
53
53
  # made a class guard on `$!` itself narrow it; #1447 narrows this decline to the guards that still do not.
54
54
  #
55
55
  # `$@` calls the exception's `backtrace`, which is an `Array[String]` for a raised exception. It is left unbound
56
- # in a program that defines a method named `backtrace` anywhere, which may return anything, and in a clause
57
- # whose body calls `set_backtrace`, which may set it to nil.
56
+ # in a program that defines a method named `backtrace` anywhere, which may return anything, in a clause whose
57
+ # body calls `set_backtrace`, which may set it to nil, and in a clause whose body never reads it.
58
58
  #
59
59
  # `$?` is unbound because the exception may have been raised while a subprocess waited: a backtick, `%x` or
60
60
  # `system` sets `$?` to nil before it runs the child, so a `Timeout::Error`, an `Interrupt` or a `Thread#raise`
@@ -65,6 +65,10 @@ module Rigor
65
65
  return entered if guarded?(body, reference)
66
66
 
67
67
  entered = entered.with_global(ERROR_INFO, rescued_type(exception_type, scope))
68
+ # Issue #1967 — a clause that never reads `$@` leaves it unbound without asking about `backtrace`: the answer
69
+ # could not reach a diagnostic, and asking records a `defines:backtrace` edge that re-checked every `rescue`
70
+ # in the program whenever such a def came or went.
71
+ return entered unless backtrace_read?(body)
68
72
  return entered if calls_set_backtrace?(body) || BlockCallTiming.project_defines_anywhere?(:backtrace, scope)
69
73
 
70
74
  entered.with_global(BACKTRACE, backtrace_type)
@@ -114,7 +118,17 @@ module Rigor
114
118
  node.rigor_each_child { |child| found ||= calls_set_backtrace?(child) }
115
119
  found
116
120
  end
117
- private_class_method :guard?, :error_info_read?, :calls_set_backtrace?
121
+
122
+ # True when `node` reads `$@` anywhere in it, a block included. An empty clause (nil) reads nothing.
123
+ def backtrace_read?(node)
124
+ return false unless node.is_a?(Prism::Node)
125
+ return node.name == BACKTRACE if node.is_a?(Prism::GlobalVariableReadNode)
126
+
127
+ found = false
128
+ node.rigor_each_child { |child| found ||= backtrace_read?(child) }
129
+ found
130
+ end
131
+ private_class_method :guard?, :error_info_read?, :calls_set_backtrace?, :backtrace_read?
118
132
 
119
133
  # True when `node` reads `$!`, `$@` or `$?` anywhere in it: the gate on typing a rescue modifier's fallback
120
134
  # under {.modifier_entry}, which most fallbacks (`rescue nil`) never need.
@@ -12,6 +12,7 @@ require_relative "../source/node_walker"
12
12
  require_relative "../analysis/self_call_resolution_recorder"
13
13
  require_relative "block_call_timing"
14
14
  require_relative "block_repetition"
15
+ require_relative "project_yielder"
15
16
  require_relative "block_parameter_binder"
16
17
  require_relative "method_parameter_binder"
17
18
  require_relative "body_fixpoint"
@@ -1051,7 +1052,7 @@ module Rigor
1051
1052
  # optimistic-carrier decline) and one terminating-arm rule, and cannot drift apart again. Only the
1052
1053
  # value is read; the post-scope belongs to whichever walk owns the statement.
1053
1054
  def type_of_conditional(node)
1054
- scope.evaluate(node, tracer: tracer).first
1055
+ LoopValueRecord.fetch(node, scope) || scope.evaluate(node, tracer: tracer).first
1055
1056
  end
1056
1057
 
1057
1058
  def type_of_else(node)
@@ -1272,11 +1273,22 @@ module Rigor
1272
1273
  Type::Combinator.bot
1273
1274
  end
1274
1275
 
1275
- # `while` and `until` loops produce nil unless interrupted by `break VALUE`; the expression value of
1276
- # `break VALUE` is not yet modeled (scope break-path propagation landed in `eval_loop`). Returning
1277
- # Constant[nil] is safe and matches Ruby semantics for the common case.
1278
- def type_of_loop(_node)
1279
- Type::Combinator.constant_of(nil)
1276
+ # A `while` / `until` / `for` loop in value position. Like {#type_of_conditional}, it delegates to the
1277
+ # statement evaluator, which owns the loop's value: `nil` or a `while` / `until` loop's `break` values
1278
+ # (issue #2031), and a `for` loop's collection or its `break` values (issue #1216).
1279
+ #
1280
+ # A `while` / `until` loop no `break` leaves is `nil` without evaluating its body, as
1281
+ # {StatementEvaluator#loop_value} answers it: a loop that is a call's receiver or argument is typed again by
1282
+ # every narrowing and rule that types that operand, and evaluating it each time re-ran the passes of every loop
1283
+ # nested in it.
1284
+ def type_of_loop(node)
1285
+ return Type::Combinator.constant_of(nil) if break_free_condition_loop?(node)
1286
+
1287
+ LoopValueRecord.fetch(node, scope) || scope.evaluate(node, tracer: tracer).first
1288
+ end
1289
+
1290
+ def break_free_condition_loop?(node)
1291
+ !node.is_a?(Prism::ForNode) && !block_level_jump?(node.statements, Prism::BreakNode)
1280
1292
  end
1281
1293
 
1282
1294
  def type_of_lambda(_node)
@@ -3318,21 +3330,34 @@ module Rigor
3318
3330
  cache = class_graph_buckets[:yields]
3319
3331
  return cache[def_node] if cache.key?(def_node)
3320
3332
 
3321
- cache[def_node] = yield_reachable?(def_node.body)
3333
+ cache[def_node] = yield_reachable?(def_node.body, def_node.parameters&.block&.name)
3322
3334
  end
3323
3335
 
3324
- def yield_reachable?(node)
3336
+ def yield_reachable?(node, block_name)
3325
3337
  return false if node.nil?
3326
- return true if node.is_a?(Prism::YieldNode)
3338
+ return true if node.is_a?(Prism::YieldNode) || forwards_block_argument?(node, block_name)
3327
3339
 
3328
3340
  node.rigor_each_child do |child|
3329
3341
  next if YIELD_BOUNDARY_NODES.include?(child.class)
3330
3342
 
3331
- return true if yield_reachable?(child)
3343
+ return true if yield_reachable?(child, block_name)
3332
3344
  end
3333
3345
  false
3334
3346
  end
3335
3347
 
3348
+ # Issue #1157 — a call that hands on its method's block (`&`, or `&blk` naming the block parameter) reaches the
3349
+ # caller's block as a `yield` does. Whether `blk` is still the method's own block (not rebound or shadowed) is
3350
+ # decided where it is typed ({#forwarded_block_return_type}); this only keeps the call-site dimension for a def
3351
+ # that might.
3352
+ def forwards_block_argument?(node, block_name)
3353
+ block = node.respond_to?(:block) ? node.block : nil
3354
+ return false unless block.is_a?(Prism::BlockArgumentNode)
3355
+
3356
+ expression = block.expression
3357
+ expression.nil? || (!block_name.nil? && expression.is_a?(Prism::LocalVariableReadNode) &&
3358
+ expression.name == block_name)
3359
+ end
3360
+
3336
3361
  # The candidate-frame memo path: consult the current run generation's bucket, and on a miss compute
3337
3362
  # and store a FINAL result. Reached only when `memo_candidate?` held (see `infer_user_method_return`).
3338
3363
  # The store gate (ADR-84 WD3): a result is stored when no transient-machinery event during the
@@ -4328,9 +4353,13 @@ module Rigor
4328
4353
 
4329
4354
  # Whether the call may run its block more than once, so a later run reads a captured binding an earlier
4330
4355
  # run rebound — the premise of laying the #587 (b) binding ({#captured_block_bindings}) under the block.
4331
- # {BlockRepetition.may_repeat?} holds the rule, which the statement pass shares (issue #1412).
4356
+ # {BlockRepetition.may_repeat?} holds the rule, which the statement pass shares (issue #1412). Issue #2301 — a
4357
+ # project callee that hands the block on ({ProjectYielder.hands_block_on?}) also counts: the block's value is what
4358
+ # the forward's target `yield`s, and that target may run it many times, so a captured local the block rebinds must
4359
+ # not read its entry binding.
4332
4360
  def block_may_repeat?(call_node, receiver_type)
4333
- BlockRepetition.may_repeat?(method_name: call_node.name, receiver_type: receiver_type, scope: scope)
4361
+ BlockRepetition.may_repeat?(method_name: call_node.name, receiver_type: receiver_type, scope: scope) ||
4362
+ ProjectYielder.hands_block_on?(call_node, receiver_type, scope)
4334
4363
  end
4335
4364
 
4336
4365
  EMPTY_BREAK_ARMS = [].freeze
@@ -4426,10 +4455,22 @@ module Rigor
4426
4455
  )
4427
4456
  type_block_body(block_arg, entry, captured: captured)
4428
4457
  when Prism::BlockArgumentNode
4429
- symbol_block_return_type(block_arg, expected)
4458
+ forwarded_block_return_type(block_arg) || symbol_block_return_type(block_arg, expected)
4430
4459
  end
4431
4460
  end
4432
4461
 
4462
+ # Issue #1157 — `&` / `&blk` hands the callee the block this method was itself called with, so the callee's
4463
+ # `yield` is worth what the CALLER's block returns: the value {.current_yield_value_type} already holds for
4464
+ # this frame (the one a `yield` written here reads). Without it a name resolver that passes its block on
4465
+ # (`def walk(x, &) = first_hit(x, &)`) read as `Dynamic[top]` through the forward and was skipped by
4466
+ # `sig-gen`. Nil when the argument is not a forwarded block or the call site passed no block.
4467
+ def forwarded_block_return_type(block_arg)
4468
+ frame = scope.match_frame
4469
+ return nil unless frame&.forwards_block?(block_arg)
4470
+
4471
+ ExpressionTyper.current_yield_value_type
4472
+ end
4473
+
4433
4474
  # The scope a block body is typed under: the surrounding scope plus the parameter bindings the receiving
4434
4475
  # method's signature implies, with the #587 (b) `captured` binding ({#repeating_captured_bindings}) laid
4435
4476
  # between the two, so the body reads what a captured local, instance variable, class variable or global
@@ -4524,6 +4565,14 @@ module Rigor
4524
4565
  # A failure in the re-answer falls back to the plain tail-only answer, for the reason
4525
4566
  # {#threaded_block_body_type} gives: a raise reaching `block_return_type_for` would report "no block".
4526
4567
  def tail_only_block_body_type(body, block_scope, captured)
4568
+ StatementEvaluator.provisional_returns { tail_only_block_body_type_unrecorded(body, block_scope, captured) }
4569
+ end
4570
+
4571
+ # Issue #2299 — the tail alone, typed from `block_scope`, is not the walk the method's returns are collected
4572
+ # from: a `return` in it reads a name the skipped prefix binds as unbound. Its records are provisional
4573
+ # ({StatementEvaluator.provisional_returns}): the statement walk's record of the same `return` replaces one,
4574
+ # and one stands only for a block the walk never enters (a block in expression position).
4575
+ def tail_only_block_body_type_unrecorded(body, block_scope, captured)
4527
4576
  return block_scope.type_of(body) unless block_body_threading_suppressed? && body.is_a?(Prism::StatementsNode)
4528
4577
 
4529
4578
  answered =
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../source/constant_path"
6
6
  require_relative "../source/node_children"
7
+ require_relative "ivar_writer_census"
7
8
 
8
9
  module Rigor
9
10
  module Inference
@@ -26,6 +27,8 @@ module Rigor
26
27
  # kept apart from `[:defines, …]`.
27
28
  # - `[:main_mixin, name]` — a module a top-level `include`, `prepend` or `extend` mixes into `Object` (or
28
29
  # `main`), as written. A module RBS does not know may carry any method, so it reaches every class.
30
+ # - the instance-variable writer entries of {IvarWriterCensus} (issue #1154): `[:ivar_set, name]`,
31
+ # `[:ivar_set_class, class_name]` and `[:own_write, class_name, name, path]`.
29
32
  #
30
33
  # The census is a `Set` of those frozen entries, merged by union across files, the project pre-pass and the
31
34
  # `pre_eval:` files. It is recorded inside descents that already visit every node ({Collector#visit}), never
@@ -74,12 +77,40 @@ module Rigor
74
77
  census.include?(DEFINES_ANY) || names.any? { |name| census.include?([:defines, name]) }
75
78
  end
76
79
 
80
+ # #1154 — the {IvarWriterCensus} entries of `census`, for the incremental diff and its readers.
81
+ IVAR_KINDS = %i[ivar_set ivar_set_class own_write].to_set.freeze
82
+
83
+ def ivar_set_entries(census) = census.select { |entry| IVAR_KINDS.include?(entry.first) }.to_set
84
+
85
+ # The negative-dependency key an {IvarWriterCensus} entry fires, the one `UnseenIvarWriters` records.
86
+ def ivar_key(entry)
87
+ case entry.first
88
+ when :ivar_set then "ivar_set:#{entry[1]}"
89
+ when :ivar_set_class then "ivar_set_class:#{entry[1]}"
90
+ else "own_write:#{entry[1]}##{entry[2]}"
91
+ end
92
+ end
93
+
77
94
  # Whether some refinement may add `write`.
78
95
  def refines_write?(census) = census.include?(REFINES_ANY) || census.include?(%i[refines write])
79
96
 
80
97
  # The module names the top-level mixins spell.
81
98
  def main_mixins(census) = census.filter_map { |entry| entry[1] if entry[0] == :main_mixin }
82
99
 
100
+ # Issue #2158 — the `global_census:` dependency key a census entry moves, without the kind prefix: `alias:$x`,
101
+ # `defines:write`, `refines:write`, `defines_any`, `refines_any`, and `main_mixin` for every top-level mixin: its
102
+ # reader asks about each, and one shared key is a deliberate precision trade-off (a mixin moving anywhere
103
+ # re-checks every write that read them). The rules that read the census record these keys
104
+ # (`CheckRules#record_global_census`), and `Analysis::Incremental.changed_global_census_keys` fires them for
105
+ # every entry that appeared in or vanished from a file's census.
106
+ def dependency_key(entry)
107
+ case entry
108
+ when DEFINES_ANY then "defines_any"
109
+ when REFINES_ANY then "refines_any"
110
+ else entry[0] == :main_mixin ? "main_mixin" : "#{entry[0]}:#{entry[1]}"
111
+ end
112
+ end
113
+
83
114
  # A standalone walk, for a file no existing descent visits (a `pre_eval:` entry).
84
115
  def scan(root)
85
116
  collector = Collector.new(InEffectRefinements.new(root))
@@ -107,12 +138,16 @@ module Rigor
107
138
  def initialize(in_effect)
108
139
  @census = Set.new
109
140
  @refine_ranges = []
141
+ @ivar_writers = IvarWriterCensus.new(@census)
110
142
  @in_effect = in_effect
111
143
  end
112
144
 
113
145
  # A node the collector cannot read must not fail the analysis of its file, nor of every file the pre-pass
114
146
  # feeds: the census then counts it as a definition of every name, which only declines a report.
115
- def visit(node, top_level:)
147
+ # `owner` is the qualified name of the class whose body the node sits in, for {IvarWriterCensus} (nil where
148
+ # the host walk does not track it, or no class owns an instance there).
149
+ def visit(node, top_level:, owner: nil)
150
+ @ivar_writers.visit(node, owner)
116
151
  case node
117
152
  when Prism::AliasGlobalVariableNode then visit_global_alias(node)
118
153
  when Prism::DefNode then record(node, node.name)