rigortype 0.4.3 → 0.4.5

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -25,6 +25,7 @@ require_relative "hash_lookup_mutation"
25
25
  require_relative "in_effect_refinements"
26
26
  require_relative "refine_census"
27
27
  require_relative "refine_self"
28
+ require_relative "reflective_ivar_write"
28
29
  require_relative "index_write_widening"
29
30
  require_relative "module_function_state"
30
31
  require_relative "multi_target_binder"
@@ -332,8 +333,10 @@ module Rigor
332
333
  # possible methods were folded already ({#seed_discovered_methods}).
333
334
  def per_file_siblings(siblings, file_def_nodes, file_siblings, file_singleton, file_visibilities)
334
335
  file_singleton_def_nodes, file_singleton_contested = file_singleton
335
- siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
336
- siblings[:contested_discovered_method_visibilities], *file_visibilities
336
+ # The seed's singleton contests stay ({#fold_visibility_contests}); it already holds a disagreement between
337
+ # this file and another.
338
+ siblings[:contested_discovered_method_visibilities] = fold_visibility_contests(
339
+ siblings[:contested_discovered_method_visibilities], {}, *file_visibilities
337
340
  )
338
341
  siblings[:contested_discovered_def_nodes] = fold_slot_contests(
339
342
  siblings[:contested_discovered_def_nodes], file_def_nodes, file_siblings[:contested_discovered_def_nodes]
@@ -507,13 +510,30 @@ module Rigor
507
510
  method_assign_effects = build_method_assign_effects(root)
508
511
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
509
512
  read_before_write, init_writes, method_assign_effects)
513
+ reflective_opaque = accumulator.delete(REFLECTIVE_OPAQUE_CLASSES)
514
+ exempt_class_body_writes!(init_writes, accumulator.delete(DEFINE_METHOD_WRITES) || {})
510
515
  merge_held_ivar_writes!(accumulator)
511
516
  record_aliased_ivar_mutations!(root, mutated_ivars)
512
517
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
513
518
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
519
+ make_reflective_classes_gradual!(accumulator, reflective_opaque)
514
520
  accumulator.transform_values(&:freeze).freeze
515
521
  end
516
522
 
523
+ # Folds the class-body writes {#collect_class_body_ivar_writes} held under {CLASS_BODY_WRITES} into the
524
+ # `init_writes` exemption, except for an ivar a `define_method` block, or a `block_as_methods:` block bound to the
525
+ # receiver's instance ({#method_body_block_call?}), of the class writes (#1768). Such an ivar had no census entry
526
+ # before the block's writes were counted, so the exemption never applied to it; a class-body `@x = nil` writes
527
+ # the class object's ivar and says nothing about whether an instance's `@x` is set, and exempting it would seed
528
+ # `@x` non-nil from the block's write alone (`if @x` folding always-truthy on an instance whose `@x` is unset).
529
+ # It keeps the read-before-write `nil` instead, which only widens.
530
+ def exempt_class_body_writes!(init_writes, define_method_writes)
531
+ (init_writes.delete(CLASS_BODY_WRITES) || {}).each do |class_name, ivars|
532
+ kept = ivars - (define_method_writes[class_name] || EMPTY_GUARDED_IVARS)
533
+ (init_writes[class_name] ||= Set.new).merge(kept)
534
+ end
535
+ end
536
+
517
537
  # B2.3 — finalize the read-before-write nil contribution. For each class, for each ivar where SOME method body
518
538
  # observed a read-before-write AND no `initialize` write exists for that ivar, contribute `Constant[nil]` to the
519
539
  # class-wide accumulator.
@@ -853,7 +873,8 @@ module Rigor
853
873
 
854
874
  owner = def_owner || qualified_prefix
855
875
  collect_initializer_block_ivars(node, owner, default_scope, accumulator, mutated_ivars, init_writes)
856
- record_attr_writer_ivars(node, owner, accumulator) if ATTR_WRITER_MACROS.include?(node.name)
876
+ record_attr_writer_ivars(node, owner, accumulator) if ATTR_WRITER_MACROS.include?(attr_macro(node))
877
+ collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
857
878
  false
858
879
  else
859
880
  walk_ivars_meta_new?(node, qualified_prefix, default_scope, accumulator,
@@ -1065,6 +1086,51 @@ module Rigor
1065
1086
  end
1066
1087
  end
1067
1088
 
1089
+ # The `define_method` arm of {#walk_class_ivars} (#1768). `define_method(:m) { … }` on the implicit
1090
+ # or `self` receiver installs its block as an instance method of `owner`, and Ruby runs the body on
1091
+ # the instance, so its `@x = …` writes are the instance's ivars exactly as a `def m` body's are —
1092
+ # `DefineMethodBlockSelf` narrows the block's `self` the same way when it is evaluated. Without this
1093
+ # arm an ivar `initialize` sets to `nil` and a `define_method` block sets to a String read as `nil`
1094
+ # in every sibling `def`. The block is typed under the instance `self`, as a def body is. Only the
1095
+ # literal-block form counts: a `define_method(:m, callable)` or `&callable` argument is not walked.
1096
+ # `owner` is empty in a `class << self` body (the call defines a class method there) and in an
1097
+ # anonymous factory body, and the block contributes nothing there. A `define_method` inside a `def`
1098
+ # body is already reached by that body's walk ({#gather_ivar_writes} enters blocks).
1099
+ #
1100
+ # An ADR-16 `block_as_methods:` call whose entry binds the receiver's instance (`get "/" do … end` in a
1101
+ # `Sinatra::Base` subclass under rigor-sinatra) takes the same arm: the plugin declares that its block runs as an
1102
+ # instance method of the receiver class, and `MacroBlockSelfType` narrows the block's `self` to that instance.
1103
+ def collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
1104
+ return if owner.empty?
1105
+
1106
+ class_name = owner.join("::")
1107
+ return unless method_body_block_call?(node, class_name, default_scope)
1108
+
1109
+ block = node.block
1110
+ return unless block.body
1111
+
1112
+ body_scope = census_body_scope(default_scope, owner, Type::Combinator.nominal_of(class_name))
1113
+ gather_ivar_writes(block.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
1114
+ written = (accumulator[DEFINE_METHOD_WRITES] ||= {})[class_name] ||= Set.new
1115
+ define_method_written_ivars(block.body, written)
1116
+ end
1117
+
1118
+ # The ivars a `define_method` block body writes, in any write form, outside a nested `def` / `class` / `module`.
1119
+ def define_method_written_ivars(node, acc)
1120
+ return unless node.is_a?(Prism::Node)
1121
+ return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
1122
+
1123
+ case node
1124
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
1125
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
1126
+ Prism::InstanceVariableTargetNode
1127
+ acc << node.name
1128
+ end
1129
+ reflective = ReflectiveIvarWrite.literal_name(node)
1130
+ acc << reflective if reflective
1131
+ node.rigor_each_child { |child| define_method_written_ivars(child, acc) }
1132
+ end
1133
+
1068
1134
  # ADR-38 block-form gate: true when a loaded plugin declares `method_name` a block-form initializer for
1069
1135
  # `class_name` (or an ancestor). Mirrors `additional_initializer?` but queries `covers_block_method?` instead of
1070
1136
  # `covers_method?`.
@@ -1156,15 +1222,21 @@ module Rigor
1156
1222
  # every `@x = …` write target as a class-body init. Consumed by `contribute_read_before_write_nil!` to exempt
1157
1223
  # ivars the author already knows might be nil (the `@x = nil` at class-body level is the canonical nullability
1158
1224
  # acknowledgement; the instance @x is technically a separate store, but the pragmatic intent is unambiguous).
1225
+ #
1226
+ # A literal `define_method` block is a method body, not class-body code (#1768): its writes reach the census as
1227
+ # a `def` body's do, so they must not exempt the ivar from the read-before-write `nil` either. Only its receiver
1228
+ # and arguments are walked.
1159
1229
  def collect_class_body_ivar_writes(node, class_name, init_writes)
1160
1230
  return unless node.is_a?(Prism::Node)
1161
1231
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
1162
1232
 
1233
+ return collect_define_method_argument_writes(node, class_name, init_writes) if define_method_block_call?(node)
1234
+
1163
1235
  if node.is_a?(Prism::InstanceVariableWriteNode) ||
1164
1236
  node.is_a?(Prism::InstanceVariableOrWriteNode) ||
1165
1237
  node.is_a?(Prism::InstanceVariableAndWriteNode) ||
1166
1238
  node.is_a?(Prism::InstanceVariableOperatorWriteNode)
1167
- (init_writes[class_name] ||= Set.new) << node.name
1239
+ ((init_writes[CLASS_BODY_WRITES] ||= {})[class_name] ||= Set.new) << node.name
1168
1240
  end
1169
1241
 
1170
1242
  node.rigor_each_child do |child|
@@ -1172,6 +1244,37 @@ module Rigor
1172
1244
  end
1173
1245
  end
1174
1246
 
1247
+ # The arguments of a `define_method` block call, walked by {#collect_class_body_ivar_writes} in place of the
1248
+ # call's children so the block body stays out of the class-body init writes.
1249
+ def collect_define_method_argument_writes(node, class_name, init_writes)
1250
+ node.arguments&.arguments&.each { |arg| collect_class_body_ivar_writes(arg, class_name, init_writes) }
1251
+ end
1252
+
1253
+ # A `define_method` call with a literal block on the implicit or `self` receiver: the block is an instance
1254
+ # method body of the class the walk is in (#1768).
1255
+ def define_method_block_call?(node)
1256
+ DefineMethodBlockSelf.define_method_on_lexical_self?(node) && node.block.is_a?(Prism::BlockNode)
1257
+ end
1258
+
1259
+ # A call with a literal block on the implicit or `self` receiver whose block is an instance method body of
1260
+ # `class_name`, the class the walk is in (#1768): `define_method`, or a call an ADR-16 `block_as_methods:` entry
1261
+ # declares runs its block as an instance method of the receiver class ({MacroBlockSelfType}). The entry is
1262
+ # matched on `Singleton[class_name]`, the receiver such a call has in the class body, through the same
1263
+ # receiver-ancestry match the evaluator narrows the block's `self` with, so the census counts exactly the blocks
1264
+ # whose `self` is narrowed to the instance.
1265
+ #
1266
+ # The class-body walk ({#collect_class_body_ivar_writes}) still records a `block_as_methods:` block's writes as
1267
+ # class-body writes, and {#exempt_class_body_writes!} takes back every ivar such a block writes.
1268
+ def method_body_block_call?(node, class_name, scope)
1269
+ return true if define_method_block_call?(node)
1270
+ return false unless node.block.is_a?(Prism::BlockNode)
1271
+ return false unless scope && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
1272
+
1273
+ MacroBlockSelfType.runs_as_receiver_instance_method?(
1274
+ scope: scope, call_node: node, receiver_type: Type::Combinator.singleton_of(class_name)
1275
+ )
1276
+ end
1277
+
1175
1278
  def detect_read_before_write(node, seen_writes, read_first)
1176
1279
  return unless node.is_a?(Prism::Node)
1177
1280
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
@@ -1199,6 +1302,9 @@ module Rigor
1199
1302
  end
1200
1303
 
1201
1304
  seen_writes << node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
1305
+ # #1931 — a literal `instance_variable_set` is a write too; its value argument, read above, runs first.
1306
+ reflective = ReflectiveIvarWrite.literal_name(node)
1307
+ seen_writes << reflective if reflective
1202
1308
  end
1203
1309
 
1204
1310
  # Records each ivar target of a `MultiWriteNode` / nested `MultiTargetNode` into `seen_writes`, and descends into
@@ -1246,6 +1352,7 @@ module Rigor
1246
1352
  end
1247
1353
  else
1248
1354
  record_compound_ivar_write(node, scope, class_name, accumulator)
1355
+ record_reflective_ivar_write(node, scope, class_name, accumulator)
1249
1356
  end
1250
1357
 
1251
1358
  # N1 — parallel / multiple assignment (`old, @cb = @cb, block`, `@i, @o, @e, @thr = Open3.popen3(cmd)`). A
@@ -1273,6 +1380,32 @@ module Rigor
1273
1380
  end
1274
1381
  end
1275
1382
 
1383
+ # #1931 — `instance_variable_set` on `self` writes an ivar no write node shows ({ReflectiveIvarWrite}). A literal
1384
+ # name (`instance_variable_set(:@k, v)`) contributes the value's type to that ivar's entry, as `@k = v` would. A
1385
+ # name the analysis cannot read (`instance_variable_set(:"@#{k}", v)`, the options-hash constructor idiom) may
1386
+ # write any ivar of the class, so every entry of the class turns gradual once the walk ends
1387
+ # ({#make_reflective_classes_gradual!}): a reader in another method otherwise kept a write's literal (`@timeout =
1388
+ # 5` read as `5`) and reported a call on it.
1389
+ def record_reflective_ivar_write(node, scope, class_name, accumulator)
1390
+ return unless ReflectiveIvarWrite.self_write?(node)
1391
+
1392
+ name = ReflectiveIvarWrite.literal_name(node)
1393
+ if name
1394
+ accumulate_ivar_type(accumulator, class_name, name, scope.type_of(ReflectiveIvarWrite.value_node(node)))
1395
+ else
1396
+ (accumulator[REFLECTIVE_OPAQUE_CLASSES] ||= Set.new) << class_name
1397
+ end
1398
+ end
1399
+
1400
+ def make_reflective_classes_gradual!(accumulator, classes)
1401
+ classes&.each do |class_name|
1402
+ entries = accumulator[class_name]
1403
+ next if entries.nil?
1404
+
1405
+ accumulator[class_name] = entries.transform_values { Type::Combinator.untyped }
1406
+ end
1407
+ end
1408
+
1276
1409
  # Records `@ivar.<method>(...)` calls whose method is in `MutationWidening::SHAPE_MUTATORS`.
1277
1410
  # The class-ivar pre-pass uses the resulting set to widen the post-collected accumulator entries (see
1278
1411
  # {.widen_mutated_ivar_entries!}). Always-safe to over- collect: any name that the widening primitive declines is
@@ -2011,6 +2144,15 @@ module Rigor
2011
2144
  OPERATOR_WRITE_CONTRIBUTIONS = :operator_write_contributions
2012
2145
  private_constant :AND_WRITE_CONTRIBUTIONS, :OPERATOR_WRITE_CONTRIBUTIONS
2013
2146
 
2147
+ # The same Symbol-key convention for two side tables the walk fills and {#build_class_ivar_index} removes
2148
+ # before anything reads class names: the ivars each class's `define_method` blocks write (in the
2149
+ # accumulator), and the class-body writes held out of `init_writes` until {#exempt_class_body_writes!}.
2150
+ DEFINE_METHOD_WRITES = :define_method_writes
2151
+ CLASS_BODY_WRITES = :class_body_writes
2152
+ # The classes a computed-name `instance_variable_set` may write any ivar of ({#record_reflective_ivar_write}).
2153
+ REFLECTIVE_OPAQUE_CLASSES = :reflective_opaque_classes
2154
+ private_constant :DEFINE_METHOD_WRITES, :CLASS_BODY_WRITES, :REFLECTIVE_OPAQUE_CLASSES
2155
+
2014
2156
  # Unions `type` into the class-ivar accumulator for `(class_name, ivar_name)`. Shared by the single-write and
2015
2157
  # multi-write (parallel-assignment) collectors.
2016
2158
  def accumulate_ivar_type(accumulator, class_name, ivar_name, type)
@@ -4010,6 +4152,10 @@ module Rigor
4010
4152
  (singleton_cref && owner_prefix.empty?)
4011
4153
  record_alias_or_undef(node, owner_prefix, in_singleton_class || defs_singleton,
4012
4154
  methods_acc.at(node))
4155
+ if node.is_a?(Prism::AliasMethodNode) && !(in_singleton_class || defs_singleton)
4156
+ supersede_def_nodes(Array(Source::AliasNames.keyword_names(node)&.first), owner_prefix,
4157
+ def_nodes_acc, methods_acc.at(node))
4158
+ end
4013
4159
  end
4014
4160
  return
4015
4161
  when Prism::SymbolNode
@@ -4024,6 +4170,9 @@ module Rigor
4024
4170
  end
4025
4171
  anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc.at(node),
4026
4172
  source_path)
4173
+ unless in_singleton_class
4174
+ supersede_def_nodes(call_superseded_names(node), owner_prefix, def_nodes_acc, methods_acc.at(node))
4175
+ end
4027
4176
  tables = methods_acc.at(node)
4028
4177
  record_singleton_prepend(node, qualified_prefix, tables,
4029
4178
  self_ok: node.name == :prepend && tables.in_block?(node))
@@ -4093,11 +4242,40 @@ module Rigor
4093
4242
  def record_declaration_facts(node, child_prefix, tables)
4094
4243
  if node.is_a?(Prism::ClassNode)
4095
4244
  record_meta_superclass_members(node, child_prefix, tables)
4245
+ record_constructed_superclass(node.superclass, child_prefix.join("::"), tables)
4096
4246
  else
4097
4247
  record_surface_mark(tables, child_prefix.join("::"), Scope::DiscoveryIndex::ENVELOPE_MODULE_MARK)
4098
4248
  end
4099
4249
  end
4100
4250
 
4251
+ # Issue #1979 — `class K < Data.define(:a)`, `< Struct.new(:a)` or `< Class.new(Parent)`: the superclass table
4252
+ # records `nil` for K, so a subclass walk enumerates it under every class. Each constant the expression names
4253
+ # (the factory's receiver, and `Class.new`'s argument, or `Object` for none) takes a
4254
+ # `[ENVELOPE_CONSTRUCTED_KIND, name]` entry; an expression of any other shape records none.
4255
+ def record_constructed_superclass(superclass, class_name, tables)
4256
+ names = constructed_superclass_names(superclass)
4257
+ names&.each do |name|
4258
+ record_envelope(tables.envelopes, class_name, [Scope::DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND, name],
4259
+ Source::ParameterEnvelope::OPAQUE)
4260
+ end
4261
+ end
4262
+
4263
+ def constructed_superclass_names(node)
4264
+ return ["Data"] if meta_call_with_name?(node, :Data, :define)
4265
+ return ["Struct"] if meta_call_with_name?(node, :Struct, :new)
4266
+ return nil unless class_new_call?(node)
4267
+
4268
+ arguments = node.arguments&.arguments || []
4269
+ return %w[Class Object] if arguments.empty?
4270
+ return nil unless arguments.one?
4271
+
4272
+ argument = arguments.first
4273
+ inner = if argument.is_a?(Prism::ConstantReadNode) then [argument.name.to_s]
4274
+ else constructed_superclass_names(argument)
4275
+ end
4276
+ inner && ["Class", *inner]
4277
+ end
4278
+
4101
4279
  # The block form of a meta-new constant write: its members, and the module mark `Module.new` earns.
4102
4280
  # Issue #1820 — `K = Class.new(Parent)`, with or without a block, names a class whose superclass the superclass
4103
4281
  # table leaves out; it takes {Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK}.
@@ -4136,7 +4314,7 @@ module Rigor
4136
4314
  # descent.
4137
4315
  def record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
4138
4316
  record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
4139
- record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc) if ATTR_MACROS.include?(node.name)
4317
+ record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc) if attr_macro(node)
4140
4318
  record_module_attr_methods(node, qualified_prefix, methods_acc) if MODULE_ATTR_MACROS.key?(node.name)
4141
4319
  record_alias_method_call(node, qualified_prefix, in_singleton_class, methods_acc)
4142
4320
  record_surface_evidence(node, qualified_prefix, methods_acc)
@@ -4649,6 +4827,49 @@ module Rigor
4649
4827
  end
4650
4828
  end
4651
4829
 
4830
+ # Issue #1989 — the instance method names a non-`def` definer written on the class body's self replaces:
4831
+ # each reader (and `x=` writer) an `attr_*` call declares, a literal `define_method(:x)`, and the new name of
4832
+ # an `alias_method :x, :y` call. A non-literal name replaces nothing Rigor can name.
4833
+ def call_superseded_names(call_node)
4834
+ if (macro = attr_macro(call_node))
4835
+ names = []
4836
+ each_attr_macro_name(call_node) do |base|
4837
+ names << base unless macro == :attr_writer
4838
+ names << :"#{base}=" unless macro == :attr_reader
4839
+ end
4840
+ names
4841
+ elsif call_node.name == :define_method
4842
+ define_method_superseded_name(call_node)
4843
+ else
4844
+ Source::AliasNames.alias_method_call_names(call_node)&.first(1) || []
4845
+ end
4846
+ end
4847
+
4848
+ def define_method_superseded_name(call_node)
4849
+ return [] unless call_node.receiver.nil? || call_node.receiver.is_a?(Prism::SelfNode)
4850
+
4851
+ name = literal_method_name(call_node.arguments&.arguments&.first)
4852
+ name.nil? ? [] : [name.to_sym]
4853
+ end
4854
+
4855
+ # Issue #1989 — a definer that is not a `def` replaces the method an earlier `def` of the same name in this walk
4856
+ # installed: `def x = 1; attr_reader :x` answers the attr reader, so the `def` body must not type the call. The
4857
+ # slot keeps the `def` and is contested, certain definer or possible, so the readers that re-type a body
4858
+ # decline. The slot is not emptied: the file still writes it, so a sibling file's `def` of the name (which the
4859
+ # cross-file seed and the project fold would otherwise put there) cannot fill it. A `def` written after the
4860
+ # definer writes the slot again and clears the contest.
4861
+ def supersede_def_nodes(names, qualified_prefix, accumulator, tables)
4862
+ return if names.empty?
4863
+
4864
+ class_name = qualified_prefix.join("::")
4865
+ slot = accumulator[class_name]
4866
+ return if slot.nil?
4867
+
4868
+ names.each do |name|
4869
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, name], false) if slot.key?(name)
4870
+ end
4871
+ end
4872
+
4652
4873
  # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
4653
4874
  # Before the anonymous class had a name the body was walked with an empty prefix, so every such `def`
4654
4875
  # landed there; that is the same leniency this key already grants a `def` nested in any other DSL block
@@ -4694,6 +4915,18 @@ module Rigor
4694
4915
  # The singleton walk's accumulator: the `{class => {name => DefNode}}` table, plus the contested slots, which
4695
4916
  # follow each slot's writer ({#follow_slot_writer}'s rule).
4696
4917
  class SingletonDefTable < Hash
4918
+ # Issue #1936 — contests the slots of `names` (every slot of `class_name` for `:all`) that an earlier write
4919
+ # filled: a later non-`def` definer replaced the `def` they hold. The slot keeps the `def`, as
4920
+ # {ScopeIndexer#supersede_def_nodes} keeps the instance one, and a later write clears the contest.
4921
+ def supersede(class_name, names)
4922
+ slot = self[class_name]
4923
+ return if slot.nil?
4924
+
4925
+ (names == :all ? slot.keys : names).each do |name|
4926
+ (@contested ||= Set.new) << [class_name, name] if slot.key?(name)
4927
+ end
4928
+ end
4929
+
4697
4930
  def track(certainty)
4698
4931
  @certainty = certainty
4699
4932
  @contested = nil
@@ -4701,9 +4934,11 @@ module Rigor
4701
4934
  end
4702
4935
 
4703
4936
  # Writes the slot; `node` and `other` are the contributions the write rests on, possible when either is.
4704
- def write(class_name, name, def_node, node, other = nil)
4937
+ # `possible` says the write rests on a fact the contributions do not carry (a module_function copy after a
4938
+ # reset that may not run, #1968).
4939
+ def write(class_name, name, def_node, node, other = nil, possible: false)
4705
4940
  (self[class_name] ||= {})[name] = def_node
4706
- if Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4941
+ if possible || Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4707
4942
  (@contested ||= Set.new) << [class_name, name]
4708
4943
  else
4709
4944
  @contested&.delete([class_name, name])
@@ -4746,6 +4981,12 @@ module Rigor
4746
4981
  # `Const`'s singleton class (a `remove_method`, a `def`) without declaring `Const`, so it stays out of
4747
4982
  # `class_sources` (whose readers take a name there as one the project declares) and joins only the per-file
4748
4983
  # class-declaration sets the incremental `class:` edges diff (`Runner#class_declarations`).
4984
+ #
4985
+ # Issue #1941 — so does a `def Const.x` (`def D.own`, or `def E.x` inside `module M`, resolved as a `class << E`
4986
+ # receiver is): it adds to `Const`'s singleton side from a file the reader's class edge need not name. In the set,
4987
+ # the declaration diff fires `class:` when the file gains or loses one, and
4988
+ # `IncrementalSession#singleton_rewrite_affected` fires it when one is renamed. `def (M::E).x` records no
4989
+ # singleton `def` at all, so it joins nothing.
4749
4990
  def record_singleton_class_sources(sources, path, root)
4750
4991
  collect_singleton_class_targets(root, []) { |name| (sources[name] ||= Set.new) << path }
4751
4992
  end
@@ -4761,6 +5002,8 @@ module Rigor
4761
5002
  yield eval_constant_receiver_prefix(node, node.expression, rendered, lexical_prefix).join("::") if rendered
4762
5003
  when Prism::CallNode
4763
5004
  yield_singleton_side_constant(node, node.receiver, lexical_prefix, &) if singleton_side_call?(node)
5005
+ when Prism::DefNode
5006
+ yield_singleton_side_constant(node, node.receiver, lexical_prefix, &)
4764
5007
  end
4765
5008
  node.rigor_each_child { |child| collect_singleton_class_targets(child, lexical_prefix, &) }
4766
5009
  end
@@ -4768,8 +5011,7 @@ module Rigor
4768
5011
  # A call on a constant that may rewrite its singleton side (`Const.singleton_class`,
4769
5012
  # `Const.define_singleton_method`, `Const.extend(M)`, `Const.instance_eval { … }`) records facts about `Const`'s
4770
5013
  # singleton methods the refinement walk reads from a file that need not declare `Const`, so the file joins the
4771
- # constant's set too. Written inside `Const`'s own body, the name is in the file's set already; a `def Const.x`
4772
- # files its own `method:` key.
5014
+ # constant's set too. Written inside `Const`'s own body, the name is in the file's set already.
4773
5015
  def singleton_side_call?(node)
4774
5016
  node.name == :singleton_class || surface_rewriting_call?(node)
4775
5017
  end
@@ -4984,7 +5226,7 @@ module Rigor
4984
5226
  # own `def` leaves additionally split on {INSTANCE_EVAL_CALLS}, whose `def`s bind on the
4985
5227
  # receiver's singleton rather than the receiver.
4986
5228
  def receiver_eval_call?(node)
4987
- node.block.is_a?(Prism::BlockNode) && RECEIVER_EVAL_CALLS.include?(node.name)
5229
+ node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode) && RECEIVER_EVAL_CALLS.include?(node.name)
4988
5230
  end
4989
5231
 
4990
5232
  # A `class_eval` / `module_eval` / `class_exec` / `module_exec` block is NOT deferred: it runs
@@ -5367,7 +5609,10 @@ module Rigor
5367
5609
  def_owner_prefix = nil, singleton_cref: false,
5368
5610
  defs_singleton: false)
5369
5611
  return unless node.is_a?(Prism::Node)
5370
- if node.is_a?(Prism::CallNode) && receiver_eval_call?(node)
5612
+
5613
+ supersede_singleton_def_nodes(node, qualified_prefix, in_singleton_class, defs_singleton, accumulator,
5614
+ def_owner_prefix)
5615
+ if receiver_eval_call?(node)
5371
5616
  return walk_eval_singleton_defs(node, qualified_prefix, in_singleton_class, accumulator,
5372
5617
  def_owner_prefix, singleton_cref: singleton_cref,
5373
5618
  defs_singleton: defs_singleton)
@@ -5658,7 +5903,8 @@ module Rigor
5658
5903
  defs_singleton: false)
5659
5904
  owner_prefix = def_owner_prefix || qualified_prefix
5660
5905
  statements = statements_of(body)
5661
- ModuleFunctionState.each_singleton_sibling(statements) do |stmt, module_function_on, named_call|
5906
+ conditional = ModuleFunctionState.conditional_clause_statements(body)
5907
+ ModuleFunctionState.each_singleton_sibling(statements, conditional) do |stmt, module_function_on, named_call|
5662
5908
  if named_call
5663
5909
  # `:unnameable` — the named defs the call copies live on the metaclass,
5664
5910
  # which this table cannot name either.
@@ -5708,19 +5954,96 @@ module Rigor
5708
5954
  statements
5709
5955
  end
5710
5956
 
5957
+ # `module_function_on` is {ModuleFunctionState.each_singleton_sibling}'s: `:uncertain` writes the module
5958
+ # function's copy as possible, so the slot holds the later def, contested, rather than the def the
5959
+ # slot held before (#1968).
5711
5960
  def record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator)
5712
- singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class) || module_function_on
5713
- return unless singleton
5961
+ declared = def_singleton?(def_node, qualified_prefix, in_singleton_class)
5962
+ return unless declared || module_function_on
5714
5963
  return if qualified_prefix.empty?
5715
5964
 
5716
5965
  class_name = qualified_prefix.join("::")
5717
- write_singleton_slot(accumulator, class_name, def_node.name, def_node, def_node)
5966
+ write_singleton_slot(accumulator, class_name, def_node.name, def_node, def_node,
5967
+ possible: !declared && module_function_on == :uncertain)
5968
+ end
5969
+
5970
+ # Issue #1936 — the singleton twin of {#supersede_def_nodes}: a definer that is not a `def` replaces the
5971
+ # singleton method an earlier `def self.x` of the walk installed, so the slot is contested and the typing read
5972
+ # declines instead of re-typing the replaced body. The definers, each written on the class's own singleton
5973
+ # class: `singleton_class.alias_method` / `define_method` / `remove_method` / `undef_method` / `attr_*` and its
5974
+ # `class_eval` family and `send` (bare, `self.` or a constant's `singleton_class`), `define_singleton_method`,
5975
+ # and in a `class << self` body the receiverless calls of the same names with `alias` and `undef`. Certain or
5976
+ # possible alike; a call whose rewritten names are not all literal contests every slot the owner holds so far.
5977
+ # Under an `instance_eval` split (`defs_singleton`) only `alias` and `undef` bind on the singleton; its calls
5978
+ # stay instance-side.
5979
+ def supersede_singleton_def_nodes(node, qualified_prefix, in_singleton_class, defs_singleton, accumulator,
5980
+ def_owner_prefix)
5981
+ return unless accumulator.is_a?(SingletonDefTable) && defs_singleton != :unnameable
5982
+
5983
+ owner_prefix = def_owner_prefix || qualified_prefix
5984
+ current = owner_prefix.empty? ? nil : owner_prefix.join("::")
5985
+ owner, names =
5986
+ case node
5987
+ when Prism::AliasMethodNode, Prism::UndefNode
5988
+ [current, singleton_keyword_names(node)] if in_singleton_class || defs_singleton == true
5989
+ when Prism::CallNode
5990
+ singleton_call_target(node, qualified_prefix, current, in_singleton_class)
5991
+ end
5992
+ accumulator.supersede(owner, names) if owner && names
5993
+ end
5994
+
5995
+ def singleton_keyword_names(node)
5996
+ return literal_names_or_all(node.names) if node.is_a?(Prism::UndefNode)
5997
+
5998
+ names = Source::AliasNames.keyword_names(node)
5999
+ names ? names.first(1) : :all
6000
+ end
6001
+
6002
+ # `[owner, names]` for a singleton definer call {#supersede_singleton_def_nodes} reads, or nil.
6003
+ def singleton_call_target(node, qualified_prefix, current, in_singleton_class)
6004
+ receiver = node.receiver
6005
+ if !in_singleton_class && singleton_class_of_receiver?(receiver)
6006
+ [singleton_class_owner(node, receiver.receiver, qualified_prefix, current), singleton_naming_call_names(node)]
6007
+ elsif receiver.nil? || receiver.is_a?(Prism::SelfNode)
6008
+ [current, in_singleton_class ? singleton_naming_call_names(node) : define_singleton_method_names(node)]
6009
+ end
6010
+ end
6011
+
6012
+ # The singleton names a naming call on the singleton class replaces, `:all` where they are not all literal,
6013
+ # or nil for another call. `attr_writer` replaces only `x=`, `attr_reader` only `x`. A `class_eval`-family or
6014
+ # `send` call may replace any name, so it is `:all`.
6015
+ def singleton_naming_call_names(node)
6016
+ arguments = node.arguments&.arguments || []
6017
+ case node.name
6018
+ when *SURFACE_EVAL_CALLS, *SURFACE_SEND_CALLS then :all
6019
+ when :alias_method then literal_names_or_all(arguments.first(1))
6020
+ when :define_method then define_singleton_method_names(node, :define_method)
6021
+ when :remove_method, :undef_method then literal_names_or_all(arguments)
6022
+ when *ATTR_MACROS
6023
+ names = literal_names_or_all(arguments)
6024
+ return names if names == :all
6025
+
6026
+ names.flat_map do |base|
6027
+ [(base unless node.name == :attr_writer), (:"#{base}=" unless node.name == :attr_reader)].compact
6028
+ end
6029
+ end
6030
+ end
6031
+
6032
+ def define_singleton_method_names(node, call = :define_singleton_method)
6033
+ return nil unless node.name == call
6034
+
6035
+ literal_names_or_all(node.arguments&.arguments&.first(1) || [])
6036
+ end
6037
+
6038
+ def literal_names_or_all(arguments)
6039
+ names = arguments.map { |argument| literal_method_name(argument) }
6040
+ names.empty? || names.any?(&:nil?) ? :all : names
5718
6041
  end
5719
6042
 
5720
6043
  # One singleton slot write, through {SingletonDefTable#write} when the walk tracks certainty.
5721
- def write_singleton_slot(accumulator, class_name, name, def_node, node, other = nil)
6044
+ def write_singleton_slot(accumulator, class_name, name, def_node, node, other = nil, possible: false)
5722
6045
  if accumulator.is_a?(SingletonDefTable)
5723
- accumulator.write(class_name, name, def_node, node, other)
6046
+ accumulator.write(class_name, name, def_node, node, other, possible: possible)
5724
6047
  else
5725
6048
  (accumulator[class_name] ||= {})[name] = def_node
5726
6049
  end
@@ -6617,32 +6940,34 @@ module Rigor
6617
6940
  # differently for the two spellings of one edge.
6618
6941
  def record_mixin_call(node, qualified_prefix, current_class, accumulator)
6619
6942
  taint_opaque_eval(node, current_class, accumulator)
6620
- kind, _arguments, via_send = mixin_call_view(node)
6621
- return if kind.nil?
6943
+ view = mixin_call_view(node)
6944
+ return if view.nil? || view.first.nil?
6622
6945
 
6946
+ kind = view.first
6623
6947
  current_class ||= toplevel_include_owner(node, qualified_prefix, current_class, accumulator)
6624
6948
  effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: false)
6625
6949
  .select { |_owner, side| side == :include }
6626
6950
  return if effects.empty?
6627
- return if !via_send && instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
6951
+ return if instance_mixin_recorded?(node, view, qualified_prefix, current_class, accumulator)
6628
6952
 
6629
6953
  # A mixin call the tables cannot hold still reshapes the ancestry: taint the whole owner side.
6630
6954
  effects.each { |owner, side| accumulator.taint(owner, side) }
6631
6955
  end
6632
6956
 
6633
6957
  # Records the instance-side edges of a call {#mixin_call_recorded?} accepts. False when it recorded
6634
- # nothing — the caller then taints the owner instead.
6635
- def instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
6636
- return false unless mixin_call_recorded?(node, current_class)
6958
+ # nothing — the caller then taints the owner instead. `view` is {#mixin_call_view}'s, so
6959
+ # `Integer.send(:include, Ext)` records `Ext` as `Integer.include(Ext)` does (issue #1722).
6960
+ def instance_mixin_recorded?(node, view, qualified_prefix, current_class, accumulator)
6961
+ kind, arguments, via_send = view
6962
+ return false unless mixin_call_recorded?(node, kind, via_send, current_class)
6637
6963
 
6638
- arguments = node.arguments&.arguments || []
6639
6964
  targets = arguments.filter_map { |arg| Source::ConstantPath.qualified_name(arg) }
6640
6965
  return false if targets.empty?
6641
6966
 
6642
6967
  owner = node.receiver.nil? ? current_class : prepend_call_receiver(node, qualified_prefix)
6643
6968
  return false if owner.nil?
6644
6969
 
6645
- write_mixin_targets(accumulator, owner, targets, prepend: node.name == :prepend)
6970
+ write_mixin_targets(accumulator, owner, targets, prepend: kind == :prepend)
6646
6971
  accumulator.note(node, owner, :include, targets, complete: targets.size == arguments.size)
6647
6972
  true
6648
6973
  end
@@ -6790,15 +7115,17 @@ module Rigor
6790
7115
  end
6791
7116
 
6792
7117
  # Whether a mixin call contributes to the tables at all: a receiverless `include` / `prepend` needs an
6793
- # enclosing declaration to file the edge under, and the RECEIVER form is recorded for `prepend` only
6794
- # — `Base.include(Loud)` is deliberately still unrecorded. Nothing needs it (this issue orders
6795
- # `prepend`, and the receiver form of `include` has no ordering question to answer) while recording
6796
- # it would widen what every consumer says about a class, which is scope this change does not own.
6797
- def mixin_call_recorded?(node, current_class)
6798
- return false unless MIXIN_CALL_NAMES.include?(node.name)
6799
- return !current_class.nil? if node.receiver.nil?
7118
+ # enclosing declaration to file the edge under, and the RECEIVER form (`Base.prepend(Loud)`, issue #1123;
7119
+ # `Integer.include(Ext)` and `Integer.send(:include, Ext)`, issue #1722) files it under the class the
7120
+ # receiver names. A receiver-form edge is unpositioned wherever it is written ({MixinAccumulator#note}), so
7121
+ # a reader that depends on ORDER declines on it, while the set-shaped readers (undefined-method
7122
+ # suppression, arity, visibility) see the module as they see an in-body `include`. A receiverless `send`
7123
+ # stays a taint: the walk does not resolve its `self`.
7124
+ def mixin_call_recorded?(node, kind, via_send, current_class)
7125
+ return false unless MIXIN_CALL_NAMES.include?(kind)
7126
+ return !via_send && !current_class.nil? if node.receiver.nil?
6800
7127
 
6801
- node.name == :prepend
7128
+ true
6802
7129
  end
6803
7130
 
6804
7131
  # Issue #1697 — a receiverless `include M` written as a statement of the file's own top level is
@@ -7004,7 +7331,9 @@ module Rigor
7004
7331
  def extends_block_handled?(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
7005
7332
  singleton_cref)
7006
7333
  case extends_block_plan(node, accumulator)
7007
- when :skip then true
7334
+ when :skip
7335
+ taint_skipped_singleton_rewrites(node.block, qualified_prefix, current_class, accumulator)
7336
+ true
7008
7337
  when :rebound
7009
7338
  walk_extends_block_call(node, qualified_prefix, current_class, accumulator,
7010
7339
  in_singleton, singleton_self, singleton_cref)
@@ -7013,6 +7342,31 @@ module Rigor
7013
7342
  end
7014
7343
  end
7015
7344
 
7345
+ # Issue #1906 — a block the extends walk skips may still run, and a `singleton_class` call in it, or in a `def`
7346
+ # it holds (`[1].each { D.singleton_class.alias_method :own, :other }` in a method body,
7347
+ # `to_prepare do def helper = D.singleton_class.alias_method(...) end`), reaches no mark the methods walk records
7348
+ # there. Every such call taints the side it may rewrite, as {#singleton_class_rewrite_tainted?} does for one the
7349
+ # walk visits; a constant receiver taints every name it can denote from here.
7350
+ def taint_skipped_singleton_rewrites(node, qualified_prefix, current_class, accumulator)
7351
+ return unless node.is_a?(Prism::Node)
7352
+
7353
+ if node.is_a?(Prism::CallNode) && singleton_class_of_receiver?(node.receiver) && surface_rewriting_call?(node)
7354
+ skipped_rewrite_owners(node.receiver.receiver, qualified_prefix, current_class).each do |owner|
7355
+ accumulator.taint(owner, :extend)
7356
+ end
7357
+ end
7358
+ node.rigor_each_child do |child|
7359
+ taint_skipped_singleton_rewrites(child, qualified_prefix, current_class, accumulator)
7360
+ end
7361
+ end
7362
+
7363
+ def skipped_rewrite_owners(object, qualified_prefix, current_class)
7364
+ return [current_class].compact if object.nil? || object.is_a?(Prism::SelfNode)
7365
+ return [] unless object.is_a?(Prism::ConstantReadNode) || object.is_a?(Prism::ConstantPathNode)
7366
+
7367
+ constant_receiver_candidates(object, qualified_prefix)
7368
+ end
7369
+
7016
7370
  ARRAY_ITERATORS = %i[each each_with_index map].freeze
7017
7371
  HASH_ITERATORS = %i[each each_pair each_key each_value map].freeze
7018
7372
  private_constant :ARRAY_ITERATORS, :HASH_ITERATORS
@@ -7228,10 +7582,15 @@ module Rigor
7228
7582
  # `Const.singleton_class.send(:include, M)`) puts methods or modules on that singleton class that no edge
7229
7583
  # records, so its singleton side is unpositioned: the owner is the enclosing class for a bare or `self.`
7230
7584
  # receiver, else the constant. True when it tainted.
7585
+ #
7586
+ # Issue #1906 — a naming call with literal names ({#singleton_class_named_call?}) marks only its names instead,
7587
+ # but only where it is a direct statement (a top-level statement, or one of a direct declaration body or of a
7588
+ # block that runs there once): there the methods walk records those marks under the same owner. In a method
7589
+ # body, a hook or a conditional it may run on a receiver or at a time the marks cannot place, so it still taints.
7231
7590
  def singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7232
7591
  receiver = node.receiver
7233
7592
  return false unless singleton_class_of_receiver?(receiver) && surface_rewriting_call?(node)
7234
- return false if singleton_class_named_call?(node)
7593
+ return false if singleton_class_named_call?(node) && accumulator.direct?(node)
7235
7594
 
7236
7595
  owner = singleton_class_owner(node, receiver.receiver, qualified_prefix, current_class)
7237
7596
  return false if owner.nil?
@@ -7245,15 +7604,37 @@ module Rigor
7245
7604
  receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil?
7246
7605
  end
7247
7606
 
7248
- # An `attr_*` on a singleton class (`singleton_class.attr_accessor :conf`) rewrites only the names it spells, so
7249
- # it makes those names' singleton envelopes opaque ({#record_singleton_class_names}) and leaves the rest of the
7250
- # singleton side readable. An `attr_*` with a name it does not spell as a literal (`attr_accessor(*KEYS)`, a
7251
- # block's variable), and an `alias_method` or `define_method` there, still taint the side: the own-definer read
7252
- # would otherwise answer the body Ruby replaced (`definer_resolution_witness_spec.rb`).
7607
+ # An `attr_*`, `alias_method`, `define_method` or `remove_method` on a singleton class
7608
+ # (`singleton_class.attr_accessor :conf`, `singleton_class.alias_method :o2, :own`) rewrites only the names it
7609
+ # spells, so it makes those names' singleton envelopes opaque and contested ({#record_singleton_class_names}) and
7610
+ # leaves the rest of the singleton side readable. One whose rewritten name is not a literal Symbol or String
7611
+ # (`attr_accessor(*KEYS)`, `alias_method name, :own`, `define_method(sym)`), and any other surface call there
7612
+ # (`class_eval`, `send`, `prepend`), still taints the side: the own-definer read would otherwise answer the body
7613
+ # Ruby replaced (`definer_resolution_witness_spec.rb`).
7253
7614
  def singleton_class_named_call?(node)
7615
+ !singleton_class_rewritten_names(node).nil?
7616
+ end
7617
+
7618
+ # Issue #1906 — the names a singleton-class call rewrites, or nil where it is not one of the naming forms or
7619
+ # does not spell every rewritten name as a literal. `alias_method` rewrites its new name (the aliased one keeps
7620
+ # its body), `define_method` its first argument, `remove_method` and `attr_*` every argument.
7621
+ def singleton_class_rewritten_names(node)
7254
7622
  arguments = node.arguments&.arguments || []
7255
- ATTR_MACROS.include?(node.name) && !arguments.empty? &&
7256
- arguments.all? { |argument| literal_method_name(argument) }
7623
+ rewritten = case node.name
7624
+ when :alias_method, :define_method then arguments.first(1)
7625
+ when :remove_method then arguments
7626
+ else ATTR_MACROS.include?(node.name) && arguments
7627
+ end
7628
+ return nil if !rewritten || arguments.empty? || !arguments.all? { |argument| literal_argument?(node, argument) }
7629
+
7630
+ rewritten.map { |argument| literal_method_name(argument) }
7631
+ end
7632
+
7633
+ # `define_method(:x, body)` takes a body that is not a name; every other argument must be a literal name.
7634
+ def literal_argument?(node, argument)
7635
+ return true if node.name == :define_method && !argument.equal?(node.arguments.arguments.first)
7636
+
7637
+ !literal_method_name(argument).nil?
7257
7638
  end
7258
7639
 
7259
7640
  def singleton_class_owner(node, object, qualified_prefix, current_class)
@@ -7447,19 +7828,17 @@ module Rigor
7447
7828
 
7448
7829
  # v0.1.2 — per-class method-visibility table for the `def.method-visibility-mismatch` CheckRule.
7449
7830
  #
7450
- # Tracks two visibility-changing forms:
7831
+ # Tracks three visibility-changing forms ({#apply_visibility_call}):
7832
+ #
7833
+ # - **Modifier blocks**: a bare `private` / `protected` / `public` call inside a class body (also written
7834
+ # `self.private`, `send(:private)` or with a block) switches the "current default" visibility for every
7835
+ # subsequent `def` and `attr_*` member until another modifier flips it again.
7836
+ # - **Named-argument form**: `private :foo, :bar` or `private %i[foo bar]` (or the same with `protected` /
7837
+ # `public`) marks specific names already recorded under the class.
7838
+ # - **Wrap-around form** (#1911, #1819, #1633): `private def foo` and `private attr_reader :foo` record the
7839
+ # members with the call's visibility and leave the default alone.
7451
7840
  #
7452
- # - **Modifier blocks**: a bare `private` / `protected` /
7453
- # `public` call inside a class body switches the
7454
- # "current default" visibility for every subsequent
7455
- # `def` until another modifier flips it again.
7456
- # - **Named-argument form**: `private :foo, :bar` (or
7457
- # the same with `protected` / `public`) marks specific
7458
- # names already-recorded under the class. Symbol-only
7459
- # args are recognised; `private def foo; end` (the
7460
- # wrap-around form) is not yet — it would need
7461
- # tracking the def-call's return-value visibility,
7462
- # which is a separate slice.
7841
+ # A `module_function` call takes each form as `private` does (#1569): Ruby makes the instance copy private.
7463
7842
  #
7464
7843
  # Top-level (no surrounding class) defs do not contribute — Ruby's top-level visibility nuances (private at
7465
7844
  # top-level marks the method on `Object`) are out of scope for v0.1.2.
@@ -7477,8 +7856,8 @@ module Rigor
7477
7856
  end
7478
7857
 
7479
7858
  # The bare calls that set the running default visibility of the body they run in: the modifiers and
7480
- # `module_function`, which {#walk_method_visibilities} does not track as a value (#1569) but which moves the
7481
- # default all the same.
7859
+ # `module_function`, which {#walk_method_visibilities} applies as `private` on the instance side
7860
+ # ({ModuleFunctionState.instance_visibility}, #1569).
7482
7861
  VISIBILITY_TOGGLES = (VISIBILITY_MODIFIERS + %i[module_function]).freeze
7483
7862
 
7484
7863
  # The `{class => {name => visibility}}` accumulator of {#walk_method_visibilities}, which also carries the
@@ -7516,6 +7895,39 @@ module Rigor
7516
7895
  end
7517
7896
  end
7518
7897
 
7898
+ # Contests the `[class_name, name]` slot, recorded or not: a visibility call changed it in a way the walk
7899
+ # cannot read (`private def self.x` reaching an instance `x`).
7900
+ def contest_slot(class_name, name)
7901
+ (@contested ||= Set.new) << [class_name, name].freeze
7902
+ end
7903
+
7904
+ # Contests every slot of `class_name` on `side` recorded so far (`private(*names)` may name any of them).
7905
+ # Issue #1984 — on the singleton side it also contests the class's wildcard slot
7906
+ # ({ScopeIndexer::SINGLETON_WILDCARD_SLOT}): the names may be ones another file records.
7907
+ def contest_class(class_name, side = :instance)
7908
+ self[class_name]&.each_key do |slot|
7909
+ contest_slot(class_name, slot) if slot.is_a?(Array) == (side == :singleton)
7910
+ end
7911
+ contest_slot(class_name, SINGLETON_WILDCARD_SLOT) if side == :singleton
7912
+ end
7913
+
7914
+ # Issue #1984 — records `visibility` in the slot of `name` on `side` ({ScopeIndexer.visibility_slot}) and
7915
+ # settles its contest as {#wrote} does.
7916
+ def write(class_name, name, side, visibility, node, toggled:)
7917
+ slot = ScopeIndexer.visibility_slot(name, side)
7918
+ (self[class_name] ||= {})[slot] = visibility
7919
+ wrote(class_name, slot, node, toggled: toggled)
7920
+ end
7921
+
7922
+ # Runs the block with the running default uncertain, so a `def` it records is contested.
7923
+ def uncertainly
7924
+ saved = @uncertain
7925
+ @uncertain = true
7926
+ yield
7927
+ ensure
7928
+ @uncertain = saved
7929
+ end
7930
+
7519
7931
  # Runs the block as a fresh body (a class or module, `class << self`, a meta-new or eval block), whose
7520
7932
  # default starts certain and whose toggles do not reach the enclosing body.
7521
7933
  def body
@@ -7537,10 +7949,37 @@ module Rigor
7537
7949
  end
7538
7950
  end
7539
7951
 
7540
- # A bare toggle ({VISIBILITY_TOGGLES}): receiverless, with no arguments.
7952
+ # A bare toggle ({VISIBILITY_TOGGLES}): a visibility call ({#visibility_call_parts}) with no arguments.
7541
7953
  def bare_visibility_toggle?(node)
7542
- node.is_a?(Prism::CallNode) && node.receiver.nil? && VISIBILITY_TOGGLES.include?(node.name) &&
7543
- (node.arguments.nil? || node.arguments.arguments.empty?) && node.block.nil?
7954
+ parts = visibility_call_parts(node)
7955
+ !parts.nil? && parts.last.empty?
7956
+ end
7957
+
7958
+ # Whether a visibility call with these arguments may set the running default: none at all, or a splat that
7959
+ # may expand to none (`private(*[])` is a bare `private`).
7960
+ def visibility_toggle_arguments?(arguments)
7961
+ arguments.empty? || arguments.any?(Prism::SplatNode)
7962
+ end
7963
+
7964
+ VISIBILITY_SEND_CALLS = %i[send __send__].freeze
7965
+ EMPTY_VISIBILITY_ARGUMENTS = [].freeze
7966
+ private_constant :VISIBILITY_SEND_CALLS, :EMPTY_VISIBILITY_ARGUMENTS
7967
+
7968
+ # Issue #1633 — `[name, arguments]` for a call Ruby runs as one of {VISIBILITY_TOGGLES} on the body's self:
7969
+ # written receiverless or on `self` (`self.private`), or sent with a literal name (`send(:private, :x)`,
7970
+ # `__send__(:private)`). A block is ignored, as `Module#private` ignores it (`private {}` is a bare
7971
+ # `private`). nil for any other call.
7972
+ def visibility_call_parts(node)
7973
+ return nil unless node.is_a?(Prism::CallNode)
7974
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
7975
+
7976
+ name = node.name
7977
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
7978
+ if VISIBILITY_SEND_CALLS.include?(name)
7979
+ name = Source::Literals.symbol_or_string(arguments.first)
7980
+ arguments = arguments.drop(1)
7981
+ end
7982
+ VISIBILITY_TOGGLES.include?(name) ? [name, arguments] : nil
7544
7983
  end
7545
7984
 
7546
7985
  # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
@@ -7594,26 +8033,70 @@ module Rigor
7594
8033
  return current_visibility
7595
8034
  end
7596
8035
  when Prism::DefNode
7597
- record_def_visibility(node, owner_prefix, in_singleton_class || defs_singleton,
8036
+ contest_foreign_singleton_visibility(node, qualified_prefix, owner_prefix, in_singleton_class, accumulator)
8037
+ record_def_visibility(node, owner_prefix, visibility_side(in_singleton_class, defs_singleton),
7598
8038
  current_visibility, accumulator)
7599
8039
  return current_visibility
7600
8040
  when Prism::CallNode
8041
+ record_constant_class_method_calls(node, qualified_prefix, accumulator)
7601
8042
  if receiver_eval_call?(node)
7602
8043
  walk_eval_visibilities(node, qualified_prefix, in_singleton_class, current_visibility,
7603
8044
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
7604
8045
  defs_singleton: defs_singleton)
7605
8046
  return current_visibility
7606
8047
  end
7607
- accumulator.toggles += 1 if bare_visibility_toggle?(node)
7608
- # The visibility table is instance-side only — `private :x` inside `class <<`
7609
- # (or a receiver-eval body that re-evaluates a singleton self) marks the
7610
- # SINGLETON method, which this table cannot express.
7611
- updated = if in_singleton_class
7612
- current_visibility
7613
- else
7614
- apply_visibility_call(node, owner_prefix, current_visibility, accumulator)
7615
- end
7616
- return updated unless updated.equal?(current_visibility)
8048
+ if AnonymousMetaClass.block_form_receiver(node)
8049
+ walk_anonymous_meta_visibilities(node, qualified_prefix, in_singleton_class, current_visibility,
8050
+ accumulator, def_owner_prefix, singleton_cref: singleton_cref,
8051
+ defs_singleton: defs_singleton)
8052
+ return current_visibility
8053
+ end
8054
+ if (parts = visibility_call_parts(node))
8055
+ accumulator.toggles += 1 if visibility_toggle_arguments?(parts.last)
8056
+ # A `module_function` call is applied as a `private` one (#1569): it makes the instance copy private.
8057
+ visibility = parts.first
8058
+ visibility = ModuleFunctionState.instance_visibility unless VISIBILITY_MODIFIERS.include?(visibility)
8059
+ # Issue #1984 — inside `class << self` (or a `class_eval` on the singleton class) the call marks the
8060
+ # SINGLETON methods, recorded under their `[:singleton, name]` slots. `module_function` is no method of
8061
+ # a singleton class, so it is walked as any other call there. The `owner_prefix` check is a guard: an
8062
+ # ownerless body (the top level, an anonymous factory block) records nothing, and its calls walk as
8063
+ # any other call.
8064
+ # An `instance_eval` body's call (`:eval`) runs on the receiver as a module: the instance side.
8065
+ side = visibility_side(in_singleton_class, defs_singleton)
8066
+ side = :instance if side == :eval
8067
+ if side && !owner_prefix.empty? && (side == :instance || VISIBILITY_MODIFIERS.include?(parts.first))
8068
+ return apply_visibility_call(node, visibility, parts.last, owner_prefix, current_visibility, accumulator,
8069
+ side: side, defs_singleton: defs_singleton) do |argument|
8070
+ walk_method_visibilities(argument, qualified_prefix, in_singleton_class, current_visibility,
8071
+ accumulator, def_owner_prefix, singleton_cref: singleton_cref,
8072
+ defs_singleton: defs_singleton)
8073
+ end
8074
+ end
8075
+ end
8076
+ if !in_singleton_class && !owner_prefix.empty?
8077
+ class_method_parts = class_method_visibility_parts(node)
8078
+ if class_method_parts
8079
+ apply_class_method_visibility(node, *class_method_parts, owner_prefix, accumulator) do |argument|
8080
+ walk_method_visibilities(argument, qualified_prefix, in_singleton_class, current_visibility,
8081
+ accumulator, def_owner_prefix, singleton_cref: singleton_cref,
8082
+ defs_singleton: defs_singleton)
8083
+ end
8084
+ return current_visibility
8085
+ end
8086
+ contest_singleton_rewrites(node, owner_prefix, accumulator)
8087
+ end
8088
+ side = visibility_side(in_singleton_class, defs_singleton)
8089
+ if %i[instance singleton].include?(side)
8090
+ record_attr_visibility(node, owner_prefix, current_visibility, accumulator, toggled: true, side: side)
8091
+ contest_replaced_visibility(replaced_by_call(node), owner_prefix, accumulator, side: side)
8092
+ end
8093
+ when Prism::AliasMethodNode
8094
+ side = visibility_side(in_singleton_class, defs_singleton)
8095
+ if %i[instance singleton].include?(side)
8096
+ contest_replaced_visibility(Array(Source::AliasNames.keyword_names(node)&.first), owner_prefix,
8097
+ accumulator, side: side)
8098
+ end
8099
+ return current_visibility
7617
8100
  end
7618
8101
 
7619
8102
  # Statement-position StatementsNode preserves left-to-right visibility flow; everything else recurses with the
@@ -7667,6 +8150,23 @@ module Rigor
7667
8150
  end
7668
8151
  end
7669
8152
 
8153
+ # A `Class.new` / `Module.new` / `Struct.new` / `Data.define` block no constant write names
8154
+ # ({#meta_new_block_split} takes the named ones): `klass = Class.new do … end`, `@k = Struct.new(:a) do … end`,
8155
+ # `register(Class.new do … end)` or a bare `Data.define(:a) do … end`. Its body is the anonymous class's, so
8156
+ # it walks as a fresh body with no owner and records nothing for the enclosing class; the receiver and the
8157
+ # arguments are walked where they stand.
8158
+ def walk_anonymous_meta_visibilities(node, qualified_prefix, in_singleton_class, current_visibility,
8159
+ accumulator, def_owner_prefix, singleton_cref:, defs_singleton:) # rubocop:disable Metrics/ParameterLists
8160
+ [node.receiver, *node.arguments&.arguments].each do |part|
8161
+ walk_method_visibilities(part, qualified_prefix, in_singleton_class, current_visibility, accumulator,
8162
+ def_owner_prefix, singleton_cref: singleton_cref, defs_singleton: defs_singleton)
8163
+ end
8164
+ accumulator.body do
8165
+ walk_method_visibilities(node.block.body, qualified_prefix, false, :public, accumulator,
8166
+ EMPTY_PREFIX, singleton_cref: singleton_cref)
8167
+ end
8168
+ end
8169
+
7670
8170
  # The eval-block arm of {#walk_method_visibilities}: the block is a fresh class body under
7671
8171
  # the receiver's def-owner — visibility starts at `:public`, and nothing inside it leaks
7672
8172
  # back to this body's modifier state. An unnameable receiver walks the body ownerless;
@@ -7680,6 +8180,9 @@ module Rigor
7680
8180
  singleton_cref: singleton_cref,
7681
8181
  defs_singleton: defs_singleton)
7682
8182
  end
8183
+ return if singleton_class_eval_walked?(node, qualified_prefix, in_singleton_class, accumulator,
8184
+ def_owner_prefix, singleton_cref, defs_singleton)
8185
+
7683
8186
  node.arguments&.arguments&.each do |arg|
7684
8187
  walk_method_visibilities(arg, qualified_prefix, in_singleton_class, current_visibility,
7685
8188
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
@@ -7704,48 +8207,201 @@ module Rigor
7704
8207
  end
7705
8208
  end
7706
8209
 
7707
- def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
7708
- return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
7709
- return if qualified_prefix.empty?
8210
+ # Issue #1984 — `singleton_class.class_eval do … end` in a class body (or `module_eval` / `class_exec` /
8211
+ # `module_exec`) runs its block as a `class << self` body does: its `def`s and visibility calls land on the
8212
+ # class's singleton slots. True when it walked the block so.
8213
+ def singleton_class_eval_walked?(node, qualified_prefix, in_singleton_class, accumulator,
8214
+ def_owner_prefix, singleton_cref, defs_singleton)
8215
+ return false if in_singleton_class || defs_singleton || INSTANCE_EVAL_CALLS.include?(node.name)
8216
+ return false unless own_singleton_class?(node.receiver) && node.arguments.nil?
8217
+
8218
+ owner_prefix = def_owner_prefix || qualified_prefix
8219
+ return false if owner_prefix.empty?
8220
+
8221
+ if node.block.body
8222
+ accumulator.body do
8223
+ walk_method_visibilities(node.block.body, qualified_prefix, true, :public, accumulator, owner_prefix,
8224
+ singleton_cref: singleton_cref)
8225
+ end
8226
+ end
8227
+ true
8228
+ end
8229
+
8230
+ # Issue #1984 — the slot a contest stands on for every singleton name of a class: a call that may make any of
8231
+ # them public (`K.public_class_method(*names)`, `K.singleton_class.class_eval { … }`).
8232
+ SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
8233
+
8234
+ # Issue #1984 — a class method visibility call written on a constant (`K.public_class_method(:x)`,
8235
+ # `K.send(:public_class_method, :x)`), anywhere: each literal name is recorded public on every class the
8236
+ # constant may name, so a reopening's or another file's `private_class_method` disagrees with it and is
8237
+ # contested. A `K.private_class_method` is not read (the rule only declines more where it is missing). Any call
8238
+ # on `K.singleton_class` (`send(:public, :x)`, `class_eval { … }`), or a `public_class_method` whose names are
8239
+ # not literal, contests every singleton name of the class.
8240
+ def record_constant_class_method_calls(node, qualified_prefix, accumulator)
8241
+ receiver = node.receiver
8242
+ if singleton_class_of_receiver?(receiver) && constant_receiver?(receiver.receiver)
8243
+ each_constant_class(receiver.receiver, qualified_prefix) do |class_name|
8244
+ accumulator.contest_class(class_name, :singleton)
8245
+ end
8246
+ return
8247
+ end
8248
+ names = constant_public_class_method_names(node)
8249
+ return if names.nil?
8250
+
8251
+ each_constant_class(receiver, qualified_prefix) do |class_name|
8252
+ next accumulator.contest_class(class_name, :singleton) if names.any?(&:nil?)
8253
+
8254
+ names.each do |method_name|
8255
+ accumulator.write(class_name, method_name, :singleton, :public, node, toggled: false)
8256
+ end
8257
+ end
8258
+ end
8259
+
8260
+ # The names a `K.public_class_method(…)` (or `K.send(:public_class_method, …)`) call on a constant names, a nil
8261
+ # for each one that is not a literal; nil for any other call.
8262
+ def constant_public_class_method_names(node)
8263
+ return nil unless constant_receiver?(node.receiver)
8264
+
8265
+ name = node.name
8266
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
8267
+ if VISIBILITY_SEND_CALLS.include?(name) || name == :public_send
8268
+ name = Source::Literals.symbol_or_string(arguments.first)
8269
+ arguments = arguments.drop(1)
8270
+ end
8271
+ return nil unless name == :public_class_method
8272
+
8273
+ arguments.flat_map { |arg| arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg] }
8274
+ .map { |arg| Source::Literals.symbol_or_string(arg) }
8275
+ end
8276
+
8277
+ def constant_receiver?(node)
8278
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
8279
+ end
8280
+
8281
+ def each_constant_class(receiver, qualified_prefix, &)
8282
+ constant_receiver_candidates(receiver, qualified_prefix).each(&)
8283
+ end
8284
+
8285
+ # Issue #1984 — the side a body's members land on: `:singleton` in a `class << self` body or a `class_eval` on
8286
+ # the singleton class, `:eval` in an `instance_eval` body on a class (its `def`s are singleton methods, its
8287
+ # visibility calls instance-side), `:instance` in a class body, and nil where the singleton cannot be named.
8288
+ def visibility_side(in_singleton_class, defs_singleton)
8289
+ return defs_singleton == :unnameable ? nil : :singleton if in_singleton_class
8290
+ return :eval if defs_singleton
8291
+
8292
+ :instance
8293
+ end
8294
+
8295
+ # Issue #1984 — the key of `name`'s slot in a class's visibility table: the name on the instance side,
8296
+ # `[:singleton, name]` on the singleton side (the envelope table's spelling).
8297
+ def visibility_slot(name, side)
8298
+ side == :singleton ? [:singleton, name].freeze : name
8299
+ end
8300
+
8301
+ # Records the visibility a `def` gets ({#visibility_side} `side`):
8302
+ #
8303
+ # - a receiverless `def` takes `current_visibility` on its side; `toggled` when that is the running default
8304
+ # (a statement), false for one a visibility call wraps;
8305
+ # - issue #1984 — `def self.x` (or `def K.x` in `K`'s body) is a public singleton method whatever the running
8306
+ # default is (`private; def self.x` is public), and writes nothing in a singleton body (it defines a method
8307
+ # of the singleton's singleton);
8308
+ # - an `instance_eval` body's `def` (`:eval`) replaces a singleton method with a visibility the walk does not
8309
+ # read, so its singleton slot is contested.
8310
+ def record_def_visibility(def_node, qualified_prefix, side, current_visibility, accumulator, toggled: true)
8311
+ return if side.nil? || qualified_prefix.empty?
7710
8312
 
7711
8313
  class_name = qualified_prefix.join("::")
7712
- accumulator[class_name] ||= {}
7713
- accumulator[class_name][def_node.name] = current_visibility
7714
- accumulator.wrote(class_name, def_node.name, def_node, toggled: true)
8314
+ if side == :eval
8315
+ accumulator.contest_slot(class_name, visibility_slot(def_node.name, :singleton))
8316
+ elsif def_node.receiver.nil?
8317
+ accumulator.write(class_name, def_node.name, side, current_visibility, def_node, toggled: toggled)
8318
+ elsif side == :instance && self_singleton_def?(def_node, qualified_prefix)
8319
+ accumulator.write(class_name, def_node.name, :singleton, :public, def_node, toggled: false)
8320
+ end
7715
8321
  end
7716
8322
 
7717
- # Recognises modifier calls on the implicit-self receiver inside a class body. Returns the (possibly updated)
7718
- # current visibility:
8323
+ def self_singleton_def?(def_node, qualified_prefix)
8324
+ def_node.receiver.is_a?(Prism::SelfNode) || def_receiver_targets_lexical_self?(def_node.receiver,
8325
+ qualified_prefix)
8326
+ end
8327
+
8328
+ # Issue #1984 — `def Other.x` whose receiver is not the lexical self (`def_owner`) makes `Other.x` public in
8329
+ # whatever order the files load, so each class the constant may name has its singleton slot contested.
8330
+ def contest_foreign_singleton_visibility(def_node, qualified_prefix, def_owner, in_singleton_class, accumulator)
8331
+ receiver = def_node.receiver
8332
+ return unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
8333
+ return if !in_singleton_class && def_receiver_targets_lexical_self?(receiver, def_owner)
8334
+
8335
+ constant_receiver_candidates(receiver, qualified_prefix).each do |name|
8336
+ accumulator.contest_slot(name, visibility_slot(def_node.name, :singleton))
8337
+ end
8338
+ end
8339
+
8340
+ # Applies one `private` / `protected` / `public` call ({#visibility_call_parts}) written on the body's self
8341
+ # and returns the running default after it. `side` is the side its names land on: `:singleton` in a
8342
+ # `class << self` body (issue #1984), `:instance` elsewhere.
7719
8343
  #
7720
- # - `private` / `public` / `protected` (no args) —
7721
- # switch the running default for subsequent defs.
7722
- # - `private :foo, :bar` — back-patch the named methods
7723
- # in the accumulator. Returns `current_visibility`
7724
- # unchanged because the running default does NOT
7725
- # change for this form.
7726
- def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator)
7727
- return current_visibility unless call_node.receiver.nil?
7728
- return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name)
7729
- return current_visibility if qualified_prefix.empty?
8344
+ # - no arguments — the call's visibility becomes the default for subsequent defs.
8345
+ # - `private :foo, :bar`, `private %i[foo bar]` — back-patch the named methods in the accumulator; the
8346
+ # default does NOT change for this form.
8347
+ # - Issues #1911, #1819, #1633 — `private def foo` (endless or not) and `private attr_reader :foo` define
8348
+ # the members with the call's visibility, whatever the running default is (`private; public def foo` is
8349
+ # public); `private def self.foo` changes the method `foo` of the side, if one exists, so its slot is
8350
+ # contested, and defines a public singleton `foo` (a singleton-singleton one in a `class << self` body).
8351
+ # - a splat (`private(*names)`) may name any member or none (a bare toggle), so the class's slots on the side
8352
+ # this file wrote so far are contested, and the statement loop reads the default after it as uncertain
8353
+ # ({#visibility_toggle_arguments?}).
8354
+ # - any other argument (`private memoize def foo`, `private delegate(...)`) is walked with the default
8355
+ # uncertain, so a `def` under it is contested: the call's visibility reaches it through a return value
8356
+ # the walk does not read.
8357
+ #
8358
+ # `walk` walks an argument the call does not consume itself.
8359
+ def apply_visibility_call(call_node, name, args, qualified_prefix, current_visibility, accumulator, # rubocop:disable Metrics/ParameterLists
8360
+ side: :instance, defs_singleton: false, &walk)
8361
+ return name if args.empty?
7730
8362
 
7731
- args = call_node.arguments&.arguments || []
7732
- if args.empty?
7733
- call_node.name
8363
+ class_name = qualified_prefix.join("::")
8364
+ accumulator.contest_class(class_name, side) if args.any?(Prism::SplatNode)
8365
+ args.each do |arg|
8366
+ apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator,
8367
+ side: side, defs_singleton: defs_singleton, &walk)
8368
+ end
8369
+ current_visibility
8370
+ end
8371
+
8372
+ def apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator, side:, defs_singleton:, &walk)
8373
+ case arg
8374
+ when Prism::DefNode
8375
+ if arg.receiver.nil?
8376
+ def_side = side == :instance && defs_singleton ? :eval : side
8377
+ record_def_visibility(arg, qualified_prefix, def_side, name, accumulator, toggled: false)
8378
+ else
8379
+ accumulator.contest_slot(qualified_prefix.join("::"), visibility_slot(arg.name, side))
8380
+ record_def_visibility(arg, qualified_prefix, defs_singleton ? :eval : side, name, accumulator,
8381
+ toggled: false)
8382
+ end
8383
+ when Prism::SymbolNode, Prism::StringNode, Prism::ArrayNode
8384
+ apply_named_visibility(arg, name, qualified_prefix, call_node, accumulator, side: side, &walk)
7734
8385
  else
7735
- apply_named_visibility(args, qualified_prefix, call_node, accumulator)
7736
- current_visibility
8386
+ # An `instance_eval` body's wrapped attr is walked like any other argument and records nothing.
8387
+ if !defs_singleton && attr_visibility_call?(arg)
8388
+ record_attr_visibility(arg, qualified_prefix, name, accumulator, toggled: false, side: side)
8389
+ else
8390
+ accumulator.uncertainly { walk.call(arg) }
8391
+ end
7737
8392
  end
7738
8393
  end
7739
8394
 
7740
- def apply_named_visibility(args, qualified_prefix, call_node, accumulator)
8395
+ # `private :foo` / `private "foo"` / `private %i[foo bar]`: each literal name the call's visibility. An array
8396
+ # element that is not a literal name is walked like any other argument.
8397
+ def apply_named_visibility(arg, visibility, qualified_prefix, call_node, accumulator, side: :instance, &walk)
8398
+ elements = arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg]
7741
8399
  class_name = qualified_prefix.join("::")
7742
- args.each do |arg|
7743
- name = visibility_target_name(arg)
7744
- next if name.nil?
8400
+ elements.each do |element|
8401
+ name = visibility_target_name(element)
8402
+ next accumulator.uncertainly { walk.call(element) } if name.nil?
7745
8403
 
7746
- accumulator[class_name] ||= {}
7747
- accumulator[class_name][name] = call_node.name
7748
- accumulator.wrote(class_name, name, call_node, toggled: false)
8404
+ accumulator.write(class_name, name, side, visibility, call_node, toggled: false)
7749
8405
  end
7750
8406
  end
7751
8407
 
@@ -7753,6 +8409,135 @@ module Rigor
7753
8409
  Source::Literals.symbol_or_string(arg)
7754
8410
  end
7755
8411
 
8412
+ CLASS_METHOD_VISIBILITIES = { private_class_method: :private, public_class_method: :public }.freeze
8413
+ private_constant :CLASS_METHOD_VISIBILITIES
8414
+
8415
+ # Issue #1984 — `[visibility, arguments]` for a `private_class_method` / `public_class_method` call written on
8416
+ # the body's self, in the spellings {#visibility_call_parts} reads (receiverless, `self.`, `send` with a
8417
+ # literal name); nil for any other call.
8418
+ def class_method_visibility_parts(node)
8419
+ return nil unless node.is_a?(Prism::CallNode)
8420
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
8421
+
8422
+ name = node.name
8423
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
8424
+ if VISIBILITY_SEND_CALLS.include?(name)
8425
+ name = Source::Literals.symbol_or_string(arguments.first)
8426
+ arguments = arguments.drop(1)
8427
+ end
8428
+ visibility = CLASS_METHOD_VISIBILITIES[name]
8429
+ visibility && [visibility, arguments]
8430
+ end
8431
+
8432
+ # Issue #1984 — applies `private_class_method` / `public_class_method` to the class's singleton slots. It sets
8433
+ # no running default (with no arguments it does nothing). A literal name (`:x`, `"x"`, `[:x]`) and a wrapped
8434
+ # `def self.x` take the call's visibility; a wrapped receiverless `def x` defines an instance method (walked as
8435
+ # a statement) and names the singleton `x` the walk does not see, as does any other receiver, so that slot is
8436
+ # contested; a splat or any other argument may name any singleton method, so every singleton slot the file
8437
+ # wrote so far for the class is contested, and the argument is walked.
8438
+ def apply_class_method_visibility(call_node, visibility, args, qualified_prefix, accumulator, &walk)
8439
+ class_name = qualified_prefix.join("::")
8440
+ args.each do |arg|
8441
+ case arg
8442
+ when Prism::DefNode
8443
+ if arg.receiver && self_singleton_def?(arg, qualified_prefix)
8444
+ accumulator.write(class_name, arg.name, :singleton, visibility, call_node, toggled: false)
8445
+ else
8446
+ walk.call(arg)
8447
+ accumulator.contest_slot(class_name, visibility_slot(arg.name, :singleton))
8448
+ end
8449
+ when Prism::SymbolNode, Prism::StringNode, Prism::ArrayNode
8450
+ apply_named_visibility(arg, visibility, qualified_prefix, call_node, accumulator, side: :singleton) do |el|
8451
+ accumulator.contest_class(class_name, :singleton)
8452
+ walk.call(el)
8453
+ end
8454
+ else
8455
+ accumulator.contest_class(class_name, :singleton)
8456
+ walk.call(arg)
8457
+ end
8458
+ end
8459
+ end
8460
+
8461
+ SINGLETON_DEFINING_CALLS = %i[define_singleton_method].freeze
8462
+ private_constant :SINGLETON_DEFINING_CALLS
8463
+
8464
+ # Issue #1984 — calls in a class body that change a singleton method's visibility in a way the walk does not
8465
+ # read, so the singleton slots they may reach are contested: `define_singleton_method(:x)` (a fresh public
8466
+ # method; a computed name contests every singleton slot of the class), and any call on `singleton_class`
8467
+ # (`singleton_class.send(:public, :x)`, `singleton_class.define_method(:x)`), whose literal arguments name
8468
+ # the slots and whose other arguments may name any.
8469
+ def contest_singleton_rewrites(node, qualified_prefix, accumulator)
8470
+ on_singleton_class = own_singleton_class?(node.receiver)
8471
+ return unless on_singleton_class || node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
8472
+ return unless on_singleton_class || SINGLETON_DEFINING_CALLS.include?(node.name)
8473
+
8474
+ class_name = qualified_prefix.join("::")
8475
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
8476
+ arguments = arguments.first(1) if SINGLETON_DEFINING_CALLS.include?(node.name)
8477
+ arguments.each do |argument|
8478
+ name = Source::Literals.symbol_or_string(argument)
8479
+ if name
8480
+ accumulator.contest_slot(class_name, visibility_slot(name, :singleton))
8481
+ else
8482
+ accumulator.contest_class(class_name, :singleton)
8483
+ end
8484
+ end
8485
+ end
8486
+
8487
+ # `singleton_class` or `self.singleton_class`: the body's own singleton class.
8488
+ def own_singleton_class?(receiver)
8489
+ singleton_class_of_receiver?(receiver) && (receiver.receiver.nil? || receiver.receiver.is_a?(Prism::SelfNode))
8490
+ end
8491
+
8492
+ # Issue #1911 — an `attr_reader` / `attr_writer` / `attr_accessor` call written on the body's self records
8493
+ # each member it defines (`x`, `x=`) with `visibility`: the running default for a statement (`toggled`), the
8494
+ # wrapping call's for `private attr_accessor :x`, which Ruby applies to the `[:x, :x=]` the macro returns.
8495
+ # A non-literal name (`attr_reader(*FIELDS)`) records nothing; a call that is no such macro records nothing.
8496
+ # Issue #1989 — the names a `define_method(:x)` or an `alias_method :x, :y` call replaces. An `attr_*` call is
8497
+ # not among them: it writes its members' slots itself ({#record_attr_visibility}).
8498
+ def replaced_by_call(call_node)
8499
+ return define_method_superseded_name(call_node) if call_node.name == :define_method
8500
+
8501
+ Source::AliasNames.alias_method_call_names(call_node)&.first(1) || []
8502
+ end
8503
+
8504
+ # Issue #1989 — a `define_method` or an alias that replaces a method whose visibility slot this walk recorded
8505
+ # gives the name a visibility the walk does not read (the running default for `define_method`, the old
8506
+ # name's for an alias), so the slot is contested rather than left asserting the replaced `def`'s. Issue #1984
8507
+ # — on the singleton side the slot is contested whether or not this file recorded it, since another file may
8508
+ # have.
8509
+ def contest_replaced_visibility(names, qualified_prefix, accumulator, side: :instance)
8510
+ return if names.empty? || qualified_prefix.empty?
8511
+
8512
+ class_name = qualified_prefix.join("::")
8513
+ recorded = accumulator[class_name]
8514
+ names.each do |name|
8515
+ slot = visibility_slot(name, side)
8516
+ accumulator.contest_slot(class_name, slot) if side == :singleton || recorded&.key?(slot)
8517
+ end
8518
+ end
8519
+
8520
+ def record_attr_visibility(call_node, qualified_prefix, visibility, accumulator, toggled:, side: :instance)
8521
+ macro = attr_macro(call_node)
8522
+ return if macro.nil? || qualified_prefix.empty?
8523
+
8524
+ class_name = qualified_prefix.join("::")
8525
+ each_attr_macro_name(call_node) do |base|
8526
+ members = []
8527
+ members << base unless macro == :attr_writer
8528
+ members << :"#{base}=" unless macro == :attr_reader
8529
+ members.each do |member|
8530
+ accumulator.write(class_name, member, side, visibility, call_node, toggled: toggled)
8531
+ end
8532
+ end
8533
+ end
8534
+
8535
+ # An `attr_reader` / `attr_writer` / `attr_accessor` call on the body's self ({#attr_macro},
8536
+ # {#record_attr_visibility}). Ruby 4.0.5 gives every spelling the running default visibility.
8537
+ def attr_visibility_call?(node)
8538
+ !attr_macro(node).nil?
8539
+ end
8540
+
7756
8541
  # Registers the alias name in the `discovered_methods` table so `undefined-method` diagnostics are not emitted for
7757
8542
  # calls to the aliased name. The kind mirrors the surrounding class context (instance inside a regular class body,
7758
8543
  # singleton inside `class << self`).
@@ -7858,21 +8643,21 @@ module Rigor
7858
8643
  end
7859
8644
 
7860
8645
  # Issue #1752 — the names a {#singleton_class_named_call?} rewrites on its singleton class's owner, each
7861
- # singleton envelope opaque: the evidence the refinement typer reads as a rewritten singleton name.
8646
+ # singleton envelope opaque and contested: the evidence the refinement typer reads as a rewritten singleton name.
7862
8647
  def record_singleton_class_names(node, object, qualified_prefix, tables)
7863
8648
  current_class = qualified_prefix.empty? ? nil : qualified_prefix.join("::")
7864
8649
  owner = singleton_class_owner(node, object, qualified_prefix, current_class)
7865
8650
  return if owner.nil?
7866
8651
 
7867
- (node.arguments&.arguments || []).each do |argument|
7868
- method_name = literal_method_name(argument)
7869
- next unless method_name
7870
-
7871
- names = [method_name]
8652
+ singleton_class_rewritten_names(node).each do |method_name|
8653
+ # `attr_writer :x` defines `x=` alone (#1936), so `x` keeps its own `def`.
8654
+ names = node.name == :attr_writer ? [] : [method_name]
7872
8655
  names << :"#{method_name}=" if %i[attr_writer attr_accessor].include?(node.name)
7873
8656
  names.each do |name|
7874
8657
  record_envelope(tables.envelopes, owner, [:singleton, name], Source::ParameterEnvelope::OPAQUE)
7875
- contest_envelope(tables, owner, [:singleton, name]) unless tables.certain
8658
+ # Contested even where the call is certain: the slot's `def` is not the body Ruby runs, so the
8659
+ # own-definer read declines on the name rather than answering it.
8660
+ contest_envelope(tables, owner, [:singleton, name])
7876
8661
  end
7877
8662
  end
7878
8663
  end
@@ -8209,8 +8994,9 @@ module Rigor
8209
8994
  return if qualified_prefix.empty?
8210
8995
 
8211
8996
  kind = in_singleton_class ? :singleton : :instance
8212
- reader = call_node.name != :attr_writer
8213
- writer = call_node.name != :attr_reader
8997
+ macro = attr_macro(call_node)
8998
+ reader = macro != :attr_writer
8999
+ writer = macro != :attr_reader
8214
9000
  class_name = qualified_prefix.join("::")
8215
9001
  each_attr_macro_name(call_node) do |base|
8216
9002
  record_method(accumulator, class_name, base, kind) if reader
@@ -8218,18 +9004,34 @@ module Rigor
8218
9004
  end
8219
9005
  end
8220
9006
 
8221
- # Yields each name an `attr_*` call declares: a Symbol or String literal argument. A non-literal one
8222
- # (`attr_writer(*names)`) names nothing Rigor can read.
9007
+ # Yields each name an `attr_*` call declares ({#attr_macro}): a Symbol or String literal argument. A
9008
+ # non-literal one (`attr_writer(*names)`, `send(:attr_writer, name)`) names nothing Rigor can read.
8223
9009
  def each_attr_macro_name(call_node)
8224
- return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
8225
- return if call_node.arguments.nil?
9010
+ return if attr_macro(call_node).nil? || call_node.arguments.nil?
8226
9011
 
8227
- call_node.arguments.arguments.each do |arg|
9012
+ arguments = call_node.arguments.arguments
9013
+ arguments = arguments.drop(1) if SURFACE_SEND_CALLS.include?(call_node.name)
9014
+ arguments.each do |arg|
8228
9015
  base = literal_method_name(arg)
8229
9016
  yield base unless base.nil?
8230
9017
  end
8231
9018
  end
8232
9019
 
9020
+ # Issue #2097 — the {ATTR_MACROS} entry a call declares members with on the lexical class, or nil. The
9021
+ # call is on the body's self, implicitly or as `self.` (`self.attr_accessor :x`), and names the macro
9022
+ # directly or as the literal first argument of `send` / `public_send` / `__send__`
9023
+ # (`send(:attr_accessor, :x)`); the same spellings `SigGen::Generator#attr_writer_names` reads. Any
9024
+ # other receiver defines on another object.
9025
+ def attr_macro(call_node)
9026
+ return nil unless call_node.is_a?(Prism::CallNode)
9027
+ return nil unless call_node.receiver.nil? || call_node.receiver.is_a?(Prism::SelfNode)
9028
+ return call_node.name if ATTR_MACROS.include?(call_node.name)
9029
+ return nil unless SURFACE_SEND_CALLS.include?(call_node.name)
9030
+
9031
+ macro = literal_method_name(call_node.arguments&.arguments&.first)
9032
+ macro if ATTR_MACROS.include?(macro)
9033
+ end
9034
+
8233
9035
  # Issue #736 — the module-level accessor macros, which introduce methods on BOTH sides of the class.
8234
9036
  # ActiveSupport defines them on `Module`, so every class and module body can use them, and Rails codebases
8235
9037
  # do: `mattr_accessor` / `cattr_accessor` (an alias of it) and `class_attribute` account for 92 false
@@ -8351,12 +9153,14 @@ module Rigor
8351
9153
  # {#discovered_project_index_incremental}'s changed-file branch) yields the live def nodes the signature
8352
9154
  # reads their parameter structure from. Issue #1120 — and each file's own refinement table, which the merged
8353
9155
  # def-index cannot attribute back to a file, so the session can diff it against the file's seed bundle.
8354
- # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements: }`.
9156
+ # Issue #1459 — and each file's {#defined_method_names}, diffed the same way.
9157
+ # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements:, defined_method_names: }`.
8355
9158
  def scan_summary_for_paths(paths, buffer: nil)
8356
9159
  acc = new_def_index_accumulator
8357
9160
  code_fingerprints = {}
8358
9161
  declaration_signatures = {}
8359
9162
  refinements = {}
9163
+ defined_method_names = {}
8360
9164
  paths.each do |path|
8361
9165
  physical = buffer ? buffer.resolve(path) : path
8362
9166
  source = File.read(physical)
@@ -8366,13 +9170,29 @@ module Rigor
8366
9170
  code_fingerprints[path] = code_fingerprint(source, parsed.comments)
8367
9171
  declaration_signatures[path] = declaration_signature(file_index)
8368
9172
  refinements[path] = file_index[:refinements] if file_index[:refinements]
9173
+ defined_method_names[path] = defined_method_names(file_index)
8369
9174
  rescue DeclarationWalk::ContractError
8370
9175
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
8371
9176
  rescue StandardError
8372
9177
  next
8373
9178
  end
8374
9179
  { def_index: finalize_def_index(acc), code_fingerprints: code_fingerprints,
8375
- declaration_signatures: declaration_signatures, refinements: refinements }
9180
+ declaration_signatures: declaration_signatures, refinements: refinements,
9181
+ defined_method_names: defined_method_names }
9182
+ end
9183
+
9184
+ # Issue #1459 — every method name one file's index defines, on any class or module and either side: the keys of
9185
+ # `methods` (which also carries an `attr_*`, `alias` or `define_method` name) and of the instance and singleton
9186
+ # def-node tables. A single file's `methods` already holds its `def` names, since `subtract_def_methods` runs only
9187
+ # on the merged accumulator; the def-node tables are read as well so the census reads exactly the tables
9188
+ # `BlockCallTiming.project_defines_anywhere?` reads. Takes a live file index or a seed bundle, which hold the
9189
+ # same three keys.
9190
+ def defined_method_names(file_index)
9191
+ names = Set.new
9192
+ %i[methods def_nodes singleton_def_nodes].each do |key|
9193
+ file_index[key]&.each_value { |table| names.merge(table.keys) }
9194
+ end
9195
+ names.freeze
8376
9196
  end
8377
9197
 
8378
9198
  # ADR-89 WD1 — a per-file digest of every cross-file DECLARATION surface an ancestry / file-level
@@ -8748,7 +9568,7 @@ module Rigor
8748
9568
  when :contested_discovered_singleton_def_nodes
8749
9569
  fold_slot_contests(target.fetch(name), singleton_def_nodes, table)
8750
9570
  when :contested_discovered_method_visibilities
8751
- fold_slot_contests(target.fetch(name), method_visibilities, table)
9571
+ fold_visibility_contests(target.fetch(name), acc[:method_visibilities], method_visibilities, table)
8752
9572
  else union_sibling_value(target.fetch(name), table)
8753
9573
  end
8754
9574
  end
@@ -8821,6 +9641,30 @@ module Rigor
8821
9641
  result.merge(file_contested)
8822
9642
  end
8823
9643
 
9644
+ # The visibility table's {#fold_slot_contests}, where `recorded` is the table the file folds into as it stood.
9645
+ # Issue #1984 — a singleton slot (`[:singleton, name]`) does not follow its writer: file order is not load
9646
+ # order, so two files that give it different visibilities (`private_class_method :x` here,
9647
+ # `class << self; public :x; end` in a reopening) leave it contested, and a contest on it stays whichever
9648
+ # file folds later. The instance slots fold as before.
9649
+ def fold_visibility_contests(sitting, recorded, file_slots, file_contested)
9650
+ stale = sitting.select { |class_name, name| !name.is_a?(Array) && file_slots[class_name]&.key?(name) }
9651
+ disagreeing = singleton_visibility_disagreements(recorded, file_slots)
9652
+ return sitting if stale.empty? && file_contested.empty? && disagreeing.empty?
9653
+
9654
+ (sitting - stale).merge(file_contested).merge(disagreeing)
9655
+ end
9656
+
9657
+ def singleton_visibility_disagreements(recorded, file_slots)
9658
+ file_slots.flat_map do |class_name, table|
9659
+ sitting = recorded[class_name]
9660
+ next [] if sitting.nil?
9661
+
9662
+ table.filter_map do |slot, visibility|
9663
+ [class_name, slot] if slot.is_a?(Array) && sitting.key?(slot) && sitting[slot] != visibility
9664
+ end
9665
+ end
9666
+ end
9667
+
8824
9668
  def union_paths(sitting, arriving)
8825
9669
  return sitting if arriving.empty?
8826
9670
  return arriving if sitting.empty?
@@ -9324,8 +10168,9 @@ module Rigor
9324
10168
  def merge_class_keyed_index_tables(acc, root, file_methods, certainty)
9325
10169
  file_visibilities, file_contested = build_method_visibility_tables(root, certainty: certainty)
9326
10170
  siblings = acc[:siblings]
9327
- siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
9328
- siblings[:contested_discovered_method_visibilities], file_visibilities, file_contested
10171
+ siblings[:contested_discovered_method_visibilities] = fold_visibility_contests(
10172
+ siblings[:contested_discovered_method_visibilities], acc[:method_visibilities], file_visibilities,
10173
+ file_contested
9329
10174
  )
9330
10175
  file_visibilities.each do |class_name, table|
9331
10176
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
@@ -10589,8 +11434,15 @@ module Rigor
10589
11434
 
10590
11435
  # Issue #1717 — a `def` / `class` / `module` body opens its own `self`, so an enclosing unentered block's
10591
11436
  # unknown-`self` mark ({Scope#block_self_unknown?}) stops at it, as the evaluator's fresh body scope does.
10592
- # Every other node hands its children the scope as is.
11437
+ # A `case` narrows its clauses ({#propagate_subjectless_case}, {#propagate_subject_case}). Every other node
11438
+ # hands its children the scope as is.
10593
11439
  def propagate_children(node, table, current_scope)
11440
+ if node.is_a?(Prism::CaseNode) || node.is_a?(Prism::CaseMatchNode)
11441
+ return propagate_subjectless_case(node, table, current_scope) if node.predicate.nil?
11442
+
11443
+ return propagate_subject_case(node, table, current_scope)
11444
+ end
11445
+
10594
11446
  child_scope =
10595
11447
  SELF_OPENING_BODIES.include?(node.class) ? current_scope.with_block_self_unknown(false) : current_scope
10596
11448
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
@@ -10783,19 +11635,106 @@ module Rigor
10783
11635
  scope.local(name).nil? ? scope : scope.with_local(name, Type::Combinator.untyped)
10784
11636
  end
10785
11637
 
11638
+ # Issue #1924 — an `if` / `elsif` / `unless` / ternary the evaluator did not enter (`[if (x ||= "d").empty?
11639
+ # then 0 else x.upcase end]`) applies no write its condition makes, so its edges are taken from a scope where
11640
+ # every bound local the condition writes reads `Dynamic[top]` ({#widen_condition_writes}), as
11641
+ # {#propagate_subjectless_case} takes a `when` condition's. The condition itself reads the scope before it.
10786
11642
  def propagate_if_branches(node, table, current_scope)
10787
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
11643
+ truthy_scope, falsey_scope = conditional_edges(node.predicate, current_scope)
10788
11644
  propagate(node.predicate, table, current_scope) if node.predicate
10789
11645
  propagate(node.statements, table, truthy_scope) if node.statements
10790
11646
  propagate(node.subsequent, table, falsey_scope) if node.subsequent
10791
11647
  end
10792
11648
 
10793
11649
  def propagate_unless_branches(node, table, current_scope)
10794
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
11650
+ truthy_scope, falsey_scope = conditional_edges(node.predicate, current_scope)
10795
11651
  propagate(node.predicate, table, current_scope) if node.predicate
10796
11652
  propagate(node.statements, table, falsey_scope) if node.statements
10797
11653
  propagate(node.else_clause, table, truthy_scope) if node.else_clause
10798
11654
  end
11655
+
11656
+ def conditional_edges(predicate, scope)
11657
+ Narrowing.predicate_scopes(predicate, widen_condition_writes(predicate, scope))
11658
+ end
11659
+
11660
+ # Issue #1495 — a `case` without a subject the evaluator did not enter (`[case; when x.nil? then 0; else x.size
11661
+ # end]`) narrows by its conditions' edges, as {StatementEvaluator#subjectless_when_scopes} does: each condition
11662
+ # under the falsey edges of the conditions before it, each clause under the join of its conditions' truthy edges,
11663
+ # and the `else` under the last falsey edge. Like the walk of an `if`, it applies no write a condition makes, so
11664
+ # the edges are taken from a scope where every bound local the condition writes reads `Dynamic[top]`
11665
+ # ({#widen_condition_writes}): neither the local's old type nor an earlier condition's narrowing of it reaches
11666
+ # them, as `when (x = s ? String(s) : nil) then x.upcase` after `x = 1` would.
11667
+ def propagate_subjectless_case(node, table, current_scope)
11668
+ falsey = current_scope
11669
+ node.conditions.each do |when_node|
11670
+ truthy_edges = when_node.conditions.map do |condition|
11671
+ propagate(condition, table, falsey)
11672
+ truthy, falsey = Narrowing.predicate_scopes(condition, widen_condition_writes(condition, falsey))
11673
+ truthy
11674
+ end
11675
+ propagate(when_node, table, truthy_edges.reduce(:join))
11676
+ end
11677
+ propagate(node.else_clause, table, falsey) if node.else_clause
11678
+ end
11679
+
11680
+ # Issue #1484 — a `case` with a subject the evaluator did not enter (`[case io when StringIO then io.string
11681
+ # end]`, `[case io in StringIO then io.string else nil end]`) narrows each clause as the evaluator does
11682
+ # ({Narrowing.case_clause_scopes}): the body under the clause's narrowed edge, the next clause and the `else`
11683
+ # under its falsey edge. The walk applies no write the subject, a `when` condition or an `in` pattern makes, so
11684
+ # every bound local one of them writes reads `Dynamic[top]` from that point on ({#widen_condition_writes}).
11685
+ # Nor does it run the evaluator's per-clause `LastLine.forget_if_set`: the statement holding the `case`
11686
+ # already forgets a `$_` any of its tests may set. A clause's tests are recorded under the scope the earlier
11687
+ # clauses' falsey edges left, where the evaluator's `when` tests read the post-subject scope: a test reading
11688
+ # the subject sees what the earlier clauses ruled out (`when NilClass then 0 when v.succ`).
11689
+ #
11690
+ # A test that calls a method may rebind an instance-variable or global subject before the body reads it
11691
+ # (`when String then 1 when reset! then @x.upcase`, where `reset!` writes `@x`), and the walk cannot tell. From
11692
+ # the first such test on, the subject reads `Dynamic[top]` on both edges ({#forget_called_subject}). The
11693
+ # evaluator keeps the narrowing there (a separate defect); the walk declines it rather than add a report.
11694
+ def propagate_subject_case(node, table, current_scope)
11695
+ subject = node.predicate
11696
+ propagate(subject, table, current_scope)
11697
+ falsey = widen_condition_writes(subject, current_scope)
11698
+ called = false
11699
+ node.conditions.each do |clause|
11700
+ tests = clause.is_a?(Prism::WhenNode) ? clause.conditions : [clause.pattern]
11701
+ tests.each { |test| propagate(test, table, falsey) }
11702
+ falsey = tests.reduce(falsey) { |acc, test| widen_condition_writes(test, acc) }
11703
+ called ||= tests.any? { |test| runs_call?(test) }
11704
+ body, falsey = Narrowing.case_clause_scopes(subject, clause, falsey)
11705
+ body, falsey = [body, falsey].map { |scope| forget_called_subject(subject, scope) } if called
11706
+ propagate(clause, table, body)
11707
+ end
11708
+ propagate(node.else_clause, table, falsey) if node.else_clause
11709
+ end
11710
+
11711
+ CODE_RUNNING_NODES = [Prism::CallNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::YieldNode].freeze
11712
+ private_constant :CODE_RUNNING_NODES
11713
+
11714
+ # Whether `node` holds a method call, `super` or `yield` anywhere, a lambda body's included.
11715
+ def runs_call?(node)
11716
+ return false unless node.is_a?(Prism::Node)
11717
+ return true if CODE_RUNNING_NODES.include?(node.class)
11718
+
11719
+ node.rigor_each_child { |child| return true if runs_call?(child) }
11720
+ false
11721
+ end
11722
+
11723
+ # `scope` with an instance-variable or global `subject` rebound to `Dynamic[top]`; any other subject as is.
11724
+ def forget_called_subject(subject, scope)
11725
+ case subject
11726
+ when Prism::InstanceVariableReadNode then scope.with_ivar(subject.name, Type::Combinator.untyped)
11727
+ when Prism::GlobalVariableReadNode then scope.with_global(subject.name, Type::Combinator.untyped)
11728
+ else scope
11729
+ end
11730
+ end
11731
+
11732
+ # `scope` with every bound local `condition` writes rebound to `Dynamic[top]`.
11733
+ def widen_condition_writes(condition, scope)
11734
+ OperandEffects.written_variables(condition).reduce(scope) do |acc, name|
11735
+ acc.local(name) ? acc.with_local(name, Type::Combinator.untyped) : acc
11736
+ end
11737
+ end
10799
11738
  end
10800
11739
  end
10801
11740
  end