rigortype 0.4.7 → 0.4.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 (268) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +29 -0
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/docs/handbook/01-getting-started.md +1 -1
  8. data/docs/handbook/11-sig-gen.md +35 -0
  9. data/docs/install.md +20 -0
  10. data/docs/manual/01-installation.md +67 -26
  11. data/docs/manual/02-cli-reference.md +307 -91
  12. data/docs/manual/03-configuration.md +41 -3
  13. data/docs/manual/04-diagnostics.md +5 -4
  14. data/docs/manual/08-skills.md +11 -0
  15. data/docs/manual/09-editor-integration.md +8 -0
  16. data/docs/manual/14-rails-quickstart.md +1 -1
  17. data/docs/manual/19-effect-labels.md +3 -4
  18. data/docs/manual/plugins/rigor-actionpack.md +15 -6
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +3 -0
  20. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  21. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  22. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  23. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +8 -0
  24. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +13 -2
  25. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -0
  26. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  27. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +40 -11
  28. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  29. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  30. data/lib/rigor/analysis/check_rules/rule_ids.rb +8 -1
  31. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  32. data/lib/rigor/analysis/check_rules.rb +598 -99
  33. data/lib/rigor/analysis/crash_signature.rb +43 -1
  34. data/lib/rigor/analysis/dependency_recorder.rb +233 -76
  35. data/lib/rigor/analysis/incremental.rb +11 -1
  36. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -1
  37. data/lib/rigor/analysis/incremental_session.rb +279 -37
  38. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  39. data/lib/rigor/analysis/rule_catalog.rb +37 -1
  40. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  41. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  42. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +4 -0
  43. data/lib/rigor/analysis/runner/declaration_position.rb +7 -7
  44. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +72 -20
  45. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +3 -8
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +26 -5
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  48. data/lib/rigor/analysis/runner.rb +222 -39
  49. data/lib/rigor/analysis/self_call_resolution_recorder.rb +61 -22
  50. data/lib/rigor/analysis/worker_session.rb +42 -41
  51. data/lib/rigor/bleeding_edge.rb +19 -4
  52. data/lib/rigor/cache/file_digest.rb +39 -31
  53. data/lib/rigor/cache/incremental_snapshot.rb +66 -7
  54. data/lib/rigor/cache/rbs_descriptor.rb +37 -2
  55. data/lib/rigor/cache/rbs_environment.rb +5 -1
  56. data/lib/rigor/cli/baseline_command.rb +15 -2
  57. data/lib/rigor/cli/check_command.rb +4 -2
  58. data/lib/rigor/cli/effects_snapshot_command.rb +8 -1
  59. data/lib/rigor/cli/lens_command.rb +17 -2
  60. data/lib/rigor/cli/sig_gen_command.rb +8 -0
  61. data/lib/rigor/cli/skill_describe.rb +1 -0
  62. data/lib/rigor/cli.rb +5 -2
  63. data/lib/rigor/config_audit.rb +32 -18
  64. data/lib/rigor/configuration/severity_profile.rb +9 -0
  65. data/lib/rigor/configuration.rb +32 -5
  66. data/lib/rigor/effects/collector.rb +53 -21
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +63 -28
  68. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  69. data/lib/rigor/environment/default_libraries.rb +17 -0
  70. data/lib/rigor/environment/deferred_signature_plan.rb +104 -6
  71. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  72. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  73. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  74. data/lib/rigor/environment/rbs_hierarchy.rb +6 -0
  75. data/lib/rigor/environment/rbs_loader.rb +312 -63
  76. data/lib/rigor/environment.rb +131 -35
  77. data/lib/rigor/inference/acceptance.rb +50 -15
  78. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  79. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  80. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  81. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  82. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  83. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  84. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  85. data/lib/rigor/inference/block_run_count.rb +9 -2
  86. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  87. data/lib/rigor/inference/callee_mutation_chain.rb +1 -1
  88. data/lib/rigor/inference/captured_locals.rb +2 -0
  89. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  90. data/lib/rigor/inference/clause_widening.rb +1 -1
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  92. data/lib/rigor/inference/construction_escape.rb +56 -15
  93. data/lib/rigor/inference/declaration_walk.rb +4 -0
  94. data/lib/rigor/inference/def_node_resolver.rb +32 -14
  95. data/lib/rigor/inference/def_return_typer.rb +1 -1
  96. data/lib/rigor/inference/definer_resolution.rb +15 -7
  97. data/lib/rigor/inference/definers_index.rb +72 -16
  98. data/lib/rigor/inference/error_info.rb +16 -0
  99. data/lib/rigor/inference/expression_typer.rb +1459 -287
  100. data/lib/rigor/inference/flow_tracer.rb +31 -14
  101. data/lib/rigor/inference/fork_map.rb +3 -0
  102. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  103. data/lib/rigor/inference/global_write_census.rb +8 -6
  104. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  105. data/lib/rigor/inference/indexed_narrowing.rb +57 -8
  106. data/lib/rigor/inference/ivar_writer_census.rb +106 -11
  107. data/lib/rigor/inference/key_presence_guard.rb +51 -25
  108. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  109. data/lib/rigor/inference/last_line.rb +5 -2
  110. data/lib/rigor/inference/loop_value_record.rb +63 -15
  111. data/lib/rigor/inference/match_rebinding/frame.rb +11 -0
  112. data/lib/rigor/inference/match_rebinding/yielding_match.rb +74 -18
  113. data/lib/rigor/inference/match_rebinding.rb +21 -8
  114. data/lib/rigor/inference/method_dispatcher/block_folding.rb +43 -29
  115. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  116. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  117. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  118. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  119. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  120. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  121. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +12 -0
  122. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +409 -67
  123. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +1 -1
  124. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +384 -64
  125. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  126. data/lib/rigor/inference/method_dispatcher.rb +66 -18
  127. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  128. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  129. data/lib/rigor/inference/module_function_state.rb +13 -2
  130. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  131. data/lib/rigor/inference/mutation_widening.rb +44 -4
  132. data/lib/rigor/inference/narrowing.rb +201 -27
  133. data/lib/rigor/inference/operand_effects.rb +43 -2
  134. data/lib/rigor/inference/optimistic_origin.rb +133 -8
  135. data/lib/rigor/inference/origin_lookup.rb +1 -1
  136. data/lib/rigor/inference/precision_scanner.rb +1 -1
  137. data/lib/rigor/inference/project_method_ownership.rb +28 -10
  138. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  139. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  140. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  141. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  142. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  143. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  144. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +56 -18
  145. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -17
  146. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  147. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  148. data/lib/rigor/inference/remover_holder_scan.rb +76 -22
  149. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  150. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +12 -11
  151. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +188 -28
  152. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  153. data/lib/rigor/inference/scope_indexer.rb +2079 -183
  154. data/lib/rigor/inference/self_call_ivar_writes.rb +26 -7
  155. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  156. data/lib/rigor/inference/singleton_hook_decline.rb +41 -6
  157. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  158. data/lib/rigor/inference/statement_evaluator.rb +970 -250
  159. data/lib/rigor/inference/string_mutation.rb +14 -0
  160. data/lib/rigor/inference/synthetic_method_scanner.rb +35 -11
  161. data/lib/rigor/inference/toplevel_def_census.rb +14 -6
  162. data/lib/rigor/inference/unknown_store_widening.rb +1 -1
  163. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  164. data/lib/rigor/inference/void_tail_summary.rb +29 -10
  165. data/lib/rigor/lens/claims/comparison.rb +78 -0
  166. data/lib/rigor/lens/claims/request.rb +131 -0
  167. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  168. data/lib/rigor/lens/claims.rb +208 -0
  169. data/lib/rigor/lens/contract_reader.rb +5 -0
  170. data/lib/rigor/lens/document.rb +1 -1
  171. data/lib/rigor/lens/file_reader.rb +21 -8
  172. data/lib/rigor/lens/run.rb +8 -0
  173. data/lib/rigor/lens.rb +1 -1
  174. data/lib/rigor/library_probe.rb +68 -0
  175. data/lib/rigor/memo_audit.rb +282 -0
  176. data/lib/rigor/plugin/base.rb +15 -2
  177. data/lib/rigor/plugin/io_boundary.rb +25 -3
  178. data/lib/rigor/plugin/manifest.rb +7 -3
  179. data/lib/rigor/plugin/registry.rb +13 -2
  180. data/lib/rigor/plugin/trust_policy.rb +46 -0
  181. data/lib/rigor/protection/mutation_cache.rb +4 -1
  182. data/lib/rigor/reflection/constant_ancestors.rb +79 -22
  183. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  184. data/lib/rigor/reflection/declarations.rb +58 -0
  185. data/lib/rigor/reflection/project_method.rb +177 -0
  186. data/lib/rigor/reflection.rb +2 -0
  187. data/lib/rigor/rooted_glob.rb +18 -1
  188. data/lib/rigor/run_scoped_slots.rb +20 -20
  189. data/lib/rigor/scope/discovery_index.rb +149 -2
  190. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  191. data/lib/rigor/scope/resolution_chain.rb +900 -55
  192. data/lib/rigor/scope.rb +307 -34
  193. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  194. data/lib/rigor/sig_gen/classification.rb +14 -1
  195. data/lib/rigor/sig_gen/generator.rb +222 -13
  196. data/lib/rigor/sig_gen/method_candidate.rb +14 -0
  197. data/lib/rigor/sig_gen/observation_collector.rb +11 -2
  198. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  199. data/lib/rigor/sig_gen/skip_reason_catalog.rb +47 -0
  200. data/lib/rigor/sig_gen/writer.rb +21 -0
  201. data/lib/rigor/sig_gen.rb +1 -0
  202. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  203. data/lib/rigor/source/node_children.rb +13 -0
  204. data/lib/rigor/type/combinator.rb +164 -8
  205. data/lib/rigor/type/interface.rb +60 -0
  206. data/lib/rigor/type.rb +1 -0
  207. data/lib/rigor/type_of_memo/serving.rb +99 -0
  208. data/lib/rigor/type_of_memo/stats.rb +43 -0
  209. data/lib/rigor/type_of_memo.rb +337 -0
  210. data/lib/rigor/typing_state.rb +1094 -0
  211. data/lib/rigor/version.rb +1 -1
  212. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +2 -2
  213. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +57 -25
  214. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  215. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  216. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  217. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  218. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  219. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +130 -30
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -5
  221. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  222. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +49 -13
  223. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  224. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +4 -2
  225. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +13 -5
  226. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +29 -0
  227. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  228. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  229. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -2
  230. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  231. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +6 -0
  232. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +199 -1
  233. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +3 -2
  234. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +4 -4
  235. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +209 -2
  236. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  237. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  238. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +80 -3
  239. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  240. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +1 -6
  241. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  242. data/sig/rigor/analysis/fact_store.rbs +2 -3
  243. data/sig/rigor/cache/descriptor.rbs +14 -0
  244. data/sig/rigor/environment.rbs +4 -1
  245. data/sig/rigor/plugin/base.rbs +53 -19
  246. data/sig/rigor/plugin/interfaces.rbs +51 -0
  247. data/sig/rigor/plugin/io_boundary.rbs +2 -3
  248. data/sig/rigor/plugin/node_context.rbs +21 -0
  249. data/sig/rigor/reflection.rbs +37 -0
  250. data/sig/rigor/scope.rbs +22 -3
  251. data/sig/rigor/trinary.rbs +1 -0
  252. data/sig/rigor/type.rbs +9 -3
  253. data/sig/rigor.rbs +3 -2
  254. data/skills/rigor-ask/SKILL.md +1 -0
  255. data/skills/rigor-project-init/references/01-detect.md +1 -0
  256. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +5 -1
  257. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  258. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  259. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  260. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  261. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  262. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  263. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  264. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  265. data/skills/rigor-type-oracle/SKILL.md +26 -1
  266. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  267. metadata +48 -2
  268. data/lib/rigor/inference/memo_audit.rb +0 -55
@@ -226,6 +226,8 @@ module Rigor
226
226
  # arguments nothing will read.
227
227
  # A precise `Hash` counts too when its binding carries the `:synthesised` construction fact (issue #2555): the
228
228
  # re-join grows it from these arguments.
229
+ # An intersection counts when a member does: `non-empty-lowercase-string`'s witness half reads `tr!`'s
230
+ # replacement ({#widen_string_intersection}).
229
231
  def shape_carrier?(type, synthesised: false)
230
232
  case type
231
233
  when Type::Tuple, Type::HashShape then true
@@ -237,7 +239,7 @@ module Rigor
237
239
  MutationRejoin.regrowable_carrier?(type) || MutationRejoin.precise_array?(type) ||
238
240
  SetMutation.regrowable?(type) || (synthesised && MutationRejoin.synthesised_carrier?(type))
239
241
  when Type::Difference then type.removes_empty_witness?
240
- when Type::Union then type.members.any? { |m| shape_carrier?(m, synthesised:) }
242
+ when Type::Union, Type::Intersection then type.members.any? { |m| shape_carrier?(m, synthesised:) }
241
243
  else false
242
244
  end
243
245
  end
@@ -304,6 +306,10 @@ module Rigor
304
306
  receiver = call_node.receiver
305
307
  return scope if receiver.nil?
306
308
 
309
+ # Issue #2335 — a block parameter, an element read or any other receiver that may be an outer local's object.
310
+ if AliasMutationFloor.candidate?(call_node)
311
+ scope = AliasMutationFloor.floor_after_call(call_node, scope) { captured_element_root(receiver) }
312
+ end
307
313
  widened = widen_outer_element_read(call_node, scope)
308
314
  return widened if widened
309
315
 
@@ -321,6 +327,14 @@ module Rigor
321
327
  end
322
328
  end
323
329
 
330
+ # The outer root of an element read in a block body, whose element {#widen_outer_element_read} widens. (A captured
331
+ # local the receiver names takes the capture path's widening and the block content join after it, which a floor
332
+ # here would not change.)
333
+ def captured_element_root(receiver)
334
+ root = ElementReadWidening.element_read_path(receiver)&.first
335
+ root && !ReceiverAlias.block_local?(root) ? [root.name] : nil
336
+ end
337
+
324
338
  # Issue #2287 — a mutator on an element read rooted at a captured local (`[0].each { |i| a[i] << 2 }`,
325
339
  # `loop { a[0] << 2; break }`) mutates an element the outer binding still carries, and names no variable
326
340
  # {ReceiverAlias.candidates} could widen. The element at that path widens in the outer scope as the
@@ -431,8 +445,7 @@ module Rigor
431
445
  join_added_pairs(widen_hash_shape(type, values: values), method_name, arg_types,
432
446
  ContentJoin.hash_shape_key_values(type))
433
447
  when Type::Constant then StringMutation.widen_constant(type, method_name)
434
- when Type::Difference
435
- widen_difference(type, method_name, arg_types: arg_types)
448
+ when Type::Difference, Type::Refined, Type::Intersection then widen_refinement(type, method_name, arg_types)
436
449
  when Type::Union
437
450
  widen_union(type, method_name, values:, arg_types:, unread_arguments:, rewritten_element:, synthesised:)
438
451
  end
@@ -477,7 +490,8 @@ module Rigor
477
490
  (ARRAY_MUTATORS.include?(method_name) && ContentJoin.array_residue(member).empty?) ||
478
491
  ((HASH_MUTATORS.include?(method_name) || HashLookupMutation::MUTATORS.include?(method_name)) &&
479
492
  ContentJoin.hash_residue(member).empty?) ||
480
- (StringMutation::MUTATORS.include?(method_name) && StringMutation.constant?(member)) ||
493
+ (StringMutation::MUTATORS.include?(method_name) &&
494
+ (StringMutation.constant?(member) || StringMutation.refined?(member))) ||
481
495
  (SetMutation::MUTATORS.include?(method_name) && SetMutation.carrier?(member))
482
496
  end
483
497
 
@@ -660,6 +674,32 @@ module Rigor
660
674
  end
661
675
  end
662
676
 
677
+ # An intersection of String refinements carrying a predicate one (`non-empty-lowercase-string`) widens member
678
+ # by member: the `Refined` member to `String` ({StringMutation.widen_refined}), a `non-empty-string` member on
679
+ # {#widen_difference}'s witness terms. A member that widened to the bare `String` is implied by the others and
680
+ # drops out, so `s.upcase!` reads `non-empty-string` rather than `non-empty-string & String`. `nil` when the
681
+ # intersection carries no String predicate refinement, or nothing widened.
682
+ def widen_string_intersection(intersection, method_name, arg_types)
683
+ return nil unless intersection.members.any? { |m| StringMutation.refined?(m) }
684
+
685
+ members = intersection.members.map { |m| widen_refinement(m, method_name, arg_types) || m }
686
+ base = Type::Combinator.nominal_of("String")
687
+ kept = members.reject { |m| m == base }
688
+ widened = kept.empty? ? base : Type::Combinator.intersection(*kept)
689
+ widened == intersection ? nil : widened
690
+ end
691
+
692
+ # The refinement carriers {#widen_carrier} hands on: the empty witness ({#widen_difference}), a String predicate
693
+ # ({StringMutation.widen_refined}) and an intersection of them ({#widen_string_intersection}). `nil` when nothing
694
+ # widened.
695
+ def widen_refinement(type, method_name, arg_types)
696
+ case type
697
+ when Type::Difference then widen_difference(type, method_name, arg_types:)
698
+ when Type::Refined then StringMutation.widen_refined(type, method_name)
699
+ when Type::Intersection then widen_string_intersection(type, method_name, arg_types)
700
+ end
701
+ end
702
+
663
703
  # `Tuple[A, B, C]` → `Nominal[Array, [union(A, B, C)]]`; under `values: :widen`, each element's
664
704
  # VALUE pinning widens to its class nominal (`1 | 2` → `Integer`): a slot-rewriting mutator
665
705
  # falsifies values along with the shape — `t = [1, 2]; t[0] += 5` holds `6` at slot 0, so a
@@ -13,6 +13,7 @@ require_relative "guard_rebinding"
13
13
  require_relative "intervening_ivar_reset"
14
14
  require_relative "key_presence_guard"
15
15
  require_relative "last_line"
16
+ require_relative "match_rebinding"
16
17
  require_relative "operand_effects"
17
18
  require_relative "optimistic_origin"
18
19
  require_relative "project_method_ownership"
@@ -532,7 +533,17 @@ module Rigor
532
533
  lexical_class_name(condition, scope) || case_equality_target_class(condition)
533
534
  end
534
535
  body_scope = record_bot_guard(narrow_guarded_slot(body_scope, slot, truthy_type), slot, truthy_type, classes)
535
- [body_scope, narrow_guarded_slot(scope, slot, falsey_type)]
536
+ marked = unwrap_case_subject(subject).is_a?(Prism::LocalVariableReadNode) && scope.gradual_narrowing?(slot.name)
537
+ # A marked subject keeps its mark under any `when` (`when "r"` narrows it without writing it); an unmarked one
538
+ # is stamped only under class conditions.
539
+ if marked || classes.none?(&:nil?)
540
+ static = gradual_union_slot?(slot) ? gradual_static_part(slot.current) : nil
541
+ static &&= case_when_types(scope, static, conditions).first
542
+ body_scope = mark_gradual_narrowing(body_scope, slot, truthy_type, marked: marked, narrowed_static: static)
543
+ end
544
+ falsey_scope = narrow_guarded_slot(scope, slot, falsey_type)
545
+ falsey_scope = falsey_scope.with_gradual_narrowing_mark(slot.name) if marked
546
+ [body_scope, falsey_scope]
536
547
  end
537
548
 
538
549
  CASE_SUBJECT_KINDS = %i[local ivar global constant].freeze
@@ -588,12 +599,21 @@ module Rigor
588
599
  end
589
600
  end
590
601
 
591
- # When the clause has exactly one `RegularExpressionNode` literal condition, narrow the
592
- # match-data globals on the body edge (same rule as `analyse_regex_match_predicate`'s
593
- # truthy edge). With multiple regex conditions (`when /a/, /b/`) the body is reachable
594
- # through any of them, so only `$~`/`$&` are safely non-nil; numbered groups whose
595
- # presence differs per alternative stay `String | nil`. With no regex condition the entry
596
- # scope passes through unchanged.
602
+ # When every condition is a `RegularExpressionNode` literal, the body edge narrows the
603
+ # match-data globals as `analyse_regex_match_predicate`'s truthy edge does. With multiple
604
+ # regex conditions (`when /a/, /b/`) the body is reachable through any of them, so only
605
+ # `$~`/`$&` are safely non-nil; numbered groups whose presence differs per alternative stay
606
+ # `String | nil`. With no regex condition the entry scope passes through unchanged.
607
+ #
608
+ # Issue #1474 — a clause that mixes a regex literal with another condition (`when /(a)/,
609
+ # "lit"`) is also entered through the other conditions: each regex literal before them ran
610
+ # its `===` and failed, leaving the globals nil, and a condition before any regex leaves
611
+ # them as they were. The body scope then joins the match globals of every way in rather
612
+ # than claiming the regex matched: each condition contributes the scope its own `===`
613
+ # leaves — a regex literal's truthy edge, the falsey edge of the regex literals that
614
+ # failed before it, the entry state where none ran, and the unknown-match edges for a
615
+ # condition that may itself be or run a Regexp ({MatchRebinding::Operands.pattern_value?},
616
+ # {MatchRebinding.may_match?}).
597
617
  def apply_when_regex_globals(conditions, scope)
598
618
  regexes = conditions.grep(Prism::RegularExpressionNode)
599
619
  return scope if regexes.empty?
@@ -604,22 +624,96 @@ module Rigor
604
624
  else
605
625
  Set.new
606
626
  end
607
- truthy, = regex_match_predicate_scopes(scope, unconditional)
608
- truthy
627
+ truthy, falsey = regex_match_predicate_scopes(scope, unconditional)
628
+ return truthy if regexes.size == conditions.size
629
+
630
+ empty_truthy, empty_falsey = regex_match_predicate_scopes(scope, Set.new)
631
+ failed = scope
632
+ paths = conditions.map do |condition|
633
+ kind = when_regex_condition_kind(condition, scope)
634
+ path, failed = when_regex_condition_scopes(kind, failed, truthy, falsey, empty_truthy, empty_falsey)
635
+ path
636
+ end
637
+ paths.reduce(:join)
638
+ end
639
+
640
+ # How a `when` condition's `===` can touch the match globals, for {#apply_when_regex_globals}:
641
+ # `:literal` a `RegularExpressionNode`, whose match the clause's shared unconditional groups
642
+ # describe; `:interpolated` an interpolated regex literal, a definite match whose groups are
643
+ # unknown; `:may_match` any other condition that may be a Regexp or run one, whose `===`
644
+ # outcome for the globals is unknown; `:neutral` a condition whose `===` and evaluation run
645
+ # no match, leaving the globals as the earlier conditions left them.
646
+ def when_regex_condition_kind(condition, scope)
647
+ return :literal if condition.is_a?(Prism::RegularExpressionNode)
648
+ return :interpolated if condition.is_a?(Prism::InterpolatedRegularExpressionNode)
649
+ if MatchRebinding::Operands.pattern_value?(condition, scope) ||
650
+ MatchRebinding.may_match?(condition, scope)
651
+ return :may_match
652
+ end
653
+
654
+ :neutral
655
+ end
656
+
657
+ # `[body_path_scope, failed_scope]` for one `when` condition of a mixed clause, `failed`
658
+ # being the globals state the conditions before it left. On the path where this condition's
659
+ # `===` matched the body reads the truthy edge (`:literal`), the ungrouped truthy edge
660
+ # (`:interpolated`), the join of that edge with `failed` (`:may_match` — it may not have
661
+ # been a match at all), or `failed` itself (`:neutral`). On the path where it failed the
662
+ # next conditions read the matching falsey edge, the ungrouped one, the join of it with
663
+ # `failed`, or `failed`.
664
+ def when_regex_condition_scopes(kind, failed, truthy, falsey, empty_truthy, empty_falsey)
665
+ case kind
666
+ when :literal then [truthy, falsey]
667
+ when :interpolated then [empty_truthy, empty_falsey]
668
+ when :may_match then [empty_truthy.join(failed), failed.join(empty_falsey)]
669
+ else [failed, failed]
670
+ end
671
+ end
672
+
673
+ # Issue #2443, #1371 — the scope the block of a call that yields only after it has matched in the caller's frame
674
+ # enters with ({MatchRebinding::YieldingMatch}): the match edge of `pattern`, the call's pattern argument, over
675
+ # `scope` with the match globals forgotten. `$~` is a `MatchData`, `$&` and the pre- and post-match a `String`,
676
+ # and so is each numbered group {#yielded_capture_groups} vouches for; every other group is unbound, so it
677
+ # reads the RBS `String?` whatever an earlier match in the method bound it to.
678
+ def yielded_match_scope(scope, pattern)
679
+ regex_match_predicate_scopes(scope, yielded_capture_groups(pattern)).first
609
680
  end
610
681
 
611
- # Issue #2443 — `scope` with what every match binds, laid over the bindings it holds, for the block of a call
612
- # that yields only after it has matched in the caller's frame ({MatchRebinding::YieldingMatch}): `$~` a
613
- # `MatchData`, and `$&` and the pre- and post-match a `String`. The numbered groups and `$+` keep the bindings
614
- # `scope` gives them. Typing them from the call's own pattern needs the group walk
615
- # ({#unconditional_capture_groups}) to read every source right, which it does not yet (#2472), and forgetting
616
- # them would report a group an earlier match in the method narrowed.
617
- def yielded_match_scope(scope)
618
- string_t = Type::Combinator.nominal_of("String")
619
- scope.with_global(:$~, Type::Combinator.nominal_of("MatchData"))
620
- .with_global(:$&, string_t).with_global(:$`, string_t).with_global(:$', string_t)
682
+ # The pattern sources {#yielded_capture_groups} reads groups from: literal characters, `.`, the anchors, the
683
+ # quantifiers, `|`, plain and `(?:…)` groups, the class escapes, `\n`, `\r` and `\t`, an escaped punctuation
684
+ # character, and a bracket class holding only those with no bracket inside it. The group walk
685
+ # ({#unconditional_capture_groups}) reads these right; it misreads sources outside them (#2472, #1476).
686
+ PLAIN_PATTERN_ATOM = %r{[A-Za-z0-9 _\-.,:;=<>!@%&~'"`/^$+*?|(){}#]|\\[wWdDsShHnrt]|\\[^A-Za-z0-9\s]}
687
+ PLAIN_PATTERN_CLASS =
688
+ %r{\[\^?(?:[A-Za-z0-9 _\-.,:;=<>!@%~'"`/^$+*?|(){}#]|\\[wWdDsShHnrt]|\\[^A-Za-z0-9\s])+\]}
689
+ PLAIN_PATTERN = /\A(?:#{PLAIN_PATTERN_CLASS}|#{PLAIN_PATTERN_ATOM}|\\[bBAzZG])*\z/
690
+ OTHER_GROUP_FORM = /\(\?(?!:)/
691
+ # A quantifier on a quantifier repeats the quantified atom, so `(a)+{0}` and `(a){2}?` may match without the
692
+ # group. `+?` and `++` are one quantifier.
693
+ STACKED_QUANTIFIER = /\+[?+]?[*{]|\+[?+][?+]|\}[*+?{]/
694
+ NO_GROUPS = Set.new.freeze
695
+ private_constant :PLAIN_PATTERN_ATOM, :PLAIN_PATTERN_CLASS, :PLAIN_PATTERN, :OTHER_GROUP_FORM,
696
+ :STACKED_QUANTIFIER, :NO_GROUPS
697
+
698
+ # Issue #1371 — the 1-based indices of the groups every match of `pattern` sets, read only from a regex
699
+ # literal written between slashes, without interpolation or the `x` flag, whose source is a {PLAIN_PATTERN}
700
+ # with no `(?` form but `(?:` and no {STACKED_QUANTIFIER}. Any other pattern, a String one included, vouches
701
+ # for no group: declining costs precision, while a misread group would read `String` where Ruby has nil. A
702
+ # `%r` literal is declined because its source escapes the delimiter, which hides a quantifier from the walk:
703
+ # `%r,(a){\,1}b,` is `/(a){,1}b/`.
704
+ def yielded_capture_groups(pattern)
705
+ return NO_GROUPS unless pattern.is_a?(Prism::RegularExpressionNode) && !pattern.extended?
706
+ return NO_GROUPS unless pattern.opening == "/"
707
+
708
+ plain_pattern_source?(pattern.content) ? unconditional_capture_groups(pattern.content) : NO_GROUPS
621
709
  end
622
710
 
711
+ def plain_pattern_source?(source)
712
+ source.ascii_only? && PLAIN_PATTERN.match?(source) && !OTHER_GROUP_FORM.match?(source) &&
713
+ !STACKED_QUANTIFIER.match?(source)
714
+ end
715
+ private_class_method :yielded_capture_groups, :plain_pattern_source?
716
+
623
717
  # Issue #2443 — true when every member of `type` is a String (a project subclass included) or a `Dynamic` that
624
718
  # is untyped or may hold only Strings, Symbols and nil ({#match_member_kind}). A `nil` member never runs the
625
719
  # block, so it counts neither way.
@@ -636,7 +730,30 @@ module Rigor
636
730
  def analyse(node, scope)
637
731
  edges = analyse_shape(node, scope)
638
732
  reader = last_line_condition(node)
639
- reader ? LastLine.predicate_scopes(reader, scope, edges) : edges
733
+ edges = LastLine.predicate_scopes(reader, scope, edges) if reader
734
+ keep_gradual_marks(node, scope, edges)
735
+ end
736
+
737
+ # Issue #2656 — a guard edge narrows a local; it does not write it. So a local a class guard marked as a gradual
738
+ # narrowing ({Scope#with_gradual_narrowing_mark}) keeps the mark on both edges of every later predicate
739
+ # (`return if x.nil?`, `x != ""`, a second class guard), which bind the narrowed type through `with_local` and
740
+ # drop it. A local the predicate itself writes (`if (x = v)`) loses it, as any write does.
741
+ def keep_gradual_marks(node, scope, edges)
742
+ return edges if edges.nil? || (edges[0].equal?(scope) && edges[1].equal?(scope))
743
+
744
+ names = scope.gradual_narrowing_names
745
+ return edges if names.empty?
746
+
747
+ # Defensive: the statement walk binds a predicate's own write (`if (x = v)`) before its edges are read, so the
748
+ # mark is already gone there; the exclusion keeps a write that reached here from carrying it.
749
+ names -= OperandEffects.written_variables(node).map(&:to_sym)
750
+ return edges if names.empty?
751
+
752
+ edges.map do |edge|
753
+ names.reduce(edge) do |acc, name|
754
+ acc.gradual_narrowing?(name) || acc.local(name).nil? ? acc : acc.with_gradual_narrowing_mark(name)
755
+ end
756
+ end
640
757
  end
641
758
 
642
759
  # The reader call a condition's value is, or nil.
@@ -1815,7 +1932,7 @@ module Rigor
1815
1932
 
1816
1933
  def match_dynamic_kind(member, scope)
1817
1934
  facet = member.static_facet
1818
- return :dynamic if facet.is_a?(Type::Top)
1935
+ return :dynamic if Type::Combinator.untyped?(member)
1819
1936
 
1820
1937
  facet_kinds = match_member_kinds(facet, scope)
1821
1938
  :dynamic if facet_kinds.all? { |kind| (STRING_MATCH_KINDS + [:nil]).include?(kind) }
@@ -1977,7 +2094,7 @@ module Rigor
1977
2094
 
1978
2095
  # Returns the Set of 1-based numbered-capture indices that are UNCONDITIONAL in
1979
2096
  # `source`: present on every successful match because no optional quantifier (`?`,
1980
- # `*`, `{0,…}`) applies to the group or any ancestor group, and the pattern contains no
2097
+ # `*`, `{0,…}`, `{,…}`) applies to the group or any ancestor group, and the pattern contains no
1981
2098
  # alternation (`|`). Optional and alternation-reachable groups are excluded — at
1982
2099
  # runtime `$N` is `nil` for them even when the overall match succeeds, so narrowing
1983
2100
  # them to non-nil `String` would be unsound. The walker is intentionally light (char
@@ -2061,13 +2178,15 @@ module Rigor
2061
2178
  end
2062
2179
 
2063
2180
  # True when the group whose closing paren is at `source[pos]` is followed by a
2064
- # quantifier that permits zero repetitions (`?`, `*`, `{0…}`). `+` and `{1,…}` do NOT
2065
- # make a group optional. A lazy/possessive suffix (`*?`, `*+`) is still zero-permitting
2066
- # on the base quantifier.
2181
+ # quantifier that permits zero repetitions (`?`, `*`, `{0…}`, `{,…}`). `+` and `{1,…}`
2182
+ # do NOT make a group optional. A lazy/possessive suffix (`*?`, `*+`) is still
2183
+ # zero-permitting on the base quantifier. `{,…}` counts only with a digit after the
2184
+ # comma — `{,}`, `{,x}` and `{, }` are literal braces, not quantifiers.
2067
2185
  def next_quantifier_optional?(source, pos)
2068
2186
  case source[pos]
2069
2187
  when "?", "*" then true
2070
- when "{" then source[pos + 1] == "0"
2188
+ when "{" then source[pos + 1] == "0" ||
2189
+ (source[pos + 1] == "," && source[pos + 2].to_s.match?(/\d/))
2071
2190
  else false
2072
2191
  end
2073
2192
  end
@@ -2536,7 +2655,62 @@ module Rigor
2536
2655
  truthy = narrow_class(slot.current, class_name, exact: exact, environment: environment, scope: scope)
2537
2656
  falsey = narrow_not_class(slot.current, class_name, exact: exact, environment: environment, scope: scope)
2538
2657
  truthy_scope = record_bot_guard(narrow_guarded_slot(scope, slot, truthy), slot, truthy, [class_name])
2539
- [truthy_scope, narrow_guarded_slot(scope, slot, falsey)]
2658
+ static = gradual_union_slot?(slot) ? gradual_static_part(slot.current) : nil
2659
+ static &&= narrow_class(static, class_name, exact: exact, environment: environment, scope: scope)
2660
+ [mark_gradual_narrowing(truthy_scope, slot, truthy, narrowed_static: static),
2661
+ narrow_guarded_slot(scope, slot, falsey)]
2662
+ end
2663
+
2664
+ # Issue #2656 — a class guard's truthy edge over a local that held a `Dynamic` value marks the narrowed binding
2665
+ # ({Scope#with_gradual_narrowing_mark}): the binding keeps the asked class's `Nominal`, which dispatch and the
2666
+ # method rules read, while `call.argument-type-mismatch` judges it as the gradual value it came from, which a
2667
+ # literal-typed parameter (`("r" | "w")`) accepts. Only a local is marked, and only when the edge left it
2668
+ # something other than `Dynamic` or `bot`.
2669
+ def mark_gradual_narrowing(scope, slot, narrowed, marked: false, narrowed_static: nil)
2670
+ return scope unless slot.kind == :local
2671
+
2672
+ mark_gradual_local(scope, slot.name, slot.current, narrowed, marked: marked, narrowed_static: narrowed_static)
2673
+ end
2674
+
2675
+ # {#mark_gradual_narrowing} for the local `name`, which held `current` before the guard and `narrowed` after.
2676
+ # `current` is gradual when it is `Dynamic` or a union with a `Dynamic` member (`h[:m] || "r"`, `c ? h[:m] :
2677
+ # nil`); `marked` says the local already carried the mark, so a `case` or a capture over it keeps it (a
2678
+ # predicate keeps it through {.keep_gradual_marks}).
2679
+ def mark_gradual_local(scope, name, current, narrowed, marked: false, narrowed_static: nil)
2680
+ return scope unless marked || gradual_bearing?(current)
2681
+ return scope if narrowed.nil? || narrowed.is_a?(Type::Dynamic) || narrowed.is_a?(Type::Bot)
2682
+ return scope unless marked || literal_or_empty?(narrowed_static || gradual_static_part(current))
2683
+
2684
+ scope.with_gradual_narrowing_mark(name)
2685
+ end
2686
+ public :mark_gradual_local
2687
+
2688
+ def gradual_bearing?(type)
2689
+ type.is_a?(Type::Dynamic) || (type.is_a?(Type::Union) && type.members.any?(Type::Dynamic))
2690
+ end
2691
+
2692
+ # Issue #2656 — the non-`Dynamic` members of a union `current` (nil for any other type). A union is stamped
2693
+ # only when that part, narrowed by the guard (`narrowed_static`, or the part itself where the caller cannot
2694
+ # narrow it), is literals or nothing: the rule judges a literal member as written, so the mark then withholds
2695
+ # only for what came from the gradual member. A static member that narrows to a class (`d = "a" + s.to_s;
2696
+ # x = h[:m] || d`) would be read past as if it were gradual, so such a union is not stamped.
2697
+ def gradual_static_part(current)
2698
+ return nil unless current.is_a?(Type::Union)
2699
+
2700
+ Type::Combinator.union(*current.members.grep_v(Type::Dynamic))
2701
+ end
2702
+
2703
+ # Whether `slot` is a local holding a union with a `Dynamic` member, the one case {.gradual_static_part}
2704
+ # answers for; checked first so a class guard on any other slot builds nothing.
2705
+ def gradual_union_slot?(slot)
2706
+ slot.kind == :local && slot.current.is_a?(Type::Union) && slot.current.members.any?(Type::Dynamic)
2707
+ end
2708
+
2709
+ def literal_or_empty?(type)
2710
+ return true if type.nil?
2711
+
2712
+ members = type.is_a?(Type::Union) ? type.members : [type]
2713
+ members.all? { |member| member.is_a?(Type::Bot) || member.is_a?(Type::Constant) }
2540
2714
  end
2541
2715
 
2542
2716
  # The class name a `is_a?` / `kind_of?` / `instance_of?` argument denotes: the top-level
@@ -31,6 +31,12 @@ module Rigor
31
31
  # pure-value operand never reached, so a chained `b.push(2).size`, an argument `puts(b.push(2))` or
32
32
  # `d.map!.with_index { … }` left `b` / `d` on the literal their assignment wrote.
33
33
  #
34
+ # Issue #3163 — a `def` inside a block or lambda of the expression is looked into for one thing: an in-place
35
+ # mutation of an instance variable, class variable or global ({.method_mutation?}). The block may call the
36
+ # method it defines, and the evaluator floors such a variable after the block
37
+ # (`StatementEvaluator#floor_block_defined_method_mutations`), which it reaches only for an operand this module
38
+ # answers for: `puts([1].each { def h; $buf << 1; end; h })`.
39
+ #
34
40
  # Short-circuiting and allocation-free outside a mutator-named call: the evaluator asks it of every call's
35
41
  # operands, and asks it again of each operand it threads. A found effect stops the recursion without a
36
42
  # `return` out of the child block, which would allocate once per frame it unwinds and make a deep literal
@@ -58,12 +64,15 @@ module Rigor
58
64
  ].freeze
59
65
  JUMP_NODES = Set[Prism::NextNode, Prism::BreakNode].freeze
60
66
  SCOPE_NODES = Set[Prism::BlockNode, Prism::LambdaNode].freeze
67
+ NON_LOCAL_READ_NODES = Set[
68
+ Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
69
+ ].freeze
61
70
  OPAQUE_NODES = Set[
62
71
  Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefinedNode
63
72
  ].freeze
64
73
  private_constant :LOCAL_WRITE_NODES, :OUTLIVING_WRITE_NODES, :INSTANCE_WRITE_NODES, :GLOBAL_WRITE_NODES,
65
74
  :COMPOUND_LOCAL_WRITES,
66
- :COMPOUND_INSTANCE_WRITES, :JUMP_NODES, :SCOPE_NODES, :OPAQUE_NODES
75
+ :COMPOUND_INSTANCE_WRITES, :JUMP_NODES, :SCOPE_NODES, :NON_LOCAL_READ_NODES, :OPAQUE_NODES
67
76
 
68
77
  module_function
69
78
 
@@ -79,7 +88,7 @@ module Rigor
79
88
  return true if OUTLIVING_WRITE_NODES.include?(klass)
80
89
  return true if jumps && JUMP_NODES.include?(klass)
81
90
  return true if klass == Prism::CallNode && outliving_mutation?(node, nesting)
82
- return false if OPAQUE_NODES.include?(klass)
91
+ return block_defined_method_mutation?(node, nesting) if OPAQUE_NODES.include?(klass)
83
92
 
84
93
  nesting += 1 if SCOPE_NODES.include?(klass)
85
94
  jumps &&= !JumpTargets.boundary?(node)
@@ -89,6 +98,38 @@ module Rigor
89
98
  end
90
99
  private_class_method :effect?
91
100
 
101
+ # Issue #3163 — the one effect an opaque node has: a `def` under a block or lambda whose body mutates a
102
+ # non-local in place ({.method_mutation?}).
103
+ def block_defined_method_mutation?(node, nesting)
104
+ node.instance_of?(Prism::DefNode) && nesting.positive? && method_mutation?(node)
105
+ end
106
+ private_class_method :block_defined_method_mutation?
107
+
108
+ # True when `node`, a `def` or anything in its body, holds a call or index write
109
+ # that mutates an instance variable, class variable or global in place: a {MutationWidening::SHAPE_MUTATORS}
110
+ # name, or an index `||=` / `&&=` / `op=`, on a read of one. A class or module body inside the method is not
111
+ # looked into; a `def` nested in it is.
112
+ def method_mutation?(node)
113
+ klass = node.class
114
+ if klass == Prism::CallNode
115
+ return true if MutationWidening::SHAPE_MUTATORS.include?(node.name) && non_local_read?(node.receiver)
116
+ elsif OUTLIVING_WRITE_NODES.include?(klass)
117
+ return true if node.respond_to?(:receiver) && non_local_read?(node.receiver)
118
+ elsif klass != Prism::DefNode && OPAQUE_NODES.include?(klass)
119
+ return false
120
+ end
121
+
122
+ found = false
123
+ node.rigor_each_child { |child| found ||= method_mutation?(child) }
124
+ found
125
+ end
126
+ private_class_method :method_mutation?
127
+
128
+ def non_local_read?(node)
129
+ NON_LOCAL_READ_NODES.include?(node.class)
130
+ end
131
+ private_class_method :non_local_read?
132
+
92
133
  # True when `node` is a call the straight-line widening would answer for a variable that outlives the
93
134
  # expression: a {MutationWidening::SHAPE_MUTATORS} name on a receiver whose {ReceiverAlias.mutated_reads},
94
135
  # or the local an element read is rooted at ({ElementReadWidening.element_read_path}, `a[0] << e`), reach
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../type"
6
+
5
7
  module Rigor
6
8
  module Inference
7
9
  # Marks a value whose *nil-freeness* rests on Rigor's deliberate choice to ignore core RBS's
@@ -102,24 +104,147 @@ module Rigor
102
104
  # `StatementEvaluator` and `AlwaysTruthyConditionCollector` all route here, so the three consumers the
103
105
  # spec binds cannot drift apart.
104
106
  #
105
- # Resolution order — the mark recorded on the node itself, then the binding a bare local / ivar read (or
106
- # a write in value position, `if (x = MAP[k])`) resolves through, then a safe-navigation call and the
107
- # predicate-fold derivation issue #313 added.
107
+ # Resolution order — the mark recorded on the node itself, then the binding a bare local / ivar read resolves
108
+ # through, then a write in value position and a safe-navigation call and the predicate-fold derivation issue
109
+ # #313 added.
108
110
  def resolve(node, scope)
109
111
  return nil if node.nil? || scope.nil?
110
112
 
111
- recorded = scope.optimistic_origins[node]
113
+ recorded = scope.optimistic_origin(node)
112
114
  return recorded if recorded
113
115
 
116
+ resolve_node(node, scope)
117
+ end
118
+
119
+ # The shape dispatch {.resolve} applies once the node-level mark is ruled out.
120
+ def resolve_node(node, scope)
114
121
  case node
115
- when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
116
- when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
122
+ when Prism::LocalVariableReadNode then scope.optimistic_local(node.name)
123
+ when Prism::InstanceVariableReadNode then scope.optimistic_ivar(node.name)
124
+ when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode,
125
+ Prism::GlobalVariableWriteNode, Prism::ClassVariableWriteNode,
126
+ Prism::MultiWriteNode, Prism::LocalVariableOrWriteNode,
127
+ Prism::InstanceVariableOrWriteNode, Prism::ClassVariableOrWriteNode,
128
+ Prism::GlobalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
129
+ Prism::InstanceVariableAndWriteNode, Prism::ClassVariableAndWriteNode,
130
+ Prism::GlobalVariableAndWriteNode
131
+ resolve_write_form(node, scope)
117
132
  when Prism::AndNode, Prism::OrNode then resolve(node.left, scope) || resolve(node.right, scope)
118
- when Prism::CallNode then resolve_through_safe_navigation(node, scope) || resolve_through_predicate(node, scope)
133
+ when Prism::CallNode
134
+ resolve_attribute_write(node, scope) || resolve_through_safe_navigation(node, scope) ||
135
+ resolve_through_predicate(node, scope)
119
136
  when Prism::ParenthesesNode then resolve_through_parentheses(node, scope)
120
137
  end
121
138
  end
122
139
 
140
+ # The write forms in value position: the plain and multiple writes whose value is the
141
+ # right-hand side, and the `||=` / `&&=` spellings whose old binding participates.
142
+ def resolve_write_form(node, scope)
143
+ case node
144
+ when Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
145
+ Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode
146
+ resolve_or_write(node, scope)
147
+ when Prism::LocalVariableAndWriteNode, Prism::InstanceVariableAndWriteNode,
148
+ Prism::ClassVariableAndWriteNode, Prism::GlobalVariableAndWriteNode
149
+ resolve_and_write(node, scope)
150
+ else
151
+ resolve_write(node, scope)
152
+ end
153
+ end
154
+
155
+ # Issue #2617 — a write in value position (`if (v = h[k])`) yields its right-hand side, so the write's own
156
+ # optimism is the RHS's. The binding lookup alone could not see it from the scope a certainty consumer
157
+ # reads: the `if`'s entry scope predates the predicate's evaluation, so `if (v = h[k])` found no mark for
158
+ # `v` there and `flow.always-truthy-condition` fired on the optimistic read. Resolving `node.value` reaches
159
+ # the mark recorded on the read itself, which lives in the side table every scope shares; the binding lookup
160
+ # stays as the fallback for the post-write scope `branch_certainty` passes.
161
+ def resolve_write(node, scope)
162
+ resolve(node.value, scope) || optimistic_write_binding(node, scope)
163
+ end
164
+
165
+ # Only locals and ivars carry a binding mark; a global or class-variable write, and a
166
+ # multiple assignment's slots (marked through {.destructuring_marks} instead), have no
167
+ # `optimistic_*` table entry to fall back to, so they answer nil here.
168
+ def optimistic_write_binding(node, scope)
169
+ case node
170
+ when Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
171
+ when Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
172
+ end
173
+ end
174
+
175
+ # Issue #3054 — `v ||= rhs` yields the OLD binding whenever it is truthy, so the write's
176
+ # value is the right-hand side's only on the paths where the old value is falsey. When the
177
+ # old binding is provably always truthy — its type has no falsey inhabitant and it carries
178
+ # no optimistic mark — the right-hand side is never reached and the expression's verdict is
179
+ # the old value's own proof, so resolving through `node.value` would silence a correct
180
+ # always-truthy verdict (`v = "x"; if (v ||= h[k])` still folds to `"x"`). Otherwise the
181
+ # verdict, when one folds, rests on the right-hand side's bet.
182
+ def resolve_or_write(node, scope)
183
+ binding_type, binding_mark = or_write_binding(node, scope)
184
+ return nil if always_truthy_type?(binding_type) && binding_mark.nil?
185
+
186
+ resolve(node.value, scope)
187
+ end
188
+
189
+ # Issue #3054 — `v &&= rhs` yields the right-hand side whenever the old binding is truthy;
190
+ # the old value itself wins only on its falsey edge, a result no always-truthy verdict can
191
+ # rest on. So the right-hand side's mark covers every constant verdict the expression folds
192
+ # to, joined by the old binding's own mark for the case where the miss — not the rvalue — is
193
+ # what makes the condition live (`v = h[k]; if (v &&= "lit")`).
194
+ def resolve_and_write(node, scope)
195
+ resolve(node.value, scope) || and_write_binding_mark(node, scope)
196
+ end
197
+
198
+ # The `[type, mark]` pair an `||=` reads its old value through: the scope binding and the
199
+ # optimistic mark it carries. Class- and global-variable or-writes have no mark table — the
200
+ # mark channel covers locals and ivars only — so their mark is always nil.
201
+ def or_write_binding(node, scope)
202
+ case node
203
+ when Prism::LocalVariableOrWriteNode
204
+ [scope.local(node.name), scope.optimistic_local(node.name)]
205
+ when Prism::InstanceVariableOrWriteNode
206
+ [scope.ivar(node.name), scope.optimistic_ivar(node.name)]
207
+ when Prism::ClassVariableOrWriteNode
208
+ [scope.cvar(node.name), nil]
209
+ when Prism::GlobalVariableOrWriteNode
210
+ [scope.global(node.name), nil]
211
+ end
212
+ end
213
+
214
+ # The old binding's optimistic mark for an `&&=` — the same two tables {.or_write_binding}
215
+ # reads, with no type: `&&=` needs the mark alone.
216
+ def and_write_binding_mark(node, scope)
217
+ case node
218
+ when Prism::LocalVariableAndWriteNode then scope.optimistic_local(node.name)
219
+ when Prism::InstanceVariableAndWriteNode then scope.optimistic_ivar(node.name)
220
+ end
221
+ end
222
+
223
+ # Issue #3054 — `recv.x = rhs` and `recv[i] = rhs` yield the assigned value, which Prism
224
+ # spells as the call's last argument (`x=` takes it alone, `[]=` trails the indices). The
225
+ # compound spellings never reach here: `+=` / `[]=` operators and `||=` / `&&=` on an
226
+ # attribute or index are `CallOperatorWriteNode` / `IndexOperatorWriteNode` /
227
+ # `CallOrWriteNode` / `CallAndWriteNode` / `IndexOrWriteNode` / `IndexAndWriteNode`, not
228
+ # `CallNode`, and their value is the old slot's, not the right-hand side's.
229
+ def resolve_attribute_write(node, scope)
230
+ resolve(node.arguments&.arguments&.last, scope) if node.attribute_write?
231
+ end
232
+
233
+ # Whether `type` proves every inhabitant truthy — the empty falsey fragment of
234
+ # `Narrowing.narrow_falsey`, mirrored carrier for carrier so this leaf module need not
235
+ # require `Narrowing` (which already requires this file). A `nil` binding (unbound) and
236
+ # every carrier the falsey fragment cannot empty (`Top`, `Dynamic`, ranges, refinements,
237
+ # differences) answer false: only a proof counts.
238
+ def always_truthy_type?(type)
239
+ case type
240
+ when Type::Constant then !(type.value.nil? || type.value == false)
241
+ when Type::Nominal then !%w[NilClass FalseClass].include?(type.class_name)
242
+ when Type::Union then type.members.all? { |member| always_truthy_type?(member) }
243
+ when Type::Singleton, Type::Tuple, Type::HashShape then true
244
+ else type.is_a?(Type::Bot)
245
+ end
246
+ end
247
+
123
248
  # `recv&.m` is `nil` exactly when `recv` is (or when `m` answers `nil`), so it restates `recv`'s presence
124
249
  # and is as optimistic as `recv`. Only the call carrying the `&.` is derived: Ruby skips that one call and
125
250
  # no more, so `recv&.m.n` sends `n` to the `nil` and raises (or answers `NilClass#n`), and neither it nor
@@ -169,7 +294,7 @@ module Rigor
169
294
  # predicate's folded boolean only when the miss answers the other boolean (or cannot be told), so
170
295
  # `!recv&.empty?` — `!false` on a hit, `!nil` on a miss — stays `true`, and so does `x = recv&.empty?; !x`.
171
296
  def miss_answer(node, scope)
172
- return nil if scope.optimistic_origins[node]
297
+ return nil if scope.optimistic_origin(node)
173
298
 
174
299
  case node
175
300
  when Prism::LocalVariableReadNode then scope.optimistic_local_miss(node.name)
@@ -23,7 +23,7 @@ module Rigor
23
23
  def origin_for(scope, node)
24
24
  return nil if node.nil?
25
25
 
26
- scope.dynamic_origins[node] || propagated(scope, node)
26
+ scope.dynamic_origin(node) || propagated(scope, node)
27
27
  end
28
28
 
29
29
  def propagated(scope, node)