rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  require_relative "../type"
4
6
  require_relative "../source/node_children"
7
+ require_relative "content_join"
5
8
  require_relative "receiver_alias"
6
9
 
7
10
  module Rigor
@@ -91,6 +94,12 @@ module Rigor
91
94
  replace
92
95
  ].to_set.freeze
93
96
 
97
+ # Every method name {#widen_for_mutator} responds to — the one set a body scan asks "could an
98
+ # in-place call on this name change its binding?" against (issue #587: the block-return
99
+ # threading gate). Derived from the two tables above rather than spelled out, so the scan and
100
+ # the widening it predicts cannot drift apart.
101
+ SHAPE_MUTATORS = (ARRAY_MUTATORS | HASH_MUTATORS).freeze
102
+
94
103
  # Methods that return the receiver (or a shallow copy) and cannot mutate it. They must not
95
104
  # trigger widening or any other receiver-fact invalidation. The list is intentionally
96
105
  # narrow — only methods whose purity is unconditional and whose return value is the
@@ -111,17 +120,45 @@ module Rigor
111
120
  # `non-empty-hash`) AND whose call name is a known in-place mutator for that shape.
112
121
  # Returns `current_scope` unchanged otherwise.
113
122
  #
123
+ # `arg_types` carries the mutator call's argument types, already typed by the caller in the
124
+ # scope the arguments are evaluated in. They are what the widened carrier JOINS its added
125
+ # content evidence from (issue #560); an empty list means "no evidence", which reproduces the
126
+ # pre-join behaviour exactly.
127
+ NO_ARG_TYPES = [].freeze
128
+
114
129
  # @param call_node [Prism::CallNode]
115
130
  # @param current_scope [Rigor::Scope]
131
+ # @param arg_types [Array<Rigor::Type::Base>]
116
132
  # @return [Rigor::Scope]
117
- def widen_after_call(call_node:, current_scope:)
133
+ def widen_after_call(call_node:, current_scope:, arg_types: NO_ARG_TYPES)
118
134
  return current_scope if pure_self_returner?(call_node.name)
119
135
 
120
- receiver = call_node.receiver
136
+ widen_receiver_aliases(call_node.receiver, call_node.name, current_scope, arg_types: arg_types)
137
+ end
138
+
139
+ # True when `receiver` names at least one variable whose CURRENT binding is a literal-shape
140
+ # carrier — the only pre-state {#widen_for_mutator} joins into. Callers use it to skip typing a
141
+ # mutator's arguments when nothing will consume them: `buf << x` on a String and `arr << x` on an
142
+ # already-nominal Array are the common cases, and `Scope#type_of` memoizes nothing.
143
+ def joinable_receiver?(receiver, scope)
144
+ return false if receiver.nil?
145
+
146
+ ReceiverAlias.candidates(receiver).any? do |read|
147
+ current =
148
+ case read
149
+ when Prism::LocalVariableReadNode then scope.local(read.name)
150
+ when Prism::InstanceVariableReadNode then scope.ivar(read.name)
151
+ end
152
+ current.is_a?(Type::Tuple) || current.is_a?(Type::HashShape)
153
+ end
154
+ end
155
+
156
+ # Widens every variable `receiver` can evaluate to, against `method_name`'s mutator table.
157
+ def widen_receiver_aliases(receiver, method_name, current_scope, arg_types: NO_ARG_TYPES)
121
158
  return current_scope if receiver.nil?
122
159
 
123
160
  ReceiverAlias.candidates(receiver).reduce(current_scope) do |acc, read|
124
- widen_alias_read(call_node.name, read, acc)
161
+ widen_alias_read(method_name, read, acc, arg_types: arg_types)
125
162
  end
126
163
  end
127
164
 
@@ -179,55 +216,223 @@ module Rigor
179
216
  # name against the OUTER scope would hit an unrelated same-named binding.
180
217
  next acc if read.is_a?(Prism::LocalVariableReadNode) && read.depth.zero?
181
218
 
182
- widen_alias_read(call_node.name, read, acc)
219
+ # `values: :keep` — this is the block-capture path, and the slice-C content join that runs
220
+ # after it re-adds the appended values' types onto the widened seed, so the seed's own value
221
+ # pinning is still justified (`out = [0]; arr.each { out << x }` never rewrites slot 0). The
222
+ # straight-line paths have no such join, which is why their default is `:widen` (issue #560).
223
+ widen_alias_read(call_node.name, read, acc, values: :keep)
183
224
  end
184
225
  end
185
226
 
186
- def widen_alias_read(method_name, read, scope)
227
+ def widen_alias_read(method_name, read, scope, values: :widen, arg_types: NO_ARG_TYPES)
187
228
  case read
188
- when Prism::LocalVariableReadNode then widen_local(method_name, read.name, scope)
189
- when Prism::InstanceVariableReadNode then widen_ivar(method_name, read.name, scope)
229
+ when Prism::LocalVariableReadNode
230
+ widen_local(method_name, read.name, scope, values: values, arg_types: arg_types)
231
+ when Prism::InstanceVariableReadNode
232
+ widen_ivar(method_name, read.name, scope, values: values, arg_types: arg_types)
190
233
  else scope
191
234
  end
192
235
  end
193
236
 
194
- def widen_local(method_name, var_name, current_scope)
237
+ def widen_local(method_name, var_name, current_scope, values: :widen, arg_types: NO_ARG_TYPES)
195
238
  current = current_scope.local(var_name)
196
- widened = widen_for_mutator(current, method_name)
239
+ widened = widen_for_mutator(current, method_name, values: values, arg_types: arg_types)
197
240
  return current_scope if widened.nil?
198
241
 
199
242
  current_scope.with_local(var_name, widened)
200
243
  end
201
244
 
202
- def widen_ivar(method_name, var_name, current_scope)
245
+ def widen_ivar(method_name, var_name, current_scope, values: :widen, arg_types: NO_ARG_TYPES)
203
246
  current = current_scope.ivar(var_name)
204
- widened = widen_for_mutator(current, method_name)
247
+ widened = widen_for_mutator(current, method_name, values: values, arg_types: arg_types)
205
248
  return current_scope if widened.nil?
206
249
 
207
250
  current_scope.with_ivar(var_name, widened)
208
251
  end
209
252
 
253
+ # Mutators that can land a NEW value in an EXISTING slot, falsifying that slot's value pinning
254
+ # (`t[0] += 5` holds 6 where `Constant[1]` was — issue #560). Adders, removers, and reorderers
255
+ # leave every surviving slot's value intact, so their widening keeps the pinning; what an ADDER
256
+ # introduces is covered by the {#join_added_elements} join instead, which is why widening the
257
+ # SEED's pinning under `<<` (the shape haml's hand-written sigs pin) is not needed either.
258
+ VALUE_REWRITING_MUTATORS = %i[[]= fill map! collect! replace store merge! update transform_values!].to_set.freeze
259
+ private_constant :VALUE_REWRITING_MUTATORS
260
+
210
261
  # Returns the widened type for a binding whose receiver is about to be mutated by
211
262
  # `method_name`, or `nil` when no widening applies (binding is not a literal-shape
212
263
  # carrier, OR the method is not a mutator for that shape, OR the binding is already a
213
264
  # nominal — no precision to lose).
214
- def widen_for_mutator(type, method_name)
265
+ def widen_for_mutator(type, method_name, values: :widen, arg_types: NO_ARG_TYPES)
266
+ values = :keep unless VALUE_REWRITING_MUTATORS.include?(method_name)
267
+
215
268
  return nil if type.nil?
216
269
 
217
270
  case type
218
271
  when Type::Tuple
219
272
  return nil unless ARRAY_MUTATORS.include?(method_name)
220
273
 
221
- widen_tuple(type)
274
+ join_added_elements(widen_tuple(type, values: values), method_name, arg_types, type.elements)
222
275
  when Type::HashShape
223
276
  return nil unless HASH_MUTATORS.include?(method_name)
224
277
 
225
- widen_hash_shape(type)
278
+ join_added_pairs(widen_hash_shape(type, values: values), method_name, arg_types,
279
+ ContentJoin.hash_shape_key_values(type))
226
280
  when Type::Difference
227
281
  widen_difference(type, method_name)
228
282
  end
229
283
  end
230
284
 
285
+ # Joins the element evidence the mutator's own ARGUMENTS introduce into the already-widened
286
+ # Array carrier (issue #560). Without it the widening keeps only the SEED's elements, which
287
+ # under-covers precisely the value the mutation added: `u = [1, 2]; u.push(6)` left
288
+ # `Array[1 | 2]`, so `u.last == 6` constant-folded to false and fired a false always-falsey on
289
+ # correct code. {ContentJoin} owns the algebra — including the seed-admissibility gate that
290
+ # keeps a heterogeneous accumulator from growing a member a hand-written signature rejects.
291
+ #
292
+ # The added evidence is **value-pin widened** first, so the join never manufactures a NEW
293
+ # constant fold in place of the one it removes: `opts[:mode] = :fast` must not leave
294
+ # `Constant[:fast]` as the whole value bound and let a later `opts[:mode] == :fast` fold to
295
+ # `Constant[true]` — that would trade an always-falsey FP for an always-truthy one on the same
296
+ # shape. Widening it is also what makes the seed's class set the right admissibility test.
297
+ #
298
+ # Non-adders (removers, reorderers) introduce no element evidence and return the widened
299
+ # carrier untouched.
300
+ #
301
+ # `seed_elements` is the PRE-STATE literal's own element list, not the widened carrier's —
302
+ # `[]` and `[x]` on an untyped `x` widen to the SAME `Array[Dynamic[top]]`, and only the
303
+ # literal distinguishes "no evidence" from "one slot holds something unknown".
304
+ #
305
+ # **The join records what it saw; it never CLOSES the parameter.** That is
306
+ # {#gradual_floor}'s job, and the reason is the seam this method sits on rather than anything
307
+ # about Array or Hash.
308
+ def join_added_elements(widened, method_name, arg_types, seed_elements)
309
+ return widened unless ContentJoin::ARRAY_CONTENT_ADDERS.include?(method_name)
310
+
311
+ added = value_pin_widened(ContentJoin.array_added_elements(method_name, arg_types))
312
+ return widened if added.empty? && arg_types.empty?
313
+
314
+ admitted = added.empty? ? NO_ADDED_EVIDENCE : ContentJoin.admissible_evidence(seed_elements, added)
315
+ ContentJoin.join_array_content(widened, gradual_floor(admitted))
316
+ end
317
+
318
+ # Straight-line evidence is ALWAYS incomplete, so a straight-line join may never close the
319
+ # parameter it contributes to.
320
+ #
321
+ # This seam sees ONE store — the call being evaluated. The widening it feeds is a one-way
322
+ # door: it replaces the literal carrier with a `Nominal`, and {#widen_for_mutator} declines a
323
+ # `Nominal` outright, so the NEXT store is invisible. Closing over one sample of a growing
324
+ # population is therefore a wrong type, not an imprecise one:
325
+ #
326
+ # a = []
327
+ # a.push(1) # joins -> Array[Integer]
328
+ # a.push("s") # DECLINED -- pre-state is a Nominal now
329
+ # a.last.upcase # correct Ruby, prints "S"
330
+ #
331
+ # closed to `Array[Integer]` and drew `undefined method 'upcase' for Integer` on code that
332
+ # runs fine. `mail`'s `Message#to_yaml` is the same defect one carrier over — `hash = {}`,
333
+ # `hash['headers'] = {}`, then `hash['multipart_body'] = []` appended through the read, where
334
+ # the value parameter kept the first store's Hash arm and had dropped the Array arm.
335
+ #
336
+ # An earlier reading of this blamed the carrier — Array element unions are over positions and
337
+ # survive a missed store, Hash value unions are over keys and do not. The probe above refutes
338
+ # it: `a.last` selects a position exactly as `hash[k]` selects a key, and a dropped arm is a
339
+ # wrong answer either way. The real line is **how much the joining path saw**, and it puts the
340
+ # BLOCK path (ADR-56 slice C) on the other side: `content_writeback_block_captures` scans the
341
+ # whole body and joins every mutator call in it before writing back, so its evidence IS
342
+ # complete for that body and its precise join stays justified — `acc = []; xs.each { |x|
343
+ # acc.push(x) }` keeps reading `Array[Integer]`.
344
+ #
345
+ # `Array[Integer | Dynamic[top]]` still does everything issue #560 needs: a union carrying
346
+ # `Dynamic` cannot constant-fold, so every stale always-falsey the join was written to remove
347
+ # stays removed.
348
+ def gradual_floor(types)
349
+ types + [Type::Combinator.untyped]
350
+ end
351
+
352
+ # The Hash-side twin of {#join_added_elements}: `h[k] = v` / `h.store(k, v)` join the stored
353
+ # key and value into the widened `Hash[K, V]` carrier, each admitted against its OWN side's
354
+ # seed evidence (a foreign key does not make the value gradual, or the reverse).
355
+ def join_added_pairs(widened, method_name, arg_types, seed_pairs)
356
+ return widened unless ContentJoin::HASH_CONTENT_ADDERS.include?(method_name)
357
+ return widened if arg_types.size < 2
358
+
359
+ added = value_pin_widened([arg_types.first, arg_types.last])
360
+ return widened unless added.size == 2
361
+
362
+ seed_keys, seed_values = seed_pairs
363
+ key = admitted_union(seed_keys, added.first)
364
+ value = admitted_union(seed_values, added.last)
365
+ ContentJoin.join_hash_content(widened, [[key, value]])
366
+ end
367
+
368
+ # One carrier for a stored key or value. A `Hash` pair has a single slot per side, so the
369
+ # admissible list folds to a union rather than being truncated — and it takes the same
370
+ # {#gradual_floor} the element side takes, for the same reason: this seam saw one store.
371
+ def admitted_union(seed_members, added)
372
+ admitted = ContentJoin.admissible_evidence(seed_members, [added])
373
+ Type::Combinator.union(*gradual_floor(admitted))
374
+ end
375
+
376
+ # A content adder whose arguments the extractor could read NOTHING out of is still one store: the
377
+ # mutation ran, and what it appended is unknown. It therefore takes the ordinary one-store treatment
378
+ # — no admitted evidence, and {#gradual_floor}'s `Dynamic[top]` arm — rather than an answer of its
379
+ # own.
380
+ #
381
+ # An EMPTY `arg_types` is a different case and keeps the pre-join carrier untouched: no argument
382
+ # machinery reached this call at all. The ADR-56 block-capture path is the one that matters — it
383
+ # passes none because its slice-C join re-adds the appended types afterwards, and flooring there
384
+ # would strip seed pinning it keeps on purpose — but it is not the only producer. A genuinely
385
+ # zero-arg adder (`m.concat`) and `content_arg_types`' own rescue land here too; both are runtime
386
+ # no-ops or unanalyzable, so leaving the carrier alone is right for them as well.
387
+ #
388
+ # Getting this wrong once is instructive enough to keep. An earlier cut replaced the carrier with a
389
+ # CLOSED `Array[<seed base>]`, reasoning that the retained constants were falsified so the honest
390
+ # answer was their nominal base. The first half is right — `m = [1, 2]; m.concat(xs)` really can
391
+ # leave `6` at the end, and keeping `Array[1 | 2]` folds `m.last == 6` to false on correct code. The
392
+ # second half broke this file's own rule two methods down (a seam that sees one store may never
393
+ # CLOSE the parameter) and cost exactly what that rule protects: `Array[Symbol]` under haml's
394
+ # hand-written `-> Array[:multi]` brought back the #561 `def.return-type-mismatch`, and
395
+ # `m.last.upcase` after the concat drew `undefined method` on code that is correct when `xs` holds
396
+ # strings.
397
+ #
398
+ # `Array[1 | 2 | Dynamic[top]]` settles all three: the `Dynamic` arm stops the fold, the surviving
399
+ # pinning keeps the accepting form the signature gate documents (`Array[:multi | Dynamic[top]]`), and
400
+ # a union carrying `Dynamic` dispatches quietly.
401
+ #
402
+ # Known and accepted false negative: `m.concat([])` is a runtime no-op, so `m.last == 6` there really
403
+ # is always false, and the `Dynamic` arm suppresses a CORRECT always-falsey. Telling an empty literal
404
+ # argument from an unreadable one is possible in principle; a false negative on a no-op call is a far
405
+ # better trade than the two false positives above.
406
+ NO_ADDED_EVIDENCE = [].freeze
407
+
408
+ # Normalizes the types the mutator's arguments contribute, before they join.
409
+ #
410
+ # `widen_value_pinned` erases a constant's VALUE. A stored literal collection needs the same
411
+ # treatment for its literal SHAPE, and for the same reason one step removed: the program keeps
412
+ # a reference to what it stored and mutates it through the slot —
413
+ #
414
+ # params[:f] ||= []
415
+ # params[:f] << :status
416
+ #
417
+ # is Redmine's `Query#as_params` idiom, six times over. The `<<` mutates the nested array, and
418
+ # nothing writes that back through the outer Hash's value parameter, so joining the literal
419
+ # `Tuple[]` would pin `Hash[Symbol, []]` on a hash whose slot really holds `[:status]` — a
420
+ # WRONG precise type, and `params[:f].empty?` would fold to `true` off it. That is the same
421
+ # class of stale fold this whole change exists to remove, so the shape goes with the value:
422
+ # `[]` joins as `Array[untyped]`, `{}` as `Hash[untyped, untyped]`. Both are true of the slot
423
+ # no matter what the program does to the object afterwards.
424
+ def value_pin_widened(types)
425
+ types.compact.map { |type| shape_erased(Type::Combinator.widen_value_pinned(type)) }
426
+ end
427
+
428
+ def shape_erased(type)
429
+ case type
430
+ when Type::Tuple then widen_tuple(type, values: :widen)
431
+ when Type::HashShape then widen_hash_shape(type, values: :widen)
432
+ else type
433
+ end
434
+ end
435
+
231
436
  # `non-empty-array[T]` / `non-empty-hash[K, V]` → the bare base nominal. These refinement
232
437
  # carriers are what `empty?` / `any?` narrowing writes (ADR-47 §4-4), and they are just as
233
438
  # invalidated by an in-place mutator as a `Tuple` is: `arr.clear` makes `arr` empty, so a
@@ -250,24 +455,30 @@ module Rigor
250
455
  end
251
456
  end
252
457
 
253
- # `Tuple[A, B, C]` → `Nominal[Array, [union(A, B, C)]]`. An empty tuple has no element
254
- # evidence, so the widened form carries `untyped` element bound matches the
255
- # `tuple_to_array` widening already used by `BlockFolding`.
256
- def widen_tuple(tuple)
458
+ # `Tuple[A, B, C]` → `Nominal[Array, [union(A, B, C)]]`; under `values: :widen`, each element's
459
+ # VALUE pinning widens to its class nominal (`1 | 2` `Integer`): a slot-rewriting mutator
460
+ # falsifies values along with the shape — `t = [1, 2]; t[0] += 5` holds `6` at slot 0, so a
461
+ # surviving `Array[1 | 2]` feeds the constant-comparison fold and fires a false always-falsey
462
+ # on `t[0] == 6` (issue #560). An empty tuple has no element evidence, so the widened form
463
+ # carries `untyped` element bound — matches `BlockFolding`'s `tuple_to_array` widening.
464
+ def widen_tuple(tuple, values: :widen)
257
465
  element_type =
258
466
  if tuple.elements.empty?
259
467
  Type::Combinator.untyped
260
- elsif tuple.elements.size == 1
261
- tuple.elements.first
262
468
  else
263
- Type::Combinator.union(*tuple.elements)
469
+ elements = tuple.elements
470
+ elements = elements.map { |e| Type::Combinator.widen_value_pinned(e) } if values == :widen
471
+ elements.size == 1 ? elements.first : Type::Combinator.union(*elements)
264
472
  end
265
473
  Type::Combinator.nominal_of("Array", type_args: [element_type])
266
474
  end
267
475
 
268
476
  # `HashShape` (closed or open) → `Nominal[Hash, [Kunion, Vunion]]`. Empty / extra-keys-only
269
- # shapes degrade to a fully-untyped Hash.
270
- def widen_hash_shape(shape)
477
+ # shapes degrade to a fully-untyped Hash. Values widen their pinning the same way
478
+ # {#widen_tuple}'s elements do (issue #560): `opts = {headers: false}` then
479
+ # `opts[:encoding] = v` must not keep `false` as the whole value bound — redmine's
480
+ # `import.rb:274` read the stored key back through it and drew a false always-falsey.
481
+ def widen_hash_shape(shape, values: :widen)
271
482
  if shape.pairs.empty?
272
483
  return Type::Combinator.nominal_of("Hash",
273
484
  type_args: [Type::Combinator.untyped,
@@ -275,160 +486,19 @@ module Rigor
275
486
  end
276
487
 
277
488
  key_type = key_union_for(shape.pairs.keys)
278
- value_type = Type::Combinator.union(*shape.pairs.values)
279
- Type::Combinator.nominal_of("Hash", type_args: [key_type, value_type])
489
+ value_types = shape.pairs.values
490
+ value_types = value_types.map { |v| Type::Combinator.widen_value_pinned(v) } if values == :widen
491
+ Type::Combinator.nominal_of("Hash", type_args: [key_type, Type::Combinator.union(*value_types)])
280
492
  end
281
493
 
282
- # Maps the literal Ruby key set to a union of the corresponding type carriers. Symbol / String /
283
- # Integer / Float keys widen to their class nominal; the `true` / `false` / `nil` singleton keys
284
- # keep their constant carrier (the constant IS the class's whole value set, and `nil` reads better
285
- # than `NilClass` in a widened `Hash[K, V]`). We deliberately do NOT fold the widenable kinds to a
286
- # `Constant<:k1> | Constant<:k2>` union — that would be a precision improvement that complicates
287
- # the widening contract; the goal here is to LOSE precision, not to record a new fact set.
288
- def key_union_for(keys)
289
- carriers = keys.map { |k| key_widening_carrier(k) }.uniq
290
- carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
291
- end
292
-
293
- def key_widening_carrier(key)
294
- case key
295
- when true, false, nil then Type::Combinator.constant_of(key)
296
- else Type::Combinator.nominal_of(key.class.name)
297
- end
298
- end
299
-
300
- # ----------------------------------------------------------------
301
- # ADR-56 slice C — receiver-content element-type JOIN.
494
+ # ADR-56 slice C's content JOIN the other half of this module lives in {ContentJoin}, which
495
+ # both the block-capture path and the straight-line path above share.
302
496
  #
303
- # `widen_after_block` above forgets a literal-shape carrier's arity when a captured local
304
- # is content-mutated inside a block, but it keeps only the SEED's element types an
305
- # unsound under-approximation for a non-empty seed (`out = [0]; arr.each { |x| out << x
306
- # }` types `Array[0]` while the runtime array is `[0, 1, 2, 3]`). Slice C joins the
307
- # appended/stored element (and key/value) types INTO the continuation collection's
308
- # parameter, so the result is `Array[0 | Integer]` rather than `Array[0]`.
309
- #
310
- # Array content-mutators that append/store ELEMENTS. The appended element type is the
311
- # call's argument type(s); `[]=`'s value is its LAST argument (the keys precede it).
312
- # Subset of `ARRAY_MUTATORS`: only the element-INTRODUCING methods (removers / reorderers
313
- # add no new element evidence and are already covered by the arity-forget).
314
- ARRAY_CONTENT_ADDERS = %i[
315
- << push append prepend unshift concat insert []= fill replace
316
- ].to_set.freeze
317
-
318
- # Hash content-mutators that store a key→value pair. For `[]=` / `store` the key is the
319
- # first argument and the value the last.
320
- HASH_CONTENT_ADDERS = %i[[]= store].to_set.freeze
321
-
322
- # String content-mutators that append to the buffer. String carries no element parameter,
323
- # so these contribute nothing to a join — they are listed so the orchestrator recognises
324
- # them as content mutators (the binding already widens to `String` via normal typing);
325
- # the join helpers below short-circuit on a non-collection pre-state.
326
- STRING_CONTENT_ADDERS = %i[<< concat prepend insert replace].to_set.freeze
327
-
328
- # Every method name that mutates a captured local's CONTENT — the union the orchestrator
329
- # scans the block body for.
330
- CONTENT_ADDERS = (ARRAY_CONTENT_ADDERS | HASH_CONTENT_ADDERS | STRING_CONTENT_ADDERS).freeze
331
-
332
- # The element types a single content-mutator call introduces into an Array, given the
333
- # per-argument types (already typed in the block body scope). `concat`/`replace` take
334
- # collection arguments, so their element evidence is the arguments' OWN element types
335
- # unioned; the rest append the argument values directly. Returns `[]` when no element
336
- # evidence (e.g. a `<<` with no resolvable arg).
337
- def array_added_elements(method_name, arg_types)
338
- return [] if arg_types.empty?
339
-
340
- case method_name
341
- when :concat, :replace
342
- arg_types.flat_map { |t| collection_element_types(t) }
343
- when :insert
344
- # `insert(index, *objs)` — first arg is the position.
345
- arg_types.drop(1)
346
- when :[]=
347
- # `arr[i] = v` / `arr[i, n] = v` — value is the last argument.
348
- [arg_types.last]
349
- when :fill
350
- # `fill(value)` — only the no-block single-value form adds a
351
- # concrete element; block / range forms are conservatively
352
- # ignored (the arity-forget already widened the binding).
353
- arg_types.size == 1 ? arg_types : []
354
- else # << push append prepend unshift
355
- arg_types
356
- end
357
- end
358
-
359
- # Builds the continuation Array type from the pre-state binding and the appended element
360
- # types. The floor is `Array[Dynamic[top]]` (the sound empty-seed behaviour) when there is
361
- # no element evidence at all.
362
- def join_array_content(pre_state, added_elements)
363
- seed_elements = collection_element_types(pre_state)
364
- added = added_elements.compact
365
- # The empty-seed floor element is `Dynamic[top]` (no element evidence). When real
366
- # appended evidence exists that floor carries nothing, so drop it — an empty accumulator
367
- # built by `out << x*2` reads `Array[Integer]`, not `Array[Integer | Dynamic[top]]`.
368
- seed_elements = drop_dynamic(seed_elements) unless added.empty?
369
- elements = seed_elements + added
370
- return Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped]) if elements.empty?
371
-
372
- Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.union(*elements)])
373
- end
374
-
375
- # Builds the continuation Hash type from the pre-state binding and a list of `[key_type,
376
- # value_type]` pairs stored by `[]=` / `store`.
377
- def join_hash_content(pre_state, added_pairs)
378
- seed_keys, seed_values = hash_shape_key_values(pre_state)
379
- added_keys = added_pairs.map(&:first).compact
380
- added_values = added_pairs.map(&:last).compact
381
- seed_keys = drop_dynamic(seed_keys) unless added_keys.empty?
382
- seed_values = drop_dynamic(seed_values) unless added_values.empty?
383
- keys = seed_keys + added_keys
384
- values = seed_values + added_values
385
- key_t = keys.empty? ? Type::Combinator.untyped : Type::Combinator.union(*keys)
386
- value_t = values.empty? ? Type::Combinator.untyped : Type::Combinator.union(*values)
387
- Type::Combinator.nominal_of("Hash", type_args: [key_t, value_t])
388
- end
389
-
390
- # Drops `Dynamic` (incl. `untyped`) constituents from a type list.
391
- def drop_dynamic(types)
392
- types.grep_v(Type::Dynamic)
393
- end
394
-
395
- # Element types carried by a collection binding, regardless of which carrier holds them: a
396
- # `Tuple` lists them, a `Nominal[Array, [E]]` has one element param, a bare `Array` /
397
- # anything else yields none.
398
- def collection_element_types(type)
399
- case type
400
- when Type::Tuple
401
- type.elements
402
- when Type::Nominal
403
- type.class_name == "Array" ? type.type_args : []
404
- when Type::Union
405
- # A loop's single-pass join can union the widened collection with its un-widened
406
- # literal seed (`Array[0] | [0]`); pull element evidence from every Array-ish member.
407
- type.members.flat_map { |m| collection_element_types(m) }
408
- else
409
- []
410
- end
411
- end
412
-
413
- # `[keys, values]` evidence from a Hash-ish pre-state binding — a `HashShape` (literal
414
- # pairs) or a `Nominal[Hash, [K, V]]`.
415
- def hash_shape_key_values(type)
416
- case type
417
- when Type::HashShape
418
- return [[], []] if type.pairs.empty?
419
-
420
- [[key_union_for(type.pairs.keys)], type.pairs.values]
421
- when Type::Nominal
422
- type.class_name == "Hash" && type.type_args.size == 2 ? [[type.type_args[0]], [type.type_args[1]]] : [[], []]
423
- when Type::Union
424
- type.members.each_with_object([[], []]) do |m, (ks, vs)|
425
- mk, mv = hash_shape_key_values(m)
426
- ks.concat(mk)
427
- vs.concat(mv)
428
- end
429
- else
430
- [[], []]
431
- end
497
+ # `key_union_for` is delegated rather than duplicated: {#widen_hash_shape} and
498
+ # `ContentJoin.hash_shape_key_values` must map a literal key set the SAME way, or a widened
499
+ # carrier and the join that reads it back disagree about the key parameter.
500
+ def key_union_for(keys)
501
+ ContentJoin.key_union_for(keys)
432
502
  end
433
503
  end
434
504
  end