rigortype 0.4.4 → 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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. metadata +13 -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,20 +510,23 @@ 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)
510
514
  exempt_class_body_writes!(init_writes, accumulator.delete(DEFINE_METHOD_WRITES) || {})
511
515
  merge_held_ivar_writes!(accumulator)
512
516
  record_aliased_ivar_mutations!(root, mutated_ivars)
513
517
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
514
518
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
519
+ make_reflective_classes_gradual!(accumulator, reflective_opaque)
515
520
  accumulator.transform_values(&:freeze).freeze
516
521
  end
517
522
 
518
523
  # Folds the class-body writes {#collect_class_body_ivar_writes} held under {CLASS_BODY_WRITES} into the
519
- # `init_writes` exemption, except for an ivar a `define_method` block of the class writes (#1768). Such an ivar
520
- # had no census entry before the block's writes were counted, so the exemption never applied to it; a
521
- # class-body `@x = nil` writes the class object's ivar and says nothing about whether an instance's `@x` is set,
522
- # and exempting it would seed `@x` non-nil from the block's write alone (`if @x` folding always-truthy on an
523
- # instance whose `@x` is unset). It keeps the read-before-write `nil` instead, which only widens.
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.
524
530
  def exempt_class_body_writes!(init_writes, define_method_writes)
525
531
  (init_writes.delete(CLASS_BODY_WRITES) || {}).each do |class_name, ivars|
526
532
  kept = ivars - (define_method_writes[class_name] || EMPTY_GUARDED_IVARS)
@@ -867,7 +873,7 @@ module Rigor
867
873
 
868
874
  owner = def_owner || qualified_prefix
869
875
  collect_initializer_block_ivars(node, owner, default_scope, accumulator, mutated_ivars, init_writes)
870
- 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))
871
877
  collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
872
878
  false
873
879
  else
@@ -1090,14 +1096,19 @@ module Rigor
1090
1096
  # `owner` is empty in a `class << self` body (the call defines a class method there) and in an
1091
1097
  # anonymous factory body, and the block contributes nothing there. A `define_method` inside a `def`
1092
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.
1093
1103
  def collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
1094
1104
  return if owner.empty?
1095
- return unless define_method_block_call?(node)
1105
+
1106
+ class_name = owner.join("::")
1107
+ return unless method_body_block_call?(node, class_name, default_scope)
1096
1108
 
1097
1109
  block = node.block
1098
1110
  return unless block.body
1099
1111
 
1100
- class_name = owner.join("::")
1101
1112
  body_scope = census_body_scope(default_scope, owner, Type::Combinator.nominal_of(class_name))
1102
1113
  gather_ivar_writes(block.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
1103
1114
  written = (accumulator[DEFINE_METHOD_WRITES] ||= {})[class_name] ||= Set.new
@@ -1115,6 +1126,8 @@ module Rigor
1115
1126
  Prism::InstanceVariableTargetNode
1116
1127
  acc << node.name
1117
1128
  end
1129
+ reflective = ReflectiveIvarWrite.literal_name(node)
1130
+ acc << reflective if reflective
1118
1131
  node.rigor_each_child { |child| define_method_written_ivars(child, acc) }
1119
1132
  end
1120
1133
 
@@ -1243,6 +1256,25 @@ module Rigor
1243
1256
  DefineMethodBlockSelf.define_method_on_lexical_self?(node) && node.block.is_a?(Prism::BlockNode)
1244
1257
  end
1245
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
+
1246
1278
  def detect_read_before_write(node, seen_writes, read_first)
1247
1279
  return unless node.is_a?(Prism::Node)
1248
1280
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
@@ -1270,6 +1302,9 @@ module Rigor
1270
1302
  end
1271
1303
 
1272
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
1273
1308
  end
1274
1309
 
1275
1310
  # Records each ivar target of a `MultiWriteNode` / nested `MultiTargetNode` into `seen_writes`, and descends into
@@ -1317,6 +1352,7 @@ module Rigor
1317
1352
  end
1318
1353
  else
1319
1354
  record_compound_ivar_write(node, scope, class_name, accumulator)
1355
+ record_reflective_ivar_write(node, scope, class_name, accumulator)
1320
1356
  end
1321
1357
 
1322
1358
  # N1 — parallel / multiple assignment (`old, @cb = @cb, block`, `@i, @o, @e, @thr = Open3.popen3(cmd)`). A
@@ -1344,6 +1380,32 @@ module Rigor
1344
1380
  end
1345
1381
  end
1346
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
+
1347
1409
  # Records `@ivar.<method>(...)` calls whose method is in `MutationWidening::SHAPE_MUTATORS`.
1348
1410
  # The class-ivar pre-pass uses the resulting set to widen the post-collected accumulator entries (see
1349
1411
  # {.widen_mutated_ivar_entries!}). Always-safe to over- collect: any name that the widening primitive declines is
@@ -2087,7 +2149,9 @@ module Rigor
2087
2149
  # accumulator), and the class-body writes held out of `init_writes` until {#exempt_class_body_writes!}.
2088
2150
  DEFINE_METHOD_WRITES = :define_method_writes
2089
2151
  CLASS_BODY_WRITES = :class_body_writes
2090
- private_constant :DEFINE_METHOD_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
2091
2155
 
2092
2156
  # Unions `type` into the class-ivar accumulator for `(class_name, ivar_name)`. Shared by the single-write and
2093
2157
  # multi-write (parallel-assignment) collectors.
@@ -4088,6 +4152,10 @@ module Rigor
4088
4152
  (singleton_cref && owner_prefix.empty?)
4089
4153
  record_alias_or_undef(node, owner_prefix, in_singleton_class || defs_singleton,
4090
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
4091
4159
  end
4092
4160
  return
4093
4161
  when Prism::SymbolNode
@@ -4102,6 +4170,9 @@ module Rigor
4102
4170
  end
4103
4171
  anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc.at(node),
4104
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
4105
4176
  tables = methods_acc.at(node)
4106
4177
  record_singleton_prepend(node, qualified_prefix, tables,
4107
4178
  self_ok: node.name == :prepend && tables.in_block?(node))
@@ -4171,11 +4242,40 @@ module Rigor
4171
4242
  def record_declaration_facts(node, child_prefix, tables)
4172
4243
  if node.is_a?(Prism::ClassNode)
4173
4244
  record_meta_superclass_members(node, child_prefix, tables)
4245
+ record_constructed_superclass(node.superclass, child_prefix.join("::"), tables)
4174
4246
  else
4175
4247
  record_surface_mark(tables, child_prefix.join("::"), Scope::DiscoveryIndex::ENVELOPE_MODULE_MARK)
4176
4248
  end
4177
4249
  end
4178
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
+
4179
4279
  # The block form of a meta-new constant write: its members, and the module mark `Module.new` earns.
4180
4280
  # Issue #1820 — `K = Class.new(Parent)`, with or without a block, names a class whose superclass the superclass
4181
4281
  # table leaves out; it takes {Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK}.
@@ -4214,7 +4314,7 @@ module Rigor
4214
4314
  # descent.
4215
4315
  def record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
4216
4316
  record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
4217
- 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)
4218
4318
  record_module_attr_methods(node, qualified_prefix, methods_acc) if MODULE_ATTR_MACROS.key?(node.name)
4219
4319
  record_alias_method_call(node, qualified_prefix, in_singleton_class, methods_acc)
4220
4320
  record_surface_evidence(node, qualified_prefix, methods_acc)
@@ -4727,6 +4827,49 @@ module Rigor
4727
4827
  end
4728
4828
  end
4729
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
+
4730
4873
  # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
4731
4874
  # Before the anonymous class had a name the body was walked with an empty prefix, so every such `def`
4732
4875
  # landed there; that is the same leniency this key already grants a `def` nested in any other DSL block
@@ -4772,6 +4915,18 @@ module Rigor
4772
4915
  # The singleton walk's accumulator: the `{class => {name => DefNode}}` table, plus the contested slots, which
4773
4916
  # follow each slot's writer ({#follow_slot_writer}'s rule).
4774
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
+
4775
4930
  def track(certainty)
4776
4931
  @certainty = certainty
4777
4932
  @contested = nil
@@ -4779,9 +4934,11 @@ module Rigor
4779
4934
  end
4780
4935
 
4781
4936
  # Writes the slot; `node` and `other` are the contributions the write rests on, possible when either is.
4782
- 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)
4783
4940
  (self[class_name] ||= {})[name] = def_node
4784
- if Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4941
+ if possible || Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4785
4942
  (@contested ||= Set.new) << [class_name, name]
4786
4943
  else
4787
4944
  @contested&.delete([class_name, name])
@@ -4824,6 +4981,12 @@ module Rigor
4824
4981
  # `Const`'s singleton class (a `remove_method`, a `def`) without declaring `Const`, so it stays out of
4825
4982
  # `class_sources` (whose readers take a name there as one the project declares) and joins only the per-file
4826
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.
4827
4990
  def record_singleton_class_sources(sources, path, root)
4828
4991
  collect_singleton_class_targets(root, []) { |name| (sources[name] ||= Set.new) << path }
4829
4992
  end
@@ -4839,6 +5002,8 @@ module Rigor
4839
5002
  yield eval_constant_receiver_prefix(node, node.expression, rendered, lexical_prefix).join("::") if rendered
4840
5003
  when Prism::CallNode
4841
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, &)
4842
5007
  end
4843
5008
  node.rigor_each_child { |child| collect_singleton_class_targets(child, lexical_prefix, &) }
4844
5009
  end
@@ -4846,8 +5011,7 @@ module Rigor
4846
5011
  # A call on a constant that may rewrite its singleton side (`Const.singleton_class`,
4847
5012
  # `Const.define_singleton_method`, `Const.extend(M)`, `Const.instance_eval { … }`) records facts about `Const`'s
4848
5013
  # singleton methods the refinement walk reads from a file that need not declare `Const`, so the file joins the
4849
- # constant's set too. Written inside `Const`'s own body, the name is in the file's set already; a `def Const.x`
4850
- # 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.
4851
5015
  def singleton_side_call?(node)
4852
5016
  node.name == :singleton_class || surface_rewriting_call?(node)
4853
5017
  end
@@ -5062,7 +5226,7 @@ module Rigor
5062
5226
  # own `def` leaves additionally split on {INSTANCE_EVAL_CALLS}, whose `def`s bind on the
5063
5227
  # receiver's singleton rather than the receiver.
5064
5228
  def receiver_eval_call?(node)
5065
- 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)
5066
5230
  end
5067
5231
 
5068
5232
  # A `class_eval` / `module_eval` / `class_exec` / `module_exec` block is NOT deferred: it runs
@@ -5445,7 +5609,10 @@ module Rigor
5445
5609
  def_owner_prefix = nil, singleton_cref: false,
5446
5610
  defs_singleton: false)
5447
5611
  return unless node.is_a?(Prism::Node)
5448
- 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)
5449
5616
  return walk_eval_singleton_defs(node, qualified_prefix, in_singleton_class, accumulator,
5450
5617
  def_owner_prefix, singleton_cref: singleton_cref,
5451
5618
  defs_singleton: defs_singleton)
@@ -5736,7 +5903,8 @@ module Rigor
5736
5903
  defs_singleton: false)
5737
5904
  owner_prefix = def_owner_prefix || qualified_prefix
5738
5905
  statements = statements_of(body)
5739
- 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|
5740
5908
  if named_call
5741
5909
  # `:unnameable` — the named defs the call copies live on the metaclass,
5742
5910
  # which this table cannot name either.
@@ -5786,19 +5954,96 @@ module Rigor
5786
5954
  statements
5787
5955
  end
5788
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).
5789
5960
  def record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator)
5790
- singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class) || module_function_on
5791
- return unless singleton
5961
+ declared = def_singleton?(def_node, qualified_prefix, in_singleton_class)
5962
+ return unless declared || module_function_on
5792
5963
  return if qualified_prefix.empty?
5793
5964
 
5794
5965
  class_name = qualified_prefix.join("::")
5795
- 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
5796
6041
  end
5797
6042
 
5798
6043
  # One singleton slot write, through {SingletonDefTable#write} when the walk tracks certainty.
5799
- 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)
5800
6045
  if accumulator.is_a?(SingletonDefTable)
5801
- accumulator.write(class_name, name, def_node, node, other)
6046
+ accumulator.write(class_name, name, def_node, node, other, possible: possible)
5802
6047
  else
5803
6048
  (accumulator[class_name] ||= {})[name] = def_node
5804
6049
  end
@@ -6695,32 +6940,34 @@ module Rigor
6695
6940
  # differently for the two spellings of one edge.
6696
6941
  def record_mixin_call(node, qualified_prefix, current_class, accumulator)
6697
6942
  taint_opaque_eval(node, current_class, accumulator)
6698
- kind, _arguments, via_send = mixin_call_view(node)
6699
- return if kind.nil?
6943
+ view = mixin_call_view(node)
6944
+ return if view.nil? || view.first.nil?
6700
6945
 
6946
+ kind = view.first
6701
6947
  current_class ||= toplevel_include_owner(node, qualified_prefix, current_class, accumulator)
6702
6948
  effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: false)
6703
6949
  .select { |_owner, side| side == :include }
6704
6950
  return if effects.empty?
6705
- 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)
6706
6952
 
6707
6953
  # A mixin call the tables cannot hold still reshapes the ancestry: taint the whole owner side.
6708
6954
  effects.each { |owner, side| accumulator.taint(owner, side) }
6709
6955
  end
6710
6956
 
6711
6957
  # Records the instance-side edges of a call {#mixin_call_recorded?} accepts. False when it recorded
6712
- # nothing — the caller then taints the owner instead.
6713
- def instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
6714
- 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)
6715
6963
 
6716
- arguments = node.arguments&.arguments || []
6717
6964
  targets = arguments.filter_map { |arg| Source::ConstantPath.qualified_name(arg) }
6718
6965
  return false if targets.empty?
6719
6966
 
6720
6967
  owner = node.receiver.nil? ? current_class : prepend_call_receiver(node, qualified_prefix)
6721
6968
  return false if owner.nil?
6722
6969
 
6723
- write_mixin_targets(accumulator, owner, targets, prepend: node.name == :prepend)
6970
+ write_mixin_targets(accumulator, owner, targets, prepend: kind == :prepend)
6724
6971
  accumulator.note(node, owner, :include, targets, complete: targets.size == arguments.size)
6725
6972
  true
6726
6973
  end
@@ -6868,15 +7115,17 @@ module Rigor
6868
7115
  end
6869
7116
 
6870
7117
  # Whether a mixin call contributes to the tables at all: a receiverless `include` / `prepend` needs an
6871
- # enclosing declaration to file the edge under, and the RECEIVER form is recorded for `prepend` only
6872
- # — `Base.include(Loud)` is deliberately still unrecorded. Nothing needs it (this issue orders
6873
- # `prepend`, and the receiver form of `include` has no ordering question to answer) while recording
6874
- # it would widen what every consumer says about a class, which is scope this change does not own.
6875
- def mixin_call_recorded?(node, current_class)
6876
- return false unless MIXIN_CALL_NAMES.include?(node.name)
6877
- 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?
6878
7127
 
6879
- node.name == :prepend
7128
+ true
6880
7129
  end
6881
7130
 
6882
7131
  # Issue #1697 — a receiverless `include M` written as a statement of the file's own top level is
@@ -7589,6 +7838,8 @@ module Rigor
7589
7838
  # - **Wrap-around form** (#1911, #1819, #1633): `private def foo` and `private attr_reader :foo` record the
7590
7839
  # members with the call's visibility and leave the default alone.
7591
7840
  #
7841
+ # A `module_function` call takes each form as `private` does (#1569): Ruby makes the instance copy private.
7842
+ #
7592
7843
  # Top-level (no surrounding class) defs do not contribute — Ruby's top-level visibility nuances (private at
7593
7844
  # top-level marks the method on `Object`) are out of scope for v0.1.2.
7594
7845
  def build_discovered_method_visibilities(root)
@@ -7605,8 +7856,8 @@ module Rigor
7605
7856
  end
7606
7857
 
7607
7858
  # The bare calls that set the running default visibility of the body they run in: the modifiers and
7608
- # `module_function`, which {#walk_method_visibilities} does not track as a value (#1569) but which moves the
7609
- # default all the same.
7859
+ # `module_function`, which {#walk_method_visibilities} applies as `private` on the instance side
7860
+ # ({ModuleFunctionState.instance_visibility}, #1569).
7610
7861
  VISIBILITY_TOGGLES = (VISIBILITY_MODIFIERS + %i[module_function]).freeze
7611
7862
 
7612
7863
  # The `{class => {name => visibility}}` accumulator of {#walk_method_visibilities}, which also carries the
@@ -7650,9 +7901,22 @@ module Rigor
7650
7901
  (@contested ||= Set.new) << [class_name, name].freeze
7651
7902
  end
7652
7903
 
7653
- # Contests every slot of `class_name` recorded so far (`private(*names)` may name any of them).
7654
- def contest_class(class_name)
7655
- self[class_name]&.each_key { |name| contest_slot(class_name, name) }
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)
7656
7920
  end
7657
7921
 
7658
7922
  # Runs the block with the running default uncertain, so a `def` it records is contested.
@@ -7769,10 +8033,12 @@ module Rigor
7769
8033
  return current_visibility
7770
8034
  end
7771
8035
  when Prism::DefNode
7772
- 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),
7773
8038
  current_visibility, accumulator)
7774
8039
  return current_visibility
7775
8040
  when Prism::CallNode
8041
+ record_constant_class_method_calls(node, qualified_prefix, accumulator)
7776
8042
  if receiver_eval_call?(node)
7777
8043
  walk_eval_visibilities(node, qualified_prefix, in_singleton_class, current_visibility,
7778
8044
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
@@ -7787,23 +8053,50 @@ module Rigor
7787
8053
  end
7788
8054
  if (parts = visibility_call_parts(node))
7789
8055
  accumulator.toggles += 1 if visibility_toggle_arguments?(parts.last)
7790
- # The visibility table is instance-side only — `private :x` inside `class <<`
7791
- # (or a receiver-eval body that re-evaluates a singleton self) marks the
7792
- # SINGLETON method, which this table cannot express. The `owner_prefix` check is a
7793
- # guard: an ownerless body (the top level, an anonymous factory block) records nothing,
7794
- # and its calls walk as any other call.
7795
- if !in_singleton_class && !owner_prefix.empty? && VISIBILITY_MODIFIERS.include?(parts.first)
7796
- return apply_visibility_call(node, *parts, owner_prefix, current_visibility, accumulator,
7797
- defs_singleton: defs_singleton) do |argument|
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|
7798
8070
  walk_method_visibilities(argument, qualified_prefix, in_singleton_class, current_visibility,
7799
8071
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
7800
8072
  defs_singleton: defs_singleton)
7801
8073
  end
7802
8074
  end
7803
8075
  end
7804
- unless in_singleton_class || defs_singleton
7805
- record_attr_visibility(node, owner_prefix, current_visibility, accumulator, toggled: true)
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)
7806
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
7807
8100
  end
7808
8101
 
7809
8102
  # Statement-position StatementsNode preserves left-to-right visibility flow; everything else recurses with the
@@ -7887,6 +8180,9 @@ module Rigor
7887
8180
  singleton_cref: singleton_cref,
7888
8181
  defs_singleton: defs_singleton)
7889
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
+
7890
8186
  node.arguments&.arguments&.each do |arg|
7891
8187
  walk_method_visibilities(arg, qualified_prefix, in_singleton_class, current_visibility,
7892
8188
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
@@ -7911,63 +8207,185 @@ module Rigor
7911
8207
  end
7912
8208
  end
7913
8209
 
7914
- # `toggled` when the def reads the running default (a statement), false for one a visibility call wraps.
7915
- def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator,
7916
- toggled: true)
7917
- return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
7918
- 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?
7919
8312
 
7920
8313
  class_name = qualified_prefix.join("::")
7921
- accumulator[class_name] ||= {}
7922
- accumulator[class_name][def_node.name] = current_visibility
7923
- accumulator.wrote(class_name, def_node.name, def_node, toggled: toggled)
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
8321
+ end
8322
+
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
7924
8338
  end
7925
8339
 
7926
8340
  # Applies one `private` / `protected` / `public` call ({#visibility_call_parts}) written on the body's self
7927
- # and returns the running default after it:
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.
7928
8343
  #
7929
8344
  # - no arguments — the call's visibility becomes the default for subsequent defs.
7930
8345
  # - `private :foo, :bar`, `private %i[foo bar]` — back-patch the named methods in the accumulator; the
7931
8346
  # default does NOT change for this form.
7932
8347
  # - Issues #1911, #1819, #1633 — `private def foo` (endless or not) and `private attr_reader :foo` define
7933
8348
  # the members with the call's visibility, whatever the running default is (`private; public def foo` is
7934
- # public); `private def self.foo` changes the instance method `foo`, if one exists, so its slot is
7935
- # contested.
7936
- # - a splat (`private(*names)`) may name any member or none (a bare toggle), so the class's slots this file
7937
- # wrote so far are contested, and the statement loop reads the default after it as uncertain
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
7938
8353
  # ({#visibility_toggle_arguments?}).
7939
8354
  # - any other argument (`private memoize def foo`, `private delegate(...)`) is walked with the default
7940
8355
  # uncertain, so a `def` under it is contested: the call's visibility reaches it through a return value
7941
8356
  # the walk does not read.
7942
8357
  #
7943
8358
  # `walk` walks an argument the call does not consume itself.
7944
- def apply_visibility_call(call_node, name, args, qualified_prefix, current_visibility, accumulator,
7945
- defs_singleton: false, &walk)
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)
7946
8361
  return name if args.empty?
7947
8362
 
7948
8363
  class_name = qualified_prefix.join("::")
7949
- accumulator.contest_class(class_name) if args.any?(Prism::SplatNode)
8364
+ accumulator.contest_class(class_name, side) if args.any?(Prism::SplatNode)
7950
8365
  args.each do |arg|
7951
8366
  apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator,
7952
- defs_singleton: defs_singleton, &walk)
8367
+ side: side, defs_singleton: defs_singleton, &walk)
7953
8368
  end
7954
8369
  current_visibility
7955
8370
  end
7956
8371
 
7957
- def apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator, defs_singleton:, &walk)
8372
+ def apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator, side:, defs_singleton:, &walk)
7958
8373
  case arg
7959
8374
  when Prism::DefNode
7960
8375
  if arg.receiver.nil?
7961
- record_def_visibility(arg, qualified_prefix, defs_singleton, name, accumulator, toggled: false)
8376
+ def_side = side == :instance && defs_singleton ? :eval : side
8377
+ record_def_visibility(arg, qualified_prefix, def_side, name, accumulator, toggled: false)
7962
8378
  else
7963
- accumulator.contest_slot(qualified_prefix.join("::"), arg.name)
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)
7964
8382
  end
7965
8383
  when Prism::SymbolNode, Prism::StringNode, Prism::ArrayNode
7966
- apply_named_visibility(arg, name, qualified_prefix, call_node, accumulator, &walk)
8384
+ apply_named_visibility(arg, name, qualified_prefix, call_node, accumulator, side: side, &walk)
7967
8385
  else
7968
8386
  # An `instance_eval` body's wrapped attr is walked like any other argument and records nothing.
7969
8387
  if !defs_singleton && attr_visibility_call?(arg)
7970
- record_attr_visibility(arg, qualified_prefix, name, accumulator, toggled: false)
8388
+ record_attr_visibility(arg, qualified_prefix, name, accumulator, toggled: false, side: side)
7971
8389
  else
7972
8390
  accumulator.uncertainly { walk.call(arg) }
7973
8391
  end
@@ -7976,16 +8394,14 @@ module Rigor
7976
8394
 
7977
8395
  # `private :foo` / `private "foo"` / `private %i[foo bar]`: each literal name the call's visibility. An array
7978
8396
  # element that is not a literal name is walked like any other argument.
7979
- def apply_named_visibility(arg, visibility, qualified_prefix, call_node, accumulator, &walk)
8397
+ def apply_named_visibility(arg, visibility, qualified_prefix, call_node, accumulator, side: :instance, &walk)
7980
8398
  elements = arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg]
7981
8399
  class_name = qualified_prefix.join("::")
7982
8400
  elements.each do |element|
7983
8401
  name = visibility_target_name(element)
7984
8402
  next accumulator.uncertainly { walk.call(element) } if name.nil?
7985
8403
 
7986
- accumulator[class_name] ||= {}
7987
- accumulator[class_name][name] = visibility
7988
- accumulator.wrote(class_name, name, call_node, toggled: false)
8404
+ accumulator.write(class_name, name, side, visibility, call_node, toggled: false)
7989
8405
  end
7990
8406
  end
7991
8407
 
@@ -7993,30 +8409,133 @@ module Rigor
7993
8409
  Source::Literals.symbol_or_string(arg)
7994
8410
  end
7995
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
+
7996
8492
  # Issue #1911 — an `attr_reader` / `attr_writer` / `attr_accessor` call written on the body's self records
7997
8493
  # each member it defines (`x`, `x=`) with `visibility`: the running default for a statement (`toggled`), the
7998
8494
  # wrapping call's for `private attr_accessor :x`, which Ruby applies to the `[:x, :x=]` the macro returns.
7999
8495
  # A non-literal name (`attr_reader(*FIELDS)`) records nothing; a call that is no such macro records nothing.
8000
- def record_attr_visibility(call_node, qualified_prefix, visibility, accumulator, toggled:)
8001
- return unless attr_visibility_call?(call_node)
8002
- return if qualified_prefix.empty?
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?
8003
8523
 
8004
8524
  class_name = qualified_prefix.join("::")
8005
8525
  each_attr_macro_name(call_node) do |base|
8006
8526
  members = []
8007
- members << base unless call_node.name == :attr_writer
8008
- members << :"#{base}=" unless call_node.name == :attr_reader
8527
+ members << base unless macro == :attr_writer
8528
+ members << :"#{base}=" unless macro == :attr_reader
8009
8529
  members.each do |member|
8010
- accumulator[class_name] ||= {}
8011
- accumulator[class_name][member] = visibility
8012
- accumulator.wrote(class_name, member, call_node, toggled: toggled)
8530
+ accumulator.write(class_name, member, side, visibility, call_node, toggled: toggled)
8013
8531
  end
8014
8532
  end
8015
8533
  end
8016
8534
 
8017
- # A receiverless `attr_reader` / `attr_writer` / `attr_accessor` call ({#record_attr_visibility}).
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.
8018
8537
  def attr_visibility_call?(node)
8019
- node.is_a?(Prism::CallNode) && node.receiver.nil? && ATTR_MACROS.include?(node.name)
8538
+ !attr_macro(node).nil?
8020
8539
  end
8021
8540
 
8022
8541
  # Registers the alias name in the `discovered_methods` table so `undefined-method` diagnostics are not emitted for
@@ -8131,7 +8650,8 @@ module Rigor
8131
8650
  return if owner.nil?
8132
8651
 
8133
8652
  singleton_class_rewritten_names(node).each do |method_name|
8134
- names = [method_name]
8653
+ # `attr_writer :x` defines `x=` alone (#1936), so `x` keeps its own `def`.
8654
+ names = node.name == :attr_writer ? [] : [method_name]
8135
8655
  names << :"#{method_name}=" if %i[attr_writer attr_accessor].include?(node.name)
8136
8656
  names.each do |name|
8137
8657
  record_envelope(tables.envelopes, owner, [:singleton, name], Source::ParameterEnvelope::OPAQUE)
@@ -8474,8 +8994,9 @@ module Rigor
8474
8994
  return if qualified_prefix.empty?
8475
8995
 
8476
8996
  kind = in_singleton_class ? :singleton : :instance
8477
- reader = call_node.name != :attr_writer
8478
- writer = call_node.name != :attr_reader
8997
+ macro = attr_macro(call_node)
8998
+ reader = macro != :attr_writer
8999
+ writer = macro != :attr_reader
8479
9000
  class_name = qualified_prefix.join("::")
8480
9001
  each_attr_macro_name(call_node) do |base|
8481
9002
  record_method(accumulator, class_name, base, kind) if reader
@@ -8483,18 +9004,34 @@ module Rigor
8483
9004
  end
8484
9005
  end
8485
9006
 
8486
- # Yields each name an `attr_*` call declares: a Symbol or String literal argument. A non-literal one
8487
- # (`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.
8488
9009
  def each_attr_macro_name(call_node)
8489
- return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
8490
- return if call_node.arguments.nil?
9010
+ return if attr_macro(call_node).nil? || call_node.arguments.nil?
8491
9011
 
8492
- 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|
8493
9015
  base = literal_method_name(arg)
8494
9016
  yield base unless base.nil?
8495
9017
  end
8496
9018
  end
8497
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
+
8498
9035
  # Issue #736 — the module-level accessor macros, which introduce methods on BOTH sides of the class.
8499
9036
  # ActiveSupport defines them on `Module`, so every class and module body can use them, and Rails codebases
8500
9037
  # do: `mattr_accessor` / `cattr_accessor` (an alias of it) and `class_attribute` account for 92 false
@@ -9031,7 +9568,7 @@ module Rigor
9031
9568
  when :contested_discovered_singleton_def_nodes
9032
9569
  fold_slot_contests(target.fetch(name), singleton_def_nodes, table)
9033
9570
  when :contested_discovered_method_visibilities
9034
- fold_slot_contests(target.fetch(name), method_visibilities, table)
9571
+ fold_visibility_contests(target.fetch(name), acc[:method_visibilities], method_visibilities, table)
9035
9572
  else union_sibling_value(target.fetch(name), table)
9036
9573
  end
9037
9574
  end
@@ -9104,6 +9641,30 @@ module Rigor
9104
9641
  result.merge(file_contested)
9105
9642
  end
9106
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
+
9107
9668
  def union_paths(sitting, arriving)
9108
9669
  return sitting if arriving.empty?
9109
9670
  return arriving if sitting.empty?
@@ -9607,8 +10168,9 @@ module Rigor
9607
10168
  def merge_class_keyed_index_tables(acc, root, file_methods, certainty)
9608
10169
  file_visibilities, file_contested = build_method_visibility_tables(root, certainty: certainty)
9609
10170
  siblings = acc[:siblings]
9610
- siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
9611
- 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
9612
10174
  )
9613
10175
  file_visibilities.each do |class_name, table|
9614
10176
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
@@ -11073,20 +11635,28 @@ module Rigor
11073
11635
  scope.local(name).nil? ? scope : scope.with_local(name, Type::Combinator.untyped)
11074
11636
  end
11075
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.
11076
11642
  def propagate_if_branches(node, table, current_scope)
11077
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
11643
+ truthy_scope, falsey_scope = conditional_edges(node.predicate, current_scope)
11078
11644
  propagate(node.predicate, table, current_scope) if node.predicate
11079
11645
  propagate(node.statements, table, truthy_scope) if node.statements
11080
11646
  propagate(node.subsequent, table, falsey_scope) if node.subsequent
11081
11647
  end
11082
11648
 
11083
11649
  def propagate_unless_branches(node, table, current_scope)
11084
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
11650
+ truthy_scope, falsey_scope = conditional_edges(node.predicate, current_scope)
11085
11651
  propagate(node.predicate, table, current_scope) if node.predicate
11086
11652
  propagate(node.statements, table, falsey_scope) if node.statements
11087
11653
  propagate(node.else_clause, table, truthy_scope) if node.else_clause
11088
11654
  end
11089
11655
 
11656
+ def conditional_edges(predicate, scope)
11657
+ Narrowing.predicate_scopes(predicate, widen_condition_writes(predicate, scope))
11658
+ end
11659
+
11090
11660
  # Issue #1495 — a `case` without a subject the evaluator did not enter (`[case; when x.nil? then 0; else x.size
11091
11661
  # end]`) narrows by its conditions' edges, as {StatementEvaluator#subjectless_when_scopes} does: each condition
11092
11662
  # under the falsey edges of the conditions before it, each clause under the join of its conditions' truthy edges,