rigortype 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  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 +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -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"
@@ -42,6 +43,8 @@ require_relative "scope_indexer/layered_def_nestings"
42
43
  require_relative "scope_indexer/mixin_accumulator"
43
44
  require_relative "scope_indexer/certainty"
44
45
  require_relative "scope_indexer/member_layouts_collector"
46
+ require_relative "scope_indexer/declared_ivar_types"
47
+ require_relative "scope_indexer/declared_param_writes"
45
48
 
46
49
  module Rigor
47
50
  module Inference
@@ -111,7 +114,17 @@ module Rigor
111
114
  # nothing-to-re-anchor walk allocates none.
112
115
  EMPTY_RENAMES = {}.freeze
113
116
 
114
- def index(root, default_scope:, converged_loop_recording: false, widen_clauses: false) # rubocop:disable Metrics/AbcSize
117
+ # No path whose contributions are all `possible` (issue #2021), the cross-file walks' default.
118
+ EMPTY_PATH_SET = Set.new.freeze
119
+
120
+ # `declared_ivar_types:` — whether an RBS-declared ivar type replaces the census's join of the writes (#1406).
121
+ # `rigor sig-gen` passes false: it proposes what the code says, and reading a declaration back would make a row
122
+ # count as generated only because it is declared.
123
+ # `ivar_writer_scope:` — the scope whose project tables the #1154 declared-parameter credit reads to find the
124
+ # ivar writers outside this file ({DeclaredParamWrites}); defaults to `default_scope`. `rigor sig-gen` indexes
125
+ # each file under an unseeded scope, so it passes one carrying the project's tables.
126
+ def index(root, default_scope:, converged_loop_recording: false, widen_clauses: false, # rubocop:disable Metrics/AbcSize
127
+ declared_ivar_types: true, ivar_writer_scope: nil)
115
128
  # Slice A-declarations. Build the declaration overrides first so every scope handed to the StatementEvaluator
116
129
  # already carries the table; structural sharing through `Scope#with_local` / `#with_fact` / `#with_self_type`
117
130
  # propagates it across every derived scope.
@@ -133,7 +146,9 @@ module Rigor
133
146
 
134
147
  # Slice 7 phase 2. Pre-pass over every class/module body to collect the per-class ivar accumulator. Seeded after
135
148
  # declared_types so the rvalue typer in the pre-pass can see declaration overrides.
136
- class_ivars = build_class_ivar_index(root, seeded_scope)
149
+ class_ivars = DeclaredParamWrites.with_writer_scope(ivar_writer_scope) do
150
+ build_class_ivar_index(root, seeded_scope, declared_ivar_types: declared_ivar_types)
151
+ end
137
152
  seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_ivars: class_ivars))
138
153
 
139
154
  # Slice 7 phase 6. Same pre-pass shape for cvars (per class) and globals (program-wide). Globals are also
@@ -196,15 +211,27 @@ module Rigor
196
211
  # share.
197
212
  seeded_scope = seed_struct_fold_safe(seeded_scope, root).with_match_frame(root)
198
213
 
199
- on_enter = ->(node, scope) { table[node] = scope }
200
- StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
201
- converged_loop_recording: converged_loop_recording,
202
- widen_clauses: widen_clauses).evaluate(root)
214
+ evaluate = recording_evaluator(table, converged_loop_recording, widen_clauses)
215
+ evaluate.call(root, seeded_scope)
203
216
 
204
- propagate(root, table, seeded_scope)
217
+ with_unentered_evaluator(evaluate) { propagate(root, table, seeded_scope) }
205
218
  table
206
219
  end
207
220
 
221
+ # The `(node, scope, excluded = nil) ->` evaluation that records every node it visits into `table`, except the
222
+ # nodes in `excluded`: the file's own pass, and each construct {#evaluate_unentered_flow} evaluates in the walk
223
+ # after it.
224
+ def recording_evaluator(table, converged_loop_recording, widen_clauses)
225
+ lambda do |node, scope, excluded = nil|
226
+ on_enter =
227
+ if excluded.nil? || excluded.empty? then ->(visited, at) { table[visited] = at }
228
+ else ->(visited, at) { table[visited] = at unless excluded.include?(visited) }
229
+ end
230
+ StatementEvaluator.new(scope: scope, on_enter: on_enter, converged_loop_recording: converged_loop_recording,
231
+ widen_clauses: widen_clauses).evaluate(node)
232
+ end
233
+ end
234
+
208
235
  # Runs the combined methods/def-nodes descent (one walk of the file), seeds the discovered-methods existence table
209
236
  # onto `seeded_scope` (merged UNDER the cross-file pre-pass seed `default_scope` carries), and returns `[scope,
210
237
  # file_def_nodes, file_envelopes]` so the caller can thread the def-node and issue #992 envelope tables into
@@ -332,8 +359,10 @@ module Rigor
332
359
  # possible methods were folded already ({#seed_discovered_methods}).
333
360
  def per_file_siblings(siblings, file_def_nodes, file_siblings, file_singleton, file_visibilities)
334
361
  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
362
+ # The seed's singleton contests stay ({#fold_visibility_contests}); it already holds a disagreement between
363
+ # this file and another.
364
+ siblings[:contested_discovered_method_visibilities] = fold_visibility_contests(
365
+ siblings[:contested_discovered_method_visibilities], {}, *file_visibilities
337
366
  )
338
367
  siblings[:contested_discovered_def_nodes] = fold_slot_contests(
339
368
  siblings[:contested_discovered_def_nodes], file_def_nodes, file_siblings[:contested_discovered_def_nodes]
@@ -495,7 +524,7 @@ module Rigor
495
524
  # (singleton vs instance). The rvalue is typed with NO local bindings — the pre-pass lacks statement-level
496
525
  # threading — so `@x = 1` records `Constant[1]` but `@x = some_local + 1` records `Dynamic[Top]` (since
497
526
  # `some_local` is unbound at pre-pass time). Multiple writes to the same ivar union via `Type::Combinator.union`.
498
- def build_class_ivar_index(root, default_scope)
527
+ def build_class_ivar_index(root, default_scope, declared_ivar_types: true)
499
528
  accumulator = {}
500
529
  mutated_ivars = {}
501
530
  read_before_write = {}
@@ -507,20 +536,25 @@ module Rigor
507
536
  method_assign_effects = build_method_assign_effects(root)
508
537
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
509
538
  read_before_write, init_writes, method_assign_effects)
539
+ reflective_opaque = accumulator.delete(REFLECTIVE_OPAQUE_CLASSES)
510
540
  exempt_class_body_writes!(init_writes, accumulator.delete(DEFINE_METHOD_WRITES) || {})
511
541
  merge_held_ivar_writes!(accumulator)
512
542
  record_aliased_ivar_mutations!(root, mutated_ivars)
513
543
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
514
544
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
545
+ make_reflective_classes_gradual!(accumulator, reflective_opaque)
546
+ # #1406 — an RBS `@x: T` or `attr_* x: T` declaration replaces the census's join of the writes.
547
+ DeclaredIvarTypes.apply!(accumulator, default_scope) if declared_ivar_types
515
548
  accumulator.transform_values(&:freeze).freeze
516
549
  end
517
550
 
518
551
  # 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.
552
+ # `init_writes` exemption, except for an ivar a `define_method` block, or a `block_as_methods:` block bound to the
553
+ # receiver's instance ({#method_body_block_call?}), of the class writes (#1768). Such an ivar had no census entry
554
+ # before the block's writes were counted, so the exemption never applied to it; a class-body `@x = nil` writes
555
+ # the class object's ivar and says nothing about whether an instance's `@x` is set, and exempting it would seed
556
+ # `@x` non-nil from the block's write alone (`if @x` folding always-truthy on an instance whose `@x` is unset).
557
+ # It keeps the read-before-write `nil` instead, which only widens.
524
558
  def exempt_class_body_writes!(init_writes, define_method_writes)
525
559
  (init_writes.delete(CLASS_BODY_WRITES) || {}).each do |class_name, ivars|
526
560
  kept = ivars - (define_method_writes[class_name] || EMPTY_GUARDED_IVARS)
@@ -867,7 +901,7 @@ module Rigor
867
901
 
868
902
  owner = def_owner || qualified_prefix
869
903
  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)
904
+ record_attr_writer_ivars(node, owner, accumulator) if ATTR_WRITER_MACROS.include?(attr_macro(node))
871
905
  collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
872
906
  false
873
907
  else
@@ -1018,8 +1052,11 @@ module Rigor
1018
1052
  # nodes to skip; soundness is post-domination at the top statement level, so dropping the nil never hides a real
1019
1053
  # runtime-nil read.
1020
1054
  dead_writes = dead_transient_nil_writes(def_node.body, class_name, method_assign_effects)
1055
+ # #1154 — an `@x = x` from a declared parameter records the parameter's declared type.
1056
+ declared = DeclaredParamWrites::EMPTY
1057
+ declared = DeclaredParamWrites.collect(def_node, class_name, body_scope) unless singleton
1021
1058
  gather_ivar_writes(def_node.body, body_scope, class_name, accumulator,
1022
- EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes)
1059
+ EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes, declared)
1023
1060
 
1024
1061
  # B2.3 — collect per-method evidence for the read-before- write nil contribution. The accumulator-level decision
1025
1062
  # ("is this ivar truly read-before-write across the class lifetime?") is finalised at
@@ -1090,14 +1127,19 @@ module Rigor
1090
1127
  # `owner` is empty in a `class << self` body (the call defines a class method there) and in an
1091
1128
  # anonymous factory body, and the block contributes nothing there. A `define_method` inside a `def`
1092
1129
  # body is already reached by that body's walk ({#gather_ivar_writes} enters blocks).
1130
+ #
1131
+ # An ADR-16 `block_as_methods:` call whose entry binds the receiver's instance (`get "/" do … end` in a
1132
+ # `Sinatra::Base` subclass under rigor-sinatra) takes the same arm: the plugin declares that its block runs as an
1133
+ # instance method of the receiver class, and `MacroBlockSelfType` narrows the block's `self` to that instance.
1093
1134
  def collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
1094
1135
  return if owner.empty?
1095
- return unless define_method_block_call?(node)
1136
+
1137
+ class_name = owner.join("::")
1138
+ return unless method_body_block_call?(node, class_name, default_scope)
1096
1139
 
1097
1140
  block = node.block
1098
1141
  return unless block.body
1099
1142
 
1100
- class_name = owner.join("::")
1101
1143
  body_scope = census_body_scope(default_scope, owner, Type::Combinator.nominal_of(class_name))
1102
1144
  gather_ivar_writes(block.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
1103
1145
  written = (accumulator[DEFINE_METHOD_WRITES] ||= {})[class_name] ||= Set.new
@@ -1115,6 +1157,8 @@ module Rigor
1115
1157
  Prism::InstanceVariableTargetNode
1116
1158
  acc << node.name
1117
1159
  end
1160
+ reflective = ReflectiveIvarWrite.literal_name(node)
1161
+ acc << reflective if reflective
1118
1162
  node.rigor_each_child { |child| define_method_written_ivars(child, acc) }
1119
1163
  end
1120
1164
 
@@ -1243,6 +1287,25 @@ module Rigor
1243
1287
  DefineMethodBlockSelf.define_method_on_lexical_self?(node) && node.block.is_a?(Prism::BlockNode)
1244
1288
  end
1245
1289
 
1290
+ # A call with a literal block on the implicit or `self` receiver whose block is an instance method body of
1291
+ # `class_name`, the class the walk is in (#1768): `define_method`, or a call an ADR-16 `block_as_methods:` entry
1292
+ # declares runs its block as an instance method of the receiver class ({MacroBlockSelfType}). The entry is
1293
+ # matched on `Singleton[class_name]`, the receiver such a call has in the class body, through the same
1294
+ # receiver-ancestry match the evaluator narrows the block's `self` with, so the census counts exactly the blocks
1295
+ # whose `self` is narrowed to the instance.
1296
+ #
1297
+ # The class-body walk ({#collect_class_body_ivar_writes}) still records a `block_as_methods:` block's writes as
1298
+ # class-body writes, and {#exempt_class_body_writes!} takes back every ivar such a block writes.
1299
+ def method_body_block_call?(node, class_name, scope)
1300
+ return true if define_method_block_call?(node)
1301
+ return false unless node.block.is_a?(Prism::BlockNode)
1302
+ return false unless scope && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
1303
+
1304
+ MacroBlockSelfType.runs_as_receiver_instance_method?(
1305
+ scope: scope, call_node: node, receiver_type: Type::Combinator.singleton_of(class_name)
1306
+ )
1307
+ end
1308
+
1246
1309
  def detect_read_before_write(node, seen_writes, read_first)
1247
1310
  return unless node.is_a?(Prism::Node)
1248
1311
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
@@ -1270,6 +1333,9 @@ module Rigor
1270
1333
  end
1271
1334
 
1272
1335
  seen_writes << node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
1336
+ # #1931 — a literal `instance_variable_set` is a write too; its value argument, read above, runs first.
1337
+ reflective = ReflectiveIvarWrite.literal_name(node)
1338
+ seen_writes << reflective if reflective
1273
1339
  end
1274
1340
 
1275
1341
  # Records each ivar target of a `MultiWriteNode` / nested `MultiTargetNode` into `seen_writes`, and descends into
@@ -1306,17 +1372,18 @@ module Rigor
1306
1372
  private_constant :TAIL_VALUE_DEPTH_CAP
1307
1373
  private_constant :EMPTY_GUARDED_IVARS
1308
1374
 
1309
- def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
1310
- mutated_ivars = nil, dead_writes = nil)
1375
+ def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS, # rubocop:disable Metrics/ParameterLists
1376
+ mutated_ivars = nil, dead_writes = nil, declared = DeclaredParamWrites::EMPTY)
1311
1377
  return unless node.is_a?(Prism::Node)
1312
1378
 
1313
1379
  if node.is_a?(Prism::InstanceVariableWriteNode)
1314
1380
  unless dead_writes&.include?(node.object_id)
1315
1381
  record_ivar_write(node, scope, class_name, accumulator,
1316
- guarded: guarded_ivars.include?(node.name))
1382
+ guarded: guarded_ivars.include?(node.name), declared: declared[node])
1317
1383
  end
1318
1384
  else
1319
1385
  record_compound_ivar_write(node, scope, class_name, accumulator)
1386
+ record_reflective_ivar_write(node, scope, class_name, accumulator)
1320
1387
  end
1321
1388
 
1322
1389
  # N1 — parallel / multiple assignment (`old, @cb = @cb, block`, `@i, @o, @e, @thr = Open3.popen3(cmd)`). A
@@ -1335,12 +1402,38 @@ module Rigor
1335
1402
 
1336
1403
  if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
1337
1404
  walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
1338
- mutated_ivars, dead_writes)
1405
+ mutated_ivars, dead_writes, declared)
1339
1406
  return
1340
1407
  end
1341
1408
 
1342
1409
  node.rigor_each_child do |c|
1343
- gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes)
1410
+ gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes, declared)
1411
+ end
1412
+ end
1413
+
1414
+ # #1931 — `instance_variable_set` on `self` writes an ivar no write node shows ({ReflectiveIvarWrite}). A literal
1415
+ # name (`instance_variable_set(:@k, v)`) contributes the value's type to that ivar's entry, as `@k = v` would. A
1416
+ # name the analysis cannot read (`instance_variable_set(:"@#{k}", v)`, the options-hash constructor idiom) may
1417
+ # write any ivar of the class, so every entry of the class turns gradual once the walk ends
1418
+ # ({#make_reflective_classes_gradual!}): a reader in another method otherwise kept a write's literal (`@timeout =
1419
+ # 5` read as `5`) and reported a call on it.
1420
+ def record_reflective_ivar_write(node, scope, class_name, accumulator)
1421
+ return unless ReflectiveIvarWrite.self_write?(node)
1422
+
1423
+ name = ReflectiveIvarWrite.literal_name(node)
1424
+ if name
1425
+ accumulate_ivar_type(accumulator, class_name, name, scope.type_of(ReflectiveIvarWrite.value_node(node)))
1426
+ else
1427
+ (accumulator[REFLECTIVE_OPAQUE_CLASSES] ||= Set.new) << class_name
1428
+ end
1429
+ end
1430
+
1431
+ def make_reflective_classes_gradual!(accumulator, classes)
1432
+ classes&.each do |class_name|
1433
+ entries = accumulator[class_name]
1434
+ next if entries.nil?
1435
+
1436
+ accumulator[class_name] = entries.transform_values { Type::Combinator.untyped }
1344
1437
  end
1345
1438
  end
1346
1439
 
@@ -1390,22 +1483,22 @@ module Rigor
1390
1483
  # init; end` would otherwise be marked too — but that pattern (write @x in the else of `if @x`) is a separate
1391
1484
  # idiom whose surrounding reads of `@x` would then surface a nil-receiver FP. The ELSE branch is left ungarded so
1392
1485
  # those reads continue to type as they did before this fix.
1393
- def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
1394
- mutated_ivars = nil, dead_writes = nil)
1486
+ def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, # rubocop:disable Metrics/ParameterLists
1487
+ mutated_ivars = nil, dead_writes = nil, declared = DeclaredParamWrites::EMPTY)
1395
1488
  then_guards = then_body_guarded_ivars(node)
1396
1489
  then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
1397
1490
 
1398
1491
  gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars,
1399
- mutated_ivars, dead_writes)
1492
+ mutated_ivars, dead_writes, declared)
1400
1493
  if node.statements
1401
1494
  gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded,
1402
- mutated_ivars, dead_writes)
1495
+ mutated_ivars, dead_writes, declared)
1403
1496
  end
1404
1497
  branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
1405
1498
  return unless branch
1406
1499
 
1407
1500
  gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars,
1408
- mutated_ivars, dead_writes)
1501
+ mutated_ivars, dead_writes, declared)
1409
1502
  end
1410
1503
 
1411
1504
  # Returns the set of ivar names that, in the THEN body of this conditional, are statically known to be in a nil /
@@ -1882,8 +1975,8 @@ module Rigor
1882
1975
  assigns.include?(target)
1883
1976
  end
1884
1977
 
1885
- def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
1886
- rvalue_type = scope.type_of(node.value)
1978
+ def record_ivar_write(node, scope, class_name, accumulator, guarded: false, declared: nil)
1979
+ rvalue_type = declared || scope.type_of(node.value)
1887
1980
 
1888
1981
  # `@x = nil unless @x` / `@y = false unless @y` — follow-up to the polarity-aware defensive-init guard fix
1889
1982
  # (ROADMAP § Future cycles — "Defensive ivar-init with nil / false rvalue"). When the rvalue is itself a falsey
@@ -2087,7 +2180,9 @@ module Rigor
2087
2180
  # accumulator), and the class-body writes held out of `init_writes` until {#exempt_class_body_writes!}.
2088
2181
  DEFINE_METHOD_WRITES = :define_method_writes
2089
2182
  CLASS_BODY_WRITES = :class_body_writes
2090
- private_constant :DEFINE_METHOD_WRITES, :CLASS_BODY_WRITES
2183
+ # The classes a computed-name `instance_variable_set` may write any ivar of ({#record_reflective_ivar_write}).
2184
+ REFLECTIVE_OPAQUE_CLASSES = :reflective_opaque_classes
2185
+ private_constant :DEFINE_METHOD_WRITES, :CLASS_BODY_WRITES, :REFLECTIVE_OPAQUE_CLASSES
2091
2186
 
2092
2187
  # Unions `type` into the class-ivar accumulator for `(class_name, ivar_name)`. Shared by the single-write and
2093
2188
  # multi-write (parallel-assignment) collectors.
@@ -3369,7 +3464,17 @@ module Rigor
3369
3464
  # walk worked out for it ({#refine_context}).
3370
3465
  MethodTables = Struct.new(:existence, :envelopes, :refinements, :certainty, :certain, :possible,
3371
3466
  :contested_envelopes, :contested_def_nodes, :root, :in_effect, :census,
3372
- :refine_text, :literal_seen, :source_bytes, :loose_constants, :block_spans) do
3467
+ :refine_text, :literal_seen, :source_bytes, :loose_constants, :block_spans,
3468
+ :definer_history) do
3469
+ # Issue #2006 — records, in walk order, that a definer other than an alias wrote the instance method `name`
3470
+ # of `class_name` at `node`: `def_node` for a `def`, nil for a `define_method(:x)` or an `attr_*` reader or
3471
+ # writer, and whether the write is {#certain}. {ScopeIndexer#apply_alias_def_nodes} reads the history to copy
3472
+ # what an alias's old name held at the alias, and to tell an alias a later definer replaced.
3473
+ def wrote_definer(class_name, name, node, def_node)
3474
+ entry = [node.location.start_offset, def_node, certain ? true : false].freeze
3475
+ ((self.definer_history ||= {})[[class_name, name]] ||= []) << entry
3476
+ end
3477
+
3373
3478
  # {ScopeIndexer.module_refine_target} against this tree's {InEffectRefinements}.
3374
3479
  def refine_target_of(node)
3375
3480
  return nil if ScopeIndexer.refine_target(node).nil?
@@ -4088,6 +4193,10 @@ module Rigor
4088
4193
  (singleton_cref && owner_prefix.empty?)
4089
4194
  record_alias_or_undef(node, owner_prefix, in_singleton_class || defs_singleton,
4090
4195
  methods_acc.at(node))
4196
+ if node.is_a?(Prism::AliasMethodNode) && !(in_singleton_class || defs_singleton)
4197
+ supersede_def_nodes(Array(Source::AliasNames.keyword_names(node)&.first), owner_prefix,
4198
+ def_nodes_acc, methods_acc.at(node))
4199
+ end
4091
4200
  end
4092
4201
  return
4093
4202
  when Prism::SymbolNode
@@ -4102,6 +4211,11 @@ module Rigor
4102
4211
  end
4103
4212
  anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc.at(node),
4104
4213
  source_path)
4214
+ unless in_singleton_class
4215
+ superseded = call_superseded_names(node)
4216
+ supersede_def_nodes(superseded, owner_prefix, def_nodes_acc, methods_acc.at(node))
4217
+ note_call_definers(node, superseded, owner_prefix, methods_acc.at(node))
4218
+ end
4105
4219
  tables = methods_acc.at(node)
4106
4220
  record_singleton_prepend(node, qualified_prefix, tables,
4107
4221
  self_ok: node.name == :prepend && tables.in_block?(node))
@@ -4171,11 +4285,40 @@ module Rigor
4171
4285
  def record_declaration_facts(node, child_prefix, tables)
4172
4286
  if node.is_a?(Prism::ClassNode)
4173
4287
  record_meta_superclass_members(node, child_prefix, tables)
4288
+ record_constructed_superclass(node.superclass, child_prefix.join("::"), tables)
4174
4289
  else
4175
4290
  record_surface_mark(tables, child_prefix.join("::"), Scope::DiscoveryIndex::ENVELOPE_MODULE_MARK)
4176
4291
  end
4177
4292
  end
4178
4293
 
4294
+ # Issue #1979 — `class K < Data.define(:a)`, `< Struct.new(:a)` or `< Class.new(Parent)`: the superclass table
4295
+ # records `nil` for K, so a subclass walk enumerates it under every class. Each constant the expression names
4296
+ # (the factory's receiver, and `Class.new`'s argument, or `Object` for none) takes a
4297
+ # `[ENVELOPE_CONSTRUCTED_KIND, name]` entry; an expression of any other shape records none.
4298
+ def record_constructed_superclass(superclass, class_name, tables)
4299
+ names = constructed_superclass_names(superclass)
4300
+ names&.each do |name|
4301
+ record_envelope(tables.envelopes, class_name, [Scope::DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND, name],
4302
+ Source::ParameterEnvelope::OPAQUE)
4303
+ end
4304
+ end
4305
+
4306
+ def constructed_superclass_names(node)
4307
+ return ["Data"] if meta_call_with_name?(node, :Data, :define)
4308
+ return ["Struct"] if meta_call_with_name?(node, :Struct, :new)
4309
+ return nil unless class_new_call?(node)
4310
+
4311
+ arguments = node.arguments&.arguments || []
4312
+ return %w[Class Object] if arguments.empty?
4313
+ return nil unless arguments.one?
4314
+
4315
+ argument = arguments.first
4316
+ inner = if argument.is_a?(Prism::ConstantReadNode) then [argument.name.to_s]
4317
+ else constructed_superclass_names(argument)
4318
+ end
4319
+ inner && ["Class", *inner]
4320
+ end
4321
+
4179
4322
  # The block form of a meta-new constant write: its members, and the module mark `Module.new` earns.
4180
4323
  # Issue #1820 — `K = Class.new(Parent)`, with or without a block, names a class whose superclass the superclass
4181
4324
  # table leaves out; it takes {Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK}.
@@ -4214,7 +4357,7 @@ module Rigor
4214
4357
  # descent.
4215
4358
  def record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
4216
4359
  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)
4360
+ record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc) if attr_macro(node)
4218
4361
  record_module_attr_methods(node, qualified_prefix, methods_acc) if MODULE_ATTR_MACROS.key?(node.name)
4219
4362
  record_alias_method_call(node, qualified_prefix, in_singleton_class, methods_acc)
4220
4363
  record_surface_evidence(node, qualified_prefix, methods_acc)
@@ -4711,6 +4854,7 @@ module Rigor
4711
4854
  accumulator[class_name] ||= {}
4712
4855
  accumulator[class_name][def_node.name] = def_node
4713
4856
  follow_slot_writer(tables, :contested_def_nodes, [class_name, def_node.name], tables.nil? || tables.certain)
4857
+ tables&.wrote_definer(class_name, def_node.name, def_node, def_node)
4714
4858
  record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator, tables)
4715
4859
  end
4716
4860
 
@@ -4727,6 +4871,59 @@ module Rigor
4727
4871
  end
4728
4872
  end
4729
4873
 
4874
+ # Issue #1989 — the instance method names a non-`def` definer written on the class body's self replaces:
4875
+ # each reader (and `x=` writer) an `attr_*` call declares, a literal `define_method(:x)`, and the new name of
4876
+ # an `alias_method :x, :y` call. A non-literal name replaces nothing Rigor can name.
4877
+ def call_superseded_names(call_node)
4878
+ if (macro = attr_macro(call_node))
4879
+ names = []
4880
+ each_attr_macro_name(call_node) do |base|
4881
+ names << base unless macro == :attr_writer
4882
+ names << :"#{base}=" unless macro == :attr_reader
4883
+ end
4884
+ names
4885
+ elsif call_node.name == :define_method
4886
+ define_method_superseded_name(call_node)
4887
+ else
4888
+ Source::AliasNames.alias_method_call_names(call_node)&.first(1) || []
4889
+ end
4890
+ end
4891
+
4892
+ def define_method_superseded_name(call_node)
4893
+ return [] unless call_node.receiver.nil? || call_node.receiver.is_a?(Prism::SelfNode)
4894
+
4895
+ name = literal_method_name(call_node.arguments&.arguments&.first)
4896
+ name.nil? ? [] : [name.to_sym]
4897
+ end
4898
+
4899
+ # Issue #1989 — a definer that is not a `def` replaces the method an earlier `def` of the same name in this walk
4900
+ # installed: `def x = 1; attr_reader :x` answers the attr reader, so the `def` body must not type the call. The
4901
+ # slot keeps the `def` and is contested, certain definer or possible, so the readers that re-type a body
4902
+ # decline. The slot is not emptied: the file still writes it, so a sibling file's `def` of the name (which the
4903
+ # cross-file seed and the project fold would otherwise put there) cannot fill it. A `def` written after the
4904
+ # definer writes the slot again and clears the contest.
4905
+ def supersede_def_nodes(names, qualified_prefix, accumulator, tables)
4906
+ return if names.empty?
4907
+
4908
+ class_name = qualified_prefix.join("::")
4909
+ slot = accumulator[class_name]
4910
+ return if slot.nil?
4911
+
4912
+ names.each do |name|
4913
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, name], false) if slot.key?(name)
4914
+ end
4915
+ end
4916
+
4917
+ # Issue #2006 — a `define_method(:x)` or `attr_*` call is a definer the alias pass reads in source order
4918
+ # ({#apply_alias_def_nodes}). An `alias_method` call, whose new name {#call_superseded_names} also lists, is
4919
+ # itself an alias: the pass records its copy, so the history must not hold it as a definer with no `def`.
4920
+ def note_call_definers(call_node, names, qualified_prefix, tables)
4921
+ return if names.empty? || qualified_prefix.empty? || call_node.name == :alias_method
4922
+
4923
+ class_name = qualified_prefix.join("::")
4924
+ names.each { |name| tables.wrote_definer(class_name, name, call_node, nil) }
4925
+ end
4926
+
4730
4927
  # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
4731
4928
  # Before the anonymous class had a name the body was walked with an empty prefix, so every such `def`
4732
4929
  # landed there; that is the same leniency this key already grants a `def` nested in any other DSL block
@@ -4772,6 +4969,18 @@ module Rigor
4772
4969
  # The singleton walk's accumulator: the `{class => {name => DefNode}}` table, plus the contested slots, which
4773
4970
  # follow each slot's writer ({#follow_slot_writer}'s rule).
4774
4971
  class SingletonDefTable < Hash
4972
+ # Issue #1936 — contests the slots of `names` (every slot of `class_name` for `:all`) that an earlier write
4973
+ # filled: a later non-`def` definer replaced the `def` they hold. The slot keeps the `def`, as
4974
+ # {ScopeIndexer#supersede_def_nodes} keeps the instance one, and a later write clears the contest.
4975
+ def supersede(class_name, names)
4976
+ slot = self[class_name]
4977
+ return if slot.nil?
4978
+
4979
+ (names == :all ? slot.keys : names).each do |name|
4980
+ (@contested ||= Set.new) << [class_name, name] if slot.key?(name)
4981
+ end
4982
+ end
4983
+
4775
4984
  def track(certainty)
4776
4985
  @certainty = certainty
4777
4986
  @contested = nil
@@ -4779,9 +4988,11 @@ module Rigor
4779
4988
  end
4780
4989
 
4781
4990
  # 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)
4991
+ # `possible` says the write rests on a fact the contributions do not carry (a module_function copy after a
4992
+ # reset that may not run, #1968).
4993
+ def write(class_name, name, def_node, node, other = nil, possible: false)
4783
4994
  (self[class_name] ||= {})[name] = def_node
4784
- if Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4995
+ if possible || Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4785
4996
  (@contested ||= Set.new) << [class_name, name]
4786
4997
  else
4787
4998
  @contested&.delete([class_name, name])
@@ -4824,6 +5035,12 @@ module Rigor
4824
5035
  # `Const`'s singleton class (a `remove_method`, a `def`) without declaring `Const`, so it stays out of
4825
5036
  # `class_sources` (whose readers take a name there as one the project declares) and joins only the per-file
4826
5037
  # class-declaration sets the incremental `class:` edges diff (`Runner#class_declarations`).
5038
+ #
5039
+ # Issue #1941 — so does a `def Const.x` (`def D.own`, or `def E.x` inside `module M`, resolved as a `class << E`
5040
+ # receiver is): it adds to `Const`'s singleton side from a file the reader's class edge need not name. In the set,
5041
+ # the declaration diff fires `class:` when the file gains or loses one, and
5042
+ # `IncrementalSession#singleton_rewrite_affected` fires it when one is renamed. `def (M::E).x` records no
5043
+ # singleton `def` at all, so it joins nothing.
4827
5044
  def record_singleton_class_sources(sources, path, root)
4828
5045
  collect_singleton_class_targets(root, []) { |name| (sources[name] ||= Set.new) << path }
4829
5046
  end
@@ -4839,6 +5056,8 @@ module Rigor
4839
5056
  yield eval_constant_receiver_prefix(node, node.expression, rendered, lexical_prefix).join("::") if rendered
4840
5057
  when Prism::CallNode
4841
5058
  yield_singleton_side_constant(node, node.receiver, lexical_prefix, &) if singleton_side_call?(node)
5059
+ when Prism::DefNode
5060
+ yield_singleton_side_constant(node, node.receiver, lexical_prefix, &)
4842
5061
  end
4843
5062
  node.rigor_each_child { |child| collect_singleton_class_targets(child, lexical_prefix, &) }
4844
5063
  end
@@ -4846,8 +5065,7 @@ module Rigor
4846
5065
  # A call on a constant that may rewrite its singleton side (`Const.singleton_class`,
4847
5066
  # `Const.define_singleton_method`, `Const.extend(M)`, `Const.instance_eval { … }`) records facts about `Const`'s
4848
5067
  # 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.
5068
+ # constant's set too. Written inside `Const`'s own body, the name is in the file's set already.
4851
5069
  def singleton_side_call?(node)
4852
5070
  node.name == :singleton_class || surface_rewriting_call?(node)
4853
5071
  end
@@ -4871,7 +5089,7 @@ module Rigor
4871
5089
  # nested `def` or block inside its body is still a range the containment half needs.
4872
5090
  # `def_owner_prefix` is the eval-body override described on {#walk_methods_and_def_nodes}:
4873
5091
  # an explicit owner for def rows while declarations stay lexical (`[]` = ownerless).
4874
- def walk_deferred_ranges(node, qualified_prefix, in_singleton_class, inside_deferred, # rubocop:disable Metrics/ParameterLists
5092
+ def walk_deferred_ranges(node, qualified_prefix, in_singleton_class, inside_deferred, # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
4875
5093
  mf_offsets, ranges, def_owner_prefix = nil, singleton_cref: false,
4876
5094
  defs_singleton: false)
4877
5095
  return unless node.is_a?(Prism::Node)
@@ -4907,11 +5125,34 @@ module Rigor
4907
5125
  singleton_cref: singleton_cref)
4908
5126
  end
4909
5127
 
5128
+ record_definer_name_row(node, ranges) if node.is_a?(Prism::CallNode)
4910
5129
  walk_deferred_children(node, qualified_prefix, in_singleton_class, inside_deferred,
4911
5130
  mf_offsets, ranges, def_owner_prefix, singleton_cref: singleton_cref,
4912
5131
  defs_singleton: defs_singleton)
4913
5132
  end
4914
5133
 
5134
+ DEFINER_NAME_CALLS = Set[:define_method, :define_singleton_method, :alias_method].freeze
5135
+ DEFINER_SEND_CALLS = Set[:send, :__send__, :public_send].freeze
5136
+ private_constant :DEFINER_NAME_CALLS, :DEFINER_SEND_CALLS
5137
+
5138
+ # Issue #2184 — a `define_method` / `define_singleton_method` / `alias_method` call with a literal (new) name,
5139
+ # on any receiver and through `send` too, records an ownerless, kindless row over the name literal: no owner or
5140
+ # side is claimed, so no ordering scan matches it and the literal contains no call. It only tells a by-name
5141
+ # reader that the project gives the name a body no def table holds. A computed name records nothing.
5142
+ def record_definer_name_row(node, ranges)
5143
+ arguments = node.arguments&.arguments || []
5144
+ if DEFINER_SEND_CALLS.include?(node.name)
5145
+ return unless DEFINER_NAME_CALLS.include?(literal_method_name(arguments.first))
5146
+
5147
+ arguments = arguments.drop(1)
5148
+ end
5149
+ return unless DEFINER_NAME_CALLS.include?(node.name) || DEFINER_SEND_CALLS.include?(node.name)
5150
+
5151
+ argument = arguments.first
5152
+ name = literal_method_name(argument)
5153
+ ranges << [argument.location.start_offset, argument.location.end_offset, name, nil, nil] if name
5154
+ end
5155
+
4915
5156
  # The `def` arm of {#walk_deferred_ranges}: records the range row — singleton-side when the
4916
5157
  # def sits in a `class <<` body or an `instance_eval` block (`defs_singleton`) — then walks
4917
5158
  # the body as a deferred range.
@@ -5062,7 +5303,7 @@ module Rigor
5062
5303
  # own `def` leaves additionally split on {INSTANCE_EVAL_CALLS}, whose `def`s bind on the
5063
5304
  # receiver's singleton rather than the receiver.
5064
5305
  def receiver_eval_call?(node)
5065
- node.block.is_a?(Prism::BlockNode) && RECEIVER_EVAL_CALLS.include?(node.name)
5306
+ node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode) && RECEIVER_EVAL_CALLS.include?(node.name)
5066
5307
  end
5067
5308
 
5068
5309
  # A `class_eval` / `module_eval` / `class_exec` / `module_exec` block is NOT deferred: it runs
@@ -5422,7 +5663,9 @@ module Rigor
5422
5663
  mf_offsets, ranges)
5423
5664
  start = def_node.location.start_offset
5424
5665
  if inside_deferred
5425
- ranges << [start, def_node.location.end_offset, nil, nil, nil]
5666
+ # Issue #2184 — the def installs at invocation under a `self` the walk does not know, so the row names the
5667
+ # method but claims no owner or side.
5668
+ ranges << [start, def_node.location.end_offset, def_node.name, nil, nil]
5426
5669
  return
5427
5670
  end
5428
5671
 
@@ -5445,7 +5688,10 @@ module Rigor
5445
5688
  def_owner_prefix = nil, singleton_cref: false,
5446
5689
  defs_singleton: false)
5447
5690
  return unless node.is_a?(Prism::Node)
5448
- if node.is_a?(Prism::CallNode) && receiver_eval_call?(node)
5691
+
5692
+ supersede_singleton_def_nodes(node, qualified_prefix, in_singleton_class, defs_singleton, accumulator,
5693
+ def_owner_prefix)
5694
+ if receiver_eval_call?(node)
5449
5695
  return walk_eval_singleton_defs(node, qualified_prefix, in_singleton_class, accumulator,
5450
5696
  def_owner_prefix, singleton_cref: singleton_cref,
5451
5697
  defs_singleton: defs_singleton)
@@ -5736,7 +5982,8 @@ module Rigor
5736
5982
  defs_singleton: false)
5737
5983
  owner_prefix = def_owner_prefix || qualified_prefix
5738
5984
  statements = statements_of(body)
5739
- ModuleFunctionState.each_singleton_sibling(statements) do |stmt, module_function_on, named_call|
5985
+ conditional = ModuleFunctionState.conditional_clause_statements(body)
5986
+ ModuleFunctionState.each_singleton_sibling(statements, conditional) do |stmt, module_function_on, named_call|
5740
5987
  if named_call
5741
5988
  # `:unnameable` — the named defs the call copies live on the metaclass,
5742
5989
  # which this table cannot name either.
@@ -5786,19 +6033,96 @@ module Rigor
5786
6033
  statements
5787
6034
  end
5788
6035
 
6036
+ # `module_function_on` is {ModuleFunctionState.each_singleton_sibling}'s: `:uncertain` writes the module
6037
+ # function's copy as possible, so the slot holds the later def, contested, rather than the def the
6038
+ # slot held before (#1968).
5789
6039
  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
6040
+ declared = def_singleton?(def_node, qualified_prefix, in_singleton_class)
6041
+ return unless declared || module_function_on
5792
6042
  return if qualified_prefix.empty?
5793
6043
 
5794
6044
  class_name = qualified_prefix.join("::")
5795
- write_singleton_slot(accumulator, class_name, def_node.name, def_node, def_node)
6045
+ write_singleton_slot(accumulator, class_name, def_node.name, def_node, def_node,
6046
+ possible: !declared && module_function_on == :uncertain)
6047
+ end
6048
+
6049
+ # Issue #1936 — the singleton twin of {#supersede_def_nodes}: a definer that is not a `def` replaces the
6050
+ # singleton method an earlier `def self.x` of the walk installed, so the slot is contested and the typing read
6051
+ # declines instead of re-typing the replaced body. The definers, each written on the class's own singleton
6052
+ # class: `singleton_class.alias_method` / `define_method` / `remove_method` / `undef_method` / `attr_*` and its
6053
+ # `class_eval` family and `send` (bare, `self.` or a constant's `singleton_class`), `define_singleton_method`,
6054
+ # and in a `class << self` body the receiverless calls of the same names with `alias` and `undef`. Certain or
6055
+ # possible alike; a call whose rewritten names are not all literal contests every slot the owner holds so far.
6056
+ # Under an `instance_eval` split (`defs_singleton`) only `alias` and `undef` bind on the singleton; its calls
6057
+ # stay instance-side.
6058
+ def supersede_singleton_def_nodes(node, qualified_prefix, in_singleton_class, defs_singleton, accumulator,
6059
+ def_owner_prefix)
6060
+ return unless accumulator.is_a?(SingletonDefTable) && defs_singleton != :unnameable
6061
+
6062
+ owner_prefix = def_owner_prefix || qualified_prefix
6063
+ current = owner_prefix.empty? ? nil : owner_prefix.join("::")
6064
+ owner, names =
6065
+ case node
6066
+ when Prism::AliasMethodNode, Prism::UndefNode
6067
+ [current, singleton_keyword_names(node)] if in_singleton_class || defs_singleton == true
6068
+ when Prism::CallNode
6069
+ singleton_call_target(node, qualified_prefix, current, in_singleton_class)
6070
+ end
6071
+ accumulator.supersede(owner, names) if owner && names
6072
+ end
6073
+
6074
+ def singleton_keyword_names(node)
6075
+ return literal_names_or_all(node.names) if node.is_a?(Prism::UndefNode)
6076
+
6077
+ names = Source::AliasNames.keyword_names(node)
6078
+ names ? names.first(1) : :all
6079
+ end
6080
+
6081
+ # `[owner, names]` for a singleton definer call {#supersede_singleton_def_nodes} reads, or nil.
6082
+ def singleton_call_target(node, qualified_prefix, current, in_singleton_class)
6083
+ receiver = node.receiver
6084
+ if !in_singleton_class && singleton_class_of_receiver?(receiver)
6085
+ [singleton_class_owner(node, receiver.receiver, qualified_prefix, current), singleton_naming_call_names(node)]
6086
+ elsif receiver.nil? || receiver.is_a?(Prism::SelfNode)
6087
+ [current, in_singleton_class ? singleton_naming_call_names(node) : define_singleton_method_names(node)]
6088
+ end
6089
+ end
6090
+
6091
+ # The singleton names a naming call on the singleton class replaces, `:all` where they are not all literal,
6092
+ # or nil for another call. `attr_writer` replaces only `x=`, `attr_reader` only `x`. A `class_eval`-family or
6093
+ # `send` call may replace any name, so it is `:all`.
6094
+ def singleton_naming_call_names(node)
6095
+ arguments = node.arguments&.arguments || []
6096
+ case node.name
6097
+ when *SURFACE_EVAL_CALLS, *SURFACE_SEND_CALLS then :all
6098
+ when :alias_method then literal_names_or_all(arguments.first(1))
6099
+ when :define_method then define_singleton_method_names(node, :define_method)
6100
+ when :remove_method, :undef_method then literal_names_or_all(arguments)
6101
+ when *ATTR_MACROS
6102
+ names = literal_names_or_all(arguments)
6103
+ return names if names == :all
6104
+
6105
+ names.flat_map do |base|
6106
+ [(base unless node.name == :attr_writer), (:"#{base}=" unless node.name == :attr_reader)].compact
6107
+ end
6108
+ end
6109
+ end
6110
+
6111
+ def define_singleton_method_names(node, call = :define_singleton_method)
6112
+ return nil unless node.name == call
6113
+
6114
+ literal_names_or_all(node.arguments&.arguments&.first(1) || [])
6115
+ end
6116
+
6117
+ def literal_names_or_all(arguments)
6118
+ names = arguments.map { |argument| literal_method_name(argument) }
6119
+ names.empty? || names.any?(&:nil?) ? :all : names
5796
6120
  end
5797
6121
 
5798
6122
  # 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)
6123
+ def write_singleton_slot(accumulator, class_name, name, def_node, node, other = nil, possible: false)
5800
6124
  if accumulator.is_a?(SingletonDefTable)
5801
- accumulator.write(class_name, name, def_node, node, other)
6125
+ accumulator.write(class_name, name, def_node, node, other, possible: possible)
5802
6126
  else
5803
6127
  (accumulator[class_name] ||= {})[name] = def_node
5804
6128
  end
@@ -6695,32 +7019,34 @@ module Rigor
6695
7019
  # differently for the two spellings of one edge.
6696
7020
  def record_mixin_call(node, qualified_prefix, current_class, accumulator)
6697
7021
  taint_opaque_eval(node, current_class, accumulator)
6698
- kind, _arguments, via_send = mixin_call_view(node)
6699
- return if kind.nil?
7022
+ view = mixin_call_view(node)
7023
+ return if view.nil? || view.first.nil?
6700
7024
 
7025
+ kind = view.first
6701
7026
  current_class ||= toplevel_include_owner(node, qualified_prefix, current_class, accumulator)
6702
7027
  effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: false)
6703
7028
  .select { |_owner, side| side == :include }
6704
7029
  return if effects.empty?
6705
- return if !via_send && instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
7030
+ return if instance_mixin_recorded?(node, view, qualified_prefix, current_class, accumulator)
6706
7031
 
6707
7032
  # A mixin call the tables cannot hold still reshapes the ancestry: taint the whole owner side.
6708
7033
  effects.each { |owner, side| accumulator.taint(owner, side) }
6709
7034
  end
6710
7035
 
6711
7036
  # 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)
7037
+ # nothing — the caller then taints the owner instead. `view` is {#mixin_call_view}'s, so
7038
+ # `Integer.send(:include, Ext)` records `Ext` as `Integer.include(Ext)` does (issue #1722).
7039
+ def instance_mixin_recorded?(node, view, qualified_prefix, current_class, accumulator)
7040
+ kind, arguments, via_send = view
7041
+ return false unless mixin_call_recorded?(node, kind, via_send, current_class)
6715
7042
 
6716
- arguments = node.arguments&.arguments || []
6717
7043
  targets = arguments.filter_map { |arg| Source::ConstantPath.qualified_name(arg) }
6718
7044
  return false if targets.empty?
6719
7045
 
6720
7046
  owner = node.receiver.nil? ? current_class : prepend_call_receiver(node, qualified_prefix)
6721
7047
  return false if owner.nil?
6722
7048
 
6723
- write_mixin_targets(accumulator, owner, targets, prepend: node.name == :prepend)
7049
+ write_mixin_targets(accumulator, owner, targets, prepend: kind == :prepend)
6724
7050
  accumulator.note(node, owner, :include, targets, complete: targets.size == arguments.size)
6725
7051
  true
6726
7052
  end
@@ -6868,15 +7194,17 @@ module Rigor
6868
7194
  end
6869
7195
 
6870
7196
  # 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?
7197
+ # enclosing declaration to file the edge under, and the RECEIVER form (`Base.prepend(Loud)`, issue #1123;
7198
+ # `Integer.include(Ext)` and `Integer.send(:include, Ext)`, issue #1722) files it under the class the
7199
+ # receiver names. A receiver-form edge is unpositioned wherever it is written ({MixinAccumulator#note}), so
7200
+ # a reader that depends on ORDER declines on it, while the set-shaped readers (undefined-method
7201
+ # suppression, arity, visibility) see the module as they see an in-body `include`. A receiverless `send`
7202
+ # stays a taint: the walk does not resolve its `self`.
7203
+ def mixin_call_recorded?(node, kind, via_send, current_class)
7204
+ return false unless MIXIN_CALL_NAMES.include?(kind)
7205
+ return !via_send && !current_class.nil? if node.receiver.nil?
6878
7206
 
6879
- node.name == :prepend
7207
+ true
6880
7208
  end
6881
7209
 
6882
7210
  # Issue #1697 — a receiverless `include M` written as a statement of the file's own top level is
@@ -7315,7 +7643,8 @@ module Rigor
7315
7643
  return record_module_function(node, current_class, accumulator, in_singleton) if node.name == :module_function
7316
7644
 
7317
7645
  taint_opaque_eval(node, current_class, accumulator)
7318
- return if singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7646
+ return if singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator,
7647
+ in_singleton: in_singleton)
7319
7648
 
7320
7649
  kind, _arguments, via_send = mixin_call_view(node)
7321
7650
  return if kind.nil?
@@ -7323,7 +7652,8 @@ module Rigor
7323
7652
  effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
7324
7653
  .select { |_owner, side| side == :extend || in_singleton }
7325
7654
  return if effects.empty?
7326
- return if !via_send && singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
7655
+ return if !via_send && singleton_mixin_recorded?(node, kind, qualified_prefix, current_class, accumulator,
7656
+ in_singleton)
7327
7657
 
7328
7658
  effects.each { |owner, side| accumulator.taint(owner, side) }
7329
7659
  end
@@ -7338,7 +7668,7 @@ module Rigor
7338
7668
  # but only where it is a direct statement (a top-level statement, or one of a direct declaration body or of a
7339
7669
  # block that runs there once): there the methods walk records those marks under the same owner. In a method
7340
7670
  # body, a hook or a conditional it may run on a receiver or at a time the marks cannot place, so it still taints.
7341
- def singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7671
+ def singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator, in_singleton: false)
7342
7672
  receiver = node.receiver
7343
7673
  return false unless singleton_class_of_receiver?(receiver) && surface_rewriting_call?(node)
7344
7674
  return false if singleton_class_named_call?(node) && accumulator.direct?(node)
@@ -7346,10 +7676,33 @@ module Rigor
7346
7676
  owner = singleton_class_owner(node, receiver.receiver, qualified_prefix, current_class)
7347
7677
  return false if owner.nil?
7348
7678
 
7679
+ # Inside `class << self` a bare `singleton_class` is the singleton's own singleton, one level up from the
7680
+ # owner's extend side, so that call stays a taint.
7681
+ up_level = in_singleton && lexical_self_receiver?(receiver.receiver)
7682
+ return true if !up_level && singleton_class_include_recorded?(node, owner, accumulator)
7683
+
7349
7684
  accumulator.taint(owner, :extend)
7350
7685
  true
7351
7686
  end
7352
7687
 
7688
+ # Issue #2094 — `Float.singleton_class.include(Ext)` (or `prepend`, which the extend table cannot order, so the
7689
+ # edge is unpositioned as every receiver-form edge is) puts `Ext` on the singleton side as `Float.extend(Ext)`
7690
+ # does, the fold #915 gives `class << self; include M`. A `send` spelling or an argument that is not a constant
7691
+ # stays a taint.
7692
+ def singleton_class_include_recorded?(node, owner, accumulator)
7693
+ return false unless %i[include prepend].include?(node.name)
7694
+
7695
+ arguments = node.arguments&.arguments || []
7696
+ return false if arguments.empty? || arguments.any? { |arg| Source::ConstantPath.qualified_name(arg).nil? }
7697
+
7698
+ !record_extend_targets(node, owner, accumulator).empty?
7699
+ end
7700
+
7701
+ # A bare or `self.` receiver: the lexical `self`.
7702
+ def lexical_self_receiver?(receiver)
7703
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
7704
+ end
7705
+
7353
7706
  # A bare, `self.` or constant receiver's `singleton_class` call.
7354
7707
  def singleton_class_of_receiver?(receiver)
7355
7708
  receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil?
@@ -7408,13 +7761,29 @@ module Rigor
7408
7761
 
7409
7762
  # Records the singleton-side edges of a receiverless `extend` (or, inside `class << self`, `include` /
7410
7763
  # `prepend`). False when it recorded nothing.
7411
- def singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
7412
- return false unless current_class && node.receiver.nil?
7764
+ def singleton_mixin_recorded?(node, kind, qualified_prefix, current_class, accumulator, in_singleton)
7765
+ return receiver_extend_recorded?(node, kind, qualified_prefix, accumulator) if node.receiver
7766
+ return false unless current_class
7413
7767
  return false unless in_singleton ? MIXIN_CALL_NAMES.include?(kind) : kind == :extend
7414
7768
 
7415
7769
  !record_extend_targets(node, current_class, accumulator).empty?
7416
7770
  end
7417
7771
 
7772
+ # Issue #2094 — the receiver form, `Float.extend(Ext)`: the singleton twin of #1722's `Integer.include(Ext)`,
7773
+ # filed under the class the constant receiver names ({#prepend_call_receiver}) and, like that form,
7774
+ # unpositioned wherever it is written ({MixinAccumulator#note}). A `self` argument names the receiver, not the
7775
+ # enclosing class, so a call with one stays a taint. A constant receiver names the same class inside
7776
+ # `class << self`, so the walk's singleton flag does not matter here.
7777
+ def receiver_extend_recorded?(node, kind, qualified_prefix, accumulator)
7778
+ return false if kind != :extend
7779
+ return false if (node.arguments&.arguments || []).any?(Prism::SelfNode)
7780
+
7781
+ owner = prepend_call_receiver(node, qualified_prefix)
7782
+ return false if owner.nil?
7783
+
7784
+ !record_extend_targets(node, owner, accumulator).empty?
7785
+ end
7786
+
7418
7787
  # The table stores search order, not call order: a later `extend` statement prepends its module
7419
7788
  # (`extend A; extend B` → singleton ancestors `[B, A]`) while the arguments of a single
7420
7789
  # `extend A, B` keep call order (`[A, B]`). Prepending each call's argument list preserves both,
@@ -7589,6 +7958,8 @@ module Rigor
7589
7958
  # - **Wrap-around form** (#1911, #1819, #1633): `private def foo` and `private attr_reader :foo` record the
7590
7959
  # members with the call's visibility and leave the default alone.
7591
7960
  #
7961
+ # A `module_function` call takes each form as `private` does (#1569): Ruby makes the instance copy private.
7962
+ #
7592
7963
  # Top-level (no surrounding class) defs do not contribute — Ruby's top-level visibility nuances (private at
7593
7964
  # top-level marks the method on `Object`) are out of scope for v0.1.2.
7594
7965
  def build_discovered_method_visibilities(root)
@@ -7605,8 +7976,8 @@ module Rigor
7605
7976
  end
7606
7977
 
7607
7978
  # 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.
7979
+ # `module_function`, which {#walk_method_visibilities} applies as `private` on the instance side
7980
+ # ({ModuleFunctionState.instance_visibility}, #1569).
7610
7981
  VISIBILITY_TOGGLES = (VISIBILITY_MODIFIERS + %i[module_function]).freeze
7611
7982
 
7612
7983
  # The `{class => {name => visibility}}` accumulator of {#walk_method_visibilities}, which also carries the
@@ -7644,15 +8015,38 @@ module Rigor
7644
8015
  end
7645
8016
  end
7646
8017
 
8018
+ # Issue #2013 — contests the instance slot of `name`, recorded or not, and marks the contest as a
8019
+ # replacement's ({ScopeIndexer.replaced_visibility_mark}): a `define_method(:name)` or an alias gave the name
8020
+ # a visibility the walk does not read, and another file may record the slot. A later certain write in this
8021
+ # walk clears the contest ({#wrote}) and leaves the mark, which then only keeps a contest another file puts on
8022
+ # the slot: the fold declines more, never reports more, and file order is not load order there either.
8023
+ def contest_replaced(class_name, name)
8024
+ contest_slot(class_name, name)
8025
+ contest_slot(class_name, ScopeIndexer.replaced_visibility_mark(name))
8026
+ end
8027
+
7647
8028
  # Contests the `[class_name, name]` slot, recorded or not: a visibility call changed it in a way the walk
7648
8029
  # cannot read (`private def self.x` reaching an instance `x`).
7649
8030
  def contest_slot(class_name, name)
7650
8031
  (@contested ||= Set.new) << [class_name, name].freeze
7651
8032
  end
7652
8033
 
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) }
8034
+ # Contests every slot of `class_name` on `side` recorded so far (`private(*names)` may name any of them).
8035
+ # Issue #1984 — on the singleton side it also contests the class's wildcard slot
8036
+ # ({ScopeIndexer::SINGLETON_WILDCARD_SLOT}): the names may be ones another file records.
8037
+ def contest_class(class_name, side = :instance)
8038
+ self[class_name]&.each_key do |slot|
8039
+ contest_slot(class_name, slot) if slot.is_a?(Array) == (side == :singleton)
8040
+ end
8041
+ contest_slot(class_name, SINGLETON_WILDCARD_SLOT) if side == :singleton
8042
+ end
8043
+
8044
+ # Issue #1984 — records `visibility` in the slot of `name` on `side` ({ScopeIndexer.visibility_slot}) and
8045
+ # settles its contest as {#wrote} does.
8046
+ def write(class_name, name, side, visibility, node, toggled:)
8047
+ slot = ScopeIndexer.visibility_slot(name, side)
8048
+ (self[class_name] ||= {})[slot] = visibility
8049
+ wrote(class_name, slot, node, toggled: toggled)
7656
8050
  end
7657
8051
 
7658
8052
  # Runs the block with the running default uncertain, so a `def` it records is contested.
@@ -7769,10 +8163,12 @@ module Rigor
7769
8163
  return current_visibility
7770
8164
  end
7771
8165
  when Prism::DefNode
7772
- record_def_visibility(node, owner_prefix, in_singleton_class || defs_singleton,
8166
+ contest_foreign_singleton_visibility(node, qualified_prefix, owner_prefix, in_singleton_class, accumulator)
8167
+ record_def_visibility(node, owner_prefix, visibility_side(in_singleton_class, defs_singleton),
7773
8168
  current_visibility, accumulator)
7774
8169
  return current_visibility
7775
8170
  when Prism::CallNode
8171
+ record_constant_class_method_calls(node, qualified_prefix, accumulator)
7776
8172
  if receiver_eval_call?(node)
7777
8173
  walk_eval_visibilities(node, qualified_prefix, in_singleton_class, current_visibility,
7778
8174
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
@@ -7787,23 +8183,50 @@ module Rigor
7787
8183
  end
7788
8184
  if (parts = visibility_call_parts(node))
7789
8185
  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|
8186
+ # A `module_function` call is applied as a `private` one (#1569): it makes the instance copy private.
8187
+ visibility = parts.first
8188
+ visibility = ModuleFunctionState.instance_visibility unless VISIBILITY_MODIFIERS.include?(visibility)
8189
+ # Issue #1984 — inside `class << self` (or a `class_eval` on the singleton class) the call marks the
8190
+ # SINGLETON methods, recorded under their `[:singleton, name]` slots. `module_function` is no method of
8191
+ # a singleton class, so it is walked as any other call there. The `owner_prefix` check is a guard: an
8192
+ # ownerless body (the top level, an anonymous factory block) records nothing, and its calls walk as
8193
+ # any other call.
8194
+ # An `instance_eval` body's call (`:eval`) runs on the receiver as a module: the instance side.
8195
+ side = visibility_side(in_singleton_class, defs_singleton)
8196
+ side = :instance if side == :eval
8197
+ if side && !owner_prefix.empty? && (side == :instance || VISIBILITY_MODIFIERS.include?(parts.first))
8198
+ return apply_visibility_call(node, visibility, parts.last, owner_prefix, current_visibility, accumulator,
8199
+ side: side, defs_singleton: defs_singleton) do |argument|
8200
+ walk_method_visibilities(argument, qualified_prefix, in_singleton_class, current_visibility,
8201
+ accumulator, def_owner_prefix, singleton_cref: singleton_cref,
8202
+ defs_singleton: defs_singleton)
8203
+ end
8204
+ end
8205
+ end
8206
+ if !in_singleton_class && !owner_prefix.empty?
8207
+ class_method_parts = class_method_visibility_parts(node)
8208
+ if class_method_parts
8209
+ apply_class_method_visibility(node, *class_method_parts, owner_prefix, accumulator) do |argument|
7798
8210
  walk_method_visibilities(argument, qualified_prefix, in_singleton_class, current_visibility,
7799
8211
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
7800
8212
  defs_singleton: defs_singleton)
7801
8213
  end
8214
+ return current_visibility
7802
8215
  end
8216
+ contest_singleton_rewrites(node, owner_prefix, accumulator)
7803
8217
  end
7804
- unless in_singleton_class || defs_singleton
7805
- record_attr_visibility(node, owner_prefix, current_visibility, accumulator, toggled: true)
8218
+ side = visibility_side(in_singleton_class, defs_singleton)
8219
+ if %i[instance singleton].include?(side)
8220
+ record_attr_visibility(node, owner_prefix, current_visibility, accumulator, toggled: true, side: side)
8221
+ contest_replaced_visibility(replaced_by_call(node), owner_prefix, accumulator, side: side)
7806
8222
  end
8223
+ when Prism::AliasMethodNode
8224
+ side = visibility_side(in_singleton_class, defs_singleton)
8225
+ if %i[instance singleton].include?(side)
8226
+ contest_replaced_visibility(Array(Source::AliasNames.keyword_names(node)&.first), owner_prefix,
8227
+ accumulator, side: side)
8228
+ end
8229
+ return current_visibility
7807
8230
  end
7808
8231
 
7809
8232
  # Statement-position StatementsNode preserves left-to-right visibility flow; everything else recurses with the
@@ -7887,6 +8310,9 @@ module Rigor
7887
8310
  singleton_cref: singleton_cref,
7888
8311
  defs_singleton: defs_singleton)
7889
8312
  end
8313
+ return if singleton_class_eval_walked?(node, qualified_prefix, in_singleton_class, accumulator,
8314
+ def_owner_prefix, singleton_cref, defs_singleton)
8315
+
7890
8316
  node.arguments&.arguments&.each do |arg|
7891
8317
  walk_method_visibilities(arg, qualified_prefix, in_singleton_class, current_visibility,
7892
8318
  accumulator, def_owner_prefix, singleton_cref: singleton_cref,
@@ -7911,63 +8337,191 @@ module Rigor
7911
8337
  end
7912
8338
  end
7913
8339
 
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?
8340
+ # Issue #1984 — `singleton_class.class_eval do … end` in a class body (or `module_eval` / `class_exec` /
8341
+ # `module_exec`) runs its block as a `class << self` body does: its `def`s and visibility calls land on the
8342
+ # class's singleton slots. True when it walked the block so.
8343
+ def singleton_class_eval_walked?(node, qualified_prefix, in_singleton_class, accumulator,
8344
+ def_owner_prefix, singleton_cref, defs_singleton)
8345
+ return false if in_singleton_class || defs_singleton || INSTANCE_EVAL_CALLS.include?(node.name)
8346
+ return false unless own_singleton_class?(node.receiver) && node.arguments.nil?
8347
+
8348
+ owner_prefix = def_owner_prefix || qualified_prefix
8349
+ return false if owner_prefix.empty?
8350
+
8351
+ if node.block.body
8352
+ accumulator.body do
8353
+ walk_method_visibilities(node.block.body, qualified_prefix, true, :public, accumulator, owner_prefix,
8354
+ singleton_cref: singleton_cref)
8355
+ end
8356
+ end
8357
+ true
8358
+ end
8359
+
8360
+ # Issue #1984 — the slot a contest stands on for every singleton name of a class: a call that may make any of
8361
+ # them public (`K.public_class_method(*names)`, `K.singleton_class.class_eval { … }`).
8362
+ SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
8363
+
8364
+ # Issue #2013 — the mark a contested instance slot `name` carries in `contested_discovered_method_visibilities`
8365
+ # when a `define_method(:name)` or an alias replaced the method: `[:replaced, name]`. No reader asks for it; it
8366
+ # keeps the slot's contest when another file's write of the slot folds later ({#fold_visibility_contests}),
8367
+ # since file order is not load order.
8368
+ def self.replaced_visibility_mark(name) = [:replaced, name].freeze
8369
+
8370
+ # Issue #1984 — a class method visibility call written on a constant (`K.public_class_method(:x)`,
8371
+ # `K.send(:public_class_method, :x)`), anywhere: each literal name is recorded public on every class the
8372
+ # constant may name, so a reopening's or another file's `private_class_method` disagrees with it and is
8373
+ # contested. A `K.private_class_method` is not read (the rule only declines more where it is missing). Any call
8374
+ # on `K.singleton_class` (`send(:public, :x)`, `class_eval { … }`), or a `public_class_method` whose names are
8375
+ # not literal, contests every singleton name of the class.
8376
+ def record_constant_class_method_calls(node, qualified_prefix, accumulator)
8377
+ receiver = node.receiver
8378
+ if singleton_class_of_receiver?(receiver) && constant_receiver?(receiver.receiver)
8379
+ each_constant_class(receiver.receiver, qualified_prefix) do |class_name|
8380
+ accumulator.contest_class(class_name, :singleton)
8381
+ end
8382
+ return
8383
+ end
8384
+ names = constant_public_class_method_names(node)
8385
+ return if names.nil?
8386
+
8387
+ each_constant_class(receiver, qualified_prefix) do |class_name|
8388
+ next accumulator.contest_class(class_name, :singleton) if names.any?(&:nil?)
8389
+
8390
+ names.each do |method_name|
8391
+ accumulator.write(class_name, method_name, :singleton, :public, node, toggled: false)
8392
+ end
8393
+ end
8394
+ end
8395
+
8396
+ # The names a `K.public_class_method(…)` (or `K.send(:public_class_method, …)`) call on a constant names, a nil
8397
+ # for each one that is not a literal; nil for any other call.
8398
+ def constant_public_class_method_names(node)
8399
+ return nil unless constant_receiver?(node.receiver)
8400
+
8401
+ name = node.name
8402
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
8403
+ if VISIBILITY_SEND_CALLS.include?(name) || name == :public_send
8404
+ name = Source::Literals.symbol_or_string(arguments.first)
8405
+ arguments = arguments.drop(1)
8406
+ end
8407
+ return nil unless name == :public_class_method
8408
+
8409
+ arguments.flat_map { |arg| arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg] }
8410
+ .map { |arg| Source::Literals.symbol_or_string(arg) }
8411
+ end
8412
+
8413
+ def constant_receiver?(node)
8414
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
8415
+ end
8416
+
8417
+ def each_constant_class(receiver, qualified_prefix, &)
8418
+ constant_receiver_candidates(receiver, qualified_prefix).each(&)
8419
+ end
8420
+
8421
+ # Issue #1984 — the side a body's members land on: `:singleton` in a `class << self` body or a `class_eval` on
8422
+ # the singleton class, `:eval` in an `instance_eval` body on a class (its `def`s are singleton methods, its
8423
+ # visibility calls instance-side), `:instance` in a class body, and nil where the singleton cannot be named.
8424
+ def visibility_side(in_singleton_class, defs_singleton)
8425
+ return defs_singleton == :unnameable ? nil : :singleton if in_singleton_class
8426
+ return :eval if defs_singleton
8427
+
8428
+ :instance
8429
+ end
8430
+
8431
+ # Issue #1984 — the key of `name`'s slot in a class's visibility table: the name on the instance side,
8432
+ # `[:singleton, name]` on the singleton side (the envelope table's spelling).
8433
+ def visibility_slot(name, side)
8434
+ side == :singleton ? [:singleton, name].freeze : name
8435
+ end
8436
+
8437
+ # Records the visibility a `def` gets ({#visibility_side} `side`):
8438
+ #
8439
+ # - a receiverless `def` takes `current_visibility` on its side; `toggled` when that is the running default
8440
+ # (a statement), false for one a visibility call wraps;
8441
+ # - issue #1984 — `def self.x` (or `def K.x` in `K`'s body) is a public singleton method whatever the running
8442
+ # default is (`private; def self.x` is public), and writes nothing in a singleton body (it defines a method
8443
+ # of the singleton's singleton);
8444
+ # - an `instance_eval` body's `def` (`:eval`) replaces a singleton method with a visibility the walk does not
8445
+ # read, so its singleton slot is contested.
8446
+ def record_def_visibility(def_node, qualified_prefix, side, current_visibility, accumulator, toggled: true)
8447
+ return if side.nil? || qualified_prefix.empty?
7919
8448
 
7920
8449
  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)
8450
+ if side == :eval
8451
+ accumulator.contest_slot(class_name, visibility_slot(def_node.name, :singleton))
8452
+ elsif def_node.receiver.nil?
8453
+ accumulator.write(class_name, def_node.name, side, current_visibility, def_node, toggled: toggled)
8454
+ elsif side == :instance && self_singleton_def?(def_node, qualified_prefix)
8455
+ accumulator.write(class_name, def_node.name, :singleton, :public, def_node, toggled: false)
8456
+ end
8457
+ end
8458
+
8459
+ def self_singleton_def?(def_node, qualified_prefix)
8460
+ def_node.receiver.is_a?(Prism::SelfNode) || def_receiver_targets_lexical_self?(def_node.receiver,
8461
+ qualified_prefix)
8462
+ end
8463
+
8464
+ # Issue #1984 — `def Other.x` whose receiver is not the lexical self (`def_owner`) makes `Other.x` public in
8465
+ # whatever order the files load, so each class the constant may name has its singleton slot contested.
8466
+ def contest_foreign_singleton_visibility(def_node, qualified_prefix, def_owner, in_singleton_class, accumulator)
8467
+ receiver = def_node.receiver
8468
+ return unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
8469
+ return if !in_singleton_class && def_receiver_targets_lexical_self?(receiver, def_owner)
8470
+
8471
+ constant_receiver_candidates(receiver, qualified_prefix).each do |name|
8472
+ accumulator.contest_slot(name, visibility_slot(def_node.name, :singleton))
8473
+ end
7924
8474
  end
7925
8475
 
7926
8476
  # Applies one `private` / `protected` / `public` call ({#visibility_call_parts}) written on the body's self
7927
- # and returns the running default after it:
8477
+ # and returns the running default after it. `side` is the side its names land on: `:singleton` in a
8478
+ # `class << self` body (issue #1984), `:instance` elsewhere.
7928
8479
  #
7929
8480
  # - no arguments — the call's visibility becomes the default for subsequent defs.
7930
8481
  # - `private :foo, :bar`, `private %i[foo bar]` — back-patch the named methods in the accumulator; the
7931
8482
  # default does NOT change for this form.
7932
8483
  # - Issues #1911, #1819, #1633 — `private def foo` (endless or not) and `private attr_reader :foo` define
7933
8484
  # 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
8485
+ # public); `private def self.foo` changes the method `foo` of the side, if one exists, so its slot is
8486
+ # contested, and defines a public singleton `foo` (a singleton-singleton one in a `class << self` body).
8487
+ # - a splat (`private(*names)`) may name any member or none (a bare toggle), so the class's slots on the side
8488
+ # this file wrote so far are contested, and the statement loop reads the default after it as uncertain
7938
8489
  # ({#visibility_toggle_arguments?}).
7939
8490
  # - any other argument (`private memoize def foo`, `private delegate(...)`) is walked with the default
7940
8491
  # uncertain, so a `def` under it is contested: the call's visibility reaches it through a return value
7941
8492
  # the walk does not read.
7942
8493
  #
7943
8494
  # `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)
8495
+ def apply_visibility_call(call_node, name, args, qualified_prefix, current_visibility, accumulator, # rubocop:disable Metrics/ParameterLists
8496
+ side: :instance, defs_singleton: false, &walk)
7946
8497
  return name if args.empty?
7947
8498
 
7948
8499
  class_name = qualified_prefix.join("::")
7949
- accumulator.contest_class(class_name) if args.any?(Prism::SplatNode)
8500
+ accumulator.contest_class(class_name, side) if args.any?(Prism::SplatNode)
7950
8501
  args.each do |arg|
7951
8502
  apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator,
7952
- defs_singleton: defs_singleton, &walk)
8503
+ side: side, defs_singleton: defs_singleton, &walk)
7953
8504
  end
7954
8505
  current_visibility
7955
8506
  end
7956
8507
 
7957
- def apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator, defs_singleton:, &walk)
8508
+ def apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator, side:, defs_singleton:, &walk)
7958
8509
  case arg
7959
8510
  when Prism::DefNode
7960
8511
  if arg.receiver.nil?
7961
- record_def_visibility(arg, qualified_prefix, defs_singleton, name, accumulator, toggled: false)
8512
+ def_side = side == :instance && defs_singleton ? :eval : side
8513
+ record_def_visibility(arg, qualified_prefix, def_side, name, accumulator, toggled: false)
7962
8514
  else
7963
- accumulator.contest_slot(qualified_prefix.join("::"), arg.name)
8515
+ accumulator.contest_slot(qualified_prefix.join("::"), visibility_slot(arg.name, side))
8516
+ record_def_visibility(arg, qualified_prefix, defs_singleton ? :eval : side, name, accumulator,
8517
+ toggled: false)
7964
8518
  end
7965
8519
  when Prism::SymbolNode, Prism::StringNode, Prism::ArrayNode
7966
- apply_named_visibility(arg, name, qualified_prefix, call_node, accumulator, &walk)
8520
+ apply_named_visibility(arg, name, qualified_prefix, call_node, accumulator, side: side, &walk)
7967
8521
  else
7968
8522
  # An `instance_eval` body's wrapped attr is walked like any other argument and records nothing.
7969
8523
  if !defs_singleton && attr_visibility_call?(arg)
7970
- record_attr_visibility(arg, qualified_prefix, name, accumulator, toggled: false)
8524
+ record_attr_visibility(arg, qualified_prefix, name, accumulator, toggled: false, side: side)
7971
8525
  else
7972
8526
  accumulator.uncertainly { walk.call(arg) }
7973
8527
  end
@@ -7976,16 +8530,14 @@ module Rigor
7976
8530
 
7977
8531
  # `private :foo` / `private "foo"` / `private %i[foo bar]`: each literal name the call's visibility. An array
7978
8532
  # 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)
8533
+ def apply_named_visibility(arg, visibility, qualified_prefix, call_node, accumulator, side: :instance, &walk)
7980
8534
  elements = arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg]
7981
8535
  class_name = qualified_prefix.join("::")
7982
8536
  elements.each do |element|
7983
8537
  name = visibility_target_name(element)
7984
8538
  next accumulator.uncertainly { walk.call(element) } if name.nil?
7985
8539
 
7986
- accumulator[class_name] ||= {}
7987
- accumulator[class_name][name] = visibility
7988
- accumulator.wrote(class_name, name, call_node, toggled: false)
8540
+ accumulator.write(class_name, name, side, visibility, call_node, toggled: false)
7989
8541
  end
7990
8542
  end
7991
8543
 
@@ -7993,30 +8545,135 @@ module Rigor
7993
8545
  Source::Literals.symbol_or_string(arg)
7994
8546
  end
7995
8547
 
8548
+ CLASS_METHOD_VISIBILITIES = { private_class_method: :private, public_class_method: :public }.freeze
8549
+ private_constant :CLASS_METHOD_VISIBILITIES
8550
+
8551
+ # Issue #1984 — `[visibility, arguments]` for a `private_class_method` / `public_class_method` call written on
8552
+ # the body's self, in the spellings {#visibility_call_parts} reads (receiverless, `self.`, `send` with a
8553
+ # literal name); nil for any other call.
8554
+ def class_method_visibility_parts(node)
8555
+ return nil unless node.is_a?(Prism::CallNode)
8556
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
8557
+
8558
+ name = node.name
8559
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
8560
+ if VISIBILITY_SEND_CALLS.include?(name)
8561
+ name = Source::Literals.symbol_or_string(arguments.first)
8562
+ arguments = arguments.drop(1)
8563
+ end
8564
+ visibility = CLASS_METHOD_VISIBILITIES[name]
8565
+ visibility && [visibility, arguments]
8566
+ end
8567
+
8568
+ # Issue #1984 — applies `private_class_method` / `public_class_method` to the class's singleton slots. It sets
8569
+ # no running default (with no arguments it does nothing). A literal name (`:x`, `"x"`, `[:x]`) and a wrapped
8570
+ # `def self.x` take the call's visibility; a wrapped receiverless `def x` defines an instance method (walked as
8571
+ # a statement) and names the singleton `x` the walk does not see, as does any other receiver, so that slot is
8572
+ # contested; a splat or any other argument may name any singleton method, so every singleton slot the file
8573
+ # wrote so far for the class is contested, and the argument is walked.
8574
+ def apply_class_method_visibility(call_node, visibility, args, qualified_prefix, accumulator, &walk)
8575
+ class_name = qualified_prefix.join("::")
8576
+ args.each do |arg|
8577
+ case arg
8578
+ when Prism::DefNode
8579
+ if arg.receiver && self_singleton_def?(arg, qualified_prefix)
8580
+ accumulator.write(class_name, arg.name, :singleton, visibility, call_node, toggled: false)
8581
+ else
8582
+ walk.call(arg)
8583
+ accumulator.contest_slot(class_name, visibility_slot(arg.name, :singleton))
8584
+ end
8585
+ when Prism::SymbolNode, Prism::StringNode, Prism::ArrayNode
8586
+ apply_named_visibility(arg, visibility, qualified_prefix, call_node, accumulator, side: :singleton) do |el|
8587
+ accumulator.contest_class(class_name, :singleton)
8588
+ walk.call(el)
8589
+ end
8590
+ else
8591
+ accumulator.contest_class(class_name, :singleton)
8592
+ walk.call(arg)
8593
+ end
8594
+ end
8595
+ end
8596
+
8597
+ SINGLETON_DEFINING_CALLS = %i[define_singleton_method].freeze
8598
+ private_constant :SINGLETON_DEFINING_CALLS
8599
+
8600
+ # Issue #1984 — calls in a class body that change a singleton method's visibility in a way the walk does not
8601
+ # read, so the singleton slots they may reach are contested: `define_singleton_method(:x)` (a fresh public
8602
+ # method; a computed name contests every singleton slot of the class), and any call on `singleton_class`
8603
+ # (`singleton_class.send(:public, :x)`, `singleton_class.define_method(:x)`), whose literal arguments name
8604
+ # the slots and whose other arguments may name any.
8605
+ def contest_singleton_rewrites(node, qualified_prefix, accumulator)
8606
+ on_singleton_class = own_singleton_class?(node.receiver)
8607
+ return unless on_singleton_class || node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
8608
+ return unless on_singleton_class || SINGLETON_DEFINING_CALLS.include?(node.name)
8609
+
8610
+ class_name = qualified_prefix.join("::")
8611
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
8612
+ arguments = arguments.first(1) if SINGLETON_DEFINING_CALLS.include?(node.name)
8613
+ arguments.each do |argument|
8614
+ name = Source::Literals.symbol_or_string(argument)
8615
+ if name
8616
+ accumulator.contest_slot(class_name, visibility_slot(name, :singleton))
8617
+ else
8618
+ accumulator.contest_class(class_name, :singleton)
8619
+ end
8620
+ end
8621
+ end
8622
+
8623
+ # `singleton_class` or `self.singleton_class`: the body's own singleton class.
8624
+ def own_singleton_class?(receiver)
8625
+ singleton_class_of_receiver?(receiver) && (receiver.receiver.nil? || receiver.receiver.is_a?(Prism::SelfNode))
8626
+ end
8627
+
7996
8628
  # Issue #1911 — an `attr_reader` / `attr_writer` / `attr_accessor` call written on the body's self records
7997
8629
  # each member it defines (`x`, `x=`) with `visibility`: the running default for a statement (`toggled`), the
7998
8630
  # wrapping call's for `private attr_accessor :x`, which Ruby applies to the `[:x, :x=]` the macro returns.
7999
8631
  # 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?
8632
+ # Issue #1989 — the names a `define_method(:x)` or an `alias_method :x, :y` call replaces. An `attr_*` call is
8633
+ # not among them: it writes its members' slots itself ({#record_attr_visibility}).
8634
+ def replaced_by_call(call_node)
8635
+ return define_method_superseded_name(call_node) if call_node.name == :define_method
8636
+
8637
+ Source::AliasNames.alias_method_call_names(call_node)&.first(1) || []
8638
+ end
8639
+
8640
+ # Issue #1989 — a `define_method` or an alias that replaces a method gives the name a visibility the walk
8641
+ # does not read (the running default for `define_method`, the old name's for an alias), so the slot is
8642
+ # contested rather than left asserting the replaced `def`'s. Issue #1984 — the slot is contested whether or
8643
+ # not this file recorded it, since another file may have; issue #2013 — on the instance side too, with the
8644
+ # mark that keeps the contest when that file's write folds later ({VisibilityTable#contest_replaced}).
8645
+ def contest_replaced_visibility(names, qualified_prefix, accumulator, side: :instance)
8646
+ return if names.empty? || qualified_prefix.empty?
8647
+
8648
+ class_name = qualified_prefix.join("::")
8649
+ names.each do |name|
8650
+ if side == :singleton
8651
+ accumulator.contest_slot(class_name, visibility_slot(name, side))
8652
+ else
8653
+ accumulator.contest_replaced(class_name, name)
8654
+ end
8655
+ end
8656
+ end
8657
+
8658
+ def record_attr_visibility(call_node, qualified_prefix, visibility, accumulator, toggled:, side: :instance)
8659
+ macro = attr_macro(call_node)
8660
+ return if macro.nil? || qualified_prefix.empty?
8003
8661
 
8004
8662
  class_name = qualified_prefix.join("::")
8005
8663
  each_attr_macro_name(call_node) do |base|
8006
8664
  members = []
8007
- members << base unless call_node.name == :attr_writer
8008
- members << :"#{base}=" unless call_node.name == :attr_reader
8665
+ members << base unless macro == :attr_writer
8666
+ members << :"#{base}=" unless macro == :attr_reader
8009
8667
  members.each do |member|
8010
- accumulator[class_name] ||= {}
8011
- accumulator[class_name][member] = visibility
8012
- accumulator.wrote(class_name, member, call_node, toggled: toggled)
8668
+ accumulator.write(class_name, member, side, visibility, call_node, toggled: toggled)
8013
8669
  end
8014
8670
  end
8015
8671
  end
8016
8672
 
8017
- # A receiverless `attr_reader` / `attr_writer` / `attr_accessor` call ({#record_attr_visibility}).
8673
+ # An `attr_reader` / `attr_writer` / `attr_accessor` call on the body's self ({#attr_macro},
8674
+ # {#record_attr_visibility}). Ruby 4.0.5 gives every spelling the running default visibility.
8018
8675
  def attr_visibility_call?(node)
8019
- node.is_a?(Prism::CallNode) && node.receiver.nil? && ATTR_MACROS.include?(node.name)
8676
+ !attr_macro(node).nil?
8020
8677
  end
8021
8678
 
8022
8679
  # Registers the alias name in the `discovered_methods` table so `undefined-method` diagnostics are not emitted for
@@ -8131,7 +8788,8 @@ module Rigor
8131
8788
  return if owner.nil?
8132
8789
 
8133
8790
  singleton_class_rewritten_names(node).each do |method_name|
8134
- names = [method_name]
8791
+ # `attr_writer :x` defines `x=` alone (#1936), so `x` keeps its own `def`.
8792
+ names = node.name == :attr_writer ? [] : [method_name]
8135
8793
  names << :"#{method_name}=" if %i[attr_writer attr_accessor].include?(node.name)
8136
8794
  names.each do |name|
8137
8795
  record_envelope(tables.envelopes, owner, [:singleton, name], Source::ParameterEnvelope::OPAQUE)
@@ -8234,48 +8892,76 @@ module Rigor
8234
8892
  #
8235
8893
  # ADR-119 WD3 — the alias is the slot's writer: the new name's slot is contested when the alias is possible
8236
8894
  # or the slot it copies is contested, and cleared otherwise.
8237
- def apply_alias_def_nodes(root, accumulator, tables = nil)
8238
- alias_map = collect_class_alias_map(root, [], AliasMap.new.track(tables&.certainty))
8239
- alias_map.each do |class_name, aliases|
8240
- class_defs = accumulator[class_name]
8241
- next unless class_defs
8242
-
8243
- aliases.each do |new_name, old_name|
8244
- def_node = class_defs[old_name]
8245
- next unless def_node.is_a?(Prism::DefNode)
8246
-
8247
- (accumulator[class_name] ||= {})[new_name] = def_node
8248
- next if tables.nil?
8249
-
8250
- certain = !alias_map.possible?(class_name, new_name) &&
8251
- !tables.contested_def_nodes&.include?([class_name, old_name])
8252
- follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], certain)
8253
- end
8895
+ #
8896
+ # Issue #2006 — in source order. An alias copies what its old name held AT the alias, read from the walk's
8897
+ # definer history ({MethodTables#wrote_definer}) and the copies earlier aliases made: a `def` of the old name
8898
+ # written after the alias (`alias old_x x; def x = 1`) is not what `old_x` runs, and nothing before the alias
8899
+ # copies nothing. The new name's slot then follows its last writer: a later `def` keeps the slot the walk
8900
+ # gave it, and a later `define_method` / `attr_*` leaves the copy contested, as {#supersede_def_nodes} leaves
8901
+ # a replaced `def`.
8902
+ def apply_alias_def_nodes(root, accumulator, tables)
8903
+ alias_map = collect_class_alias_map(root, [], AliasMap.new.track(tables.certainty))
8904
+ history = (tables.definer_history || {}).transform_values(&:dup)
8905
+ alias_map.copy_into(history).each { |key| settle_alias_slot(accumulator, tables, key, history[key]) }
8906
+ end
8907
+
8908
+ # Writes the new name's slot from its last writer `writes.last` ({#apply_alias_def_nodes}): an alias copy or a
8909
+ # later `def` writes its `def` with its certainty (for the `def`, what the walk wrote already). A last writer
8910
+ # with no `def` this file holds — a later `define_method` / `attr_*`, or an alias that copies nothing this file
8911
+ # defines (an inherited or core method, an attr reader, a name it cannot read) — contests the slot, which keeps
8912
+ # what it holds or else the latest copy, so no sibling file's `def` fills it.
8913
+ def settle_alias_slot(accumulator, tables, (class_name, new_name), writes)
8914
+ last = writes.last
8915
+ slot = (accumulator[class_name] ||= {})
8916
+ if last[1]
8917
+ slot[new_name] = last[1]
8918
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], last[2])
8919
+ else
8920
+ copy = writes.rfind { |write| write[1] }
8921
+ slot[new_name] ||= copy[1] if copy
8922
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], false)
8254
8923
  end
8255
8924
  end
8256
8925
 
8257
- # The `{class => {new name => old name}}` map {#collect_class_alias_map} fills, which also remembers, per
8258
- # entry, whether the alias that wrote it last is possible ({Certainty}).
8926
+ # The `{class => {new name => old name}}` map {#collect_class_alias_map} fills, which also lists every alias it
8927
+ # met as an event `[class, new name, old name, start offset, possible?]` ({Certainty}).
8259
8928
  class AliasMap < Hash
8929
+ attr_reader :events
8930
+
8260
8931
  def track(certainty)
8261
8932
  @certainty = certainty || Certainty::EMPTY
8262
- @possible = nil
8933
+ @events = []
8263
8934
  self
8264
8935
  end
8265
8936
 
8266
- # Records that `node` wrote the `[class_name, new_name]` entry.
8267
- def wrote(class_name, new_name, node)
8937
+ # Records that `node` wrote the `[class_name, new_name]` entry, aliasing `old_name`.
8938
+ def wrote(class_name, new_name, old_name, node)
8268
8939
  return if @certainty.nil?
8269
8940
 
8270
- if Certainty.possible?(@certainty, node)
8271
- (@possible ||= Set.new) << [class_name, new_name]
8272
- else
8273
- @possible&.delete([class_name, new_name])
8274
- end
8941
+ @events << [class_name, new_name, old_name, node.location.start_offset, Certainty.possible?(@certainty, node)]
8942
+ end
8943
+
8944
+ # Issue #2006 — adds every alias to `history` (`{[class, name] => [[offset, def node or nil, certain,
8945
+ # (:alias)], ...]}`, ascending), in source order. An alias whose old name held a `def` at the alias copies it,
8946
+ # certain when that write and the alias are. Any other alias — an old name with no `def` of this file before
8947
+ # it (inherited, core, an attr reader, a sibling file's), or one it cannot read — adds a write with no `def`,
8948
+ # which leaves the slot contested ({ScopeIndexer#settle_alias_slot}). An alias whose new name it cannot read
8949
+ # adds one to every name of the class written before it. Returns the `[class, name]` keys it wrote.
8950
+ def copy_into(history)
8951
+ @events.sort_by { |event| event[3] }.flat_map do |class_name, new_name, old_name, at, possible|
8952
+ source = old_name && history[[class_name, old_name]]&.rfind { |write| write.first < at }
8953
+ write = if source&.[](1) then [at, source[1], source[2] && !possible, :alias].freeze
8954
+ else [at, nil, false, :alias].freeze
8955
+ end
8956
+ keys = new_name ? [[class_name, new_name]] : names_before(history, class_name, at)
8957
+ keys.each { |key| (history[key] ||= []).push(write).sort_by!(&:first) }
8958
+ end.uniq
8275
8959
  end
8276
8960
 
8277
- def possible?(class_name, new_name)
8278
- !@possible.nil? && @possible.include?([class_name, new_name])
8961
+ private
8962
+
8963
+ def names_before(history, class_name, at)
8964
+ history.filter_map { |key, writes| key if key.first == class_name && writes.first.first < at }
8279
8965
  end
8280
8966
  end
8281
8967
 
@@ -8337,12 +9023,19 @@ module Rigor
8337
9023
  # `t` alias was the corpus case). Unlike AliasMethodNode a call's children can carry further
8338
9024
  # class bodies (`Class.new do … end`), so the walk continues below it either way.
8339
9025
  names = Source::AliasNames.alias_method_call_names(node)
8340
- if names && !rec_prefix.empty? && !(singleton_cref && leaf_owner.nil?)
9026
+ if (names || unread_alias_method?(node)) && !rec_prefix.empty? && !(singleton_cref && leaf_owner.nil?)
8341
9027
  record_alias_call_entry(accumulator, rec_prefix.join("::"), names, node)
8342
9028
  end
8343
9029
  false
8344
9030
  end
8345
9031
 
9032
+ # Issue #2006 — an `alias_method` call on no receiver or `self` whose names {Source::AliasNames} cannot read
9033
+ # (`alias_method n, :to_s`, `self.alias_method :x, :y`, a splat): it still replaces a method, which
9034
+ # {AliasMap#copy_into} contests.
9035
+ def unread_alias_method?(node)
9036
+ node.name == :alias_method && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
9037
+ end
9038
+
8346
9039
  # The rebinding-block call arm of {#collect_class_alias_map}: an anonymous meta-new call
8347
9040
  # (`Class.new { … }` no write names) files its block's aliases nowhere, and an eval-family
8348
9041
  # call binds them on the receiver — a nameable receiver re-anchors the owner while a
@@ -8433,20 +9126,32 @@ module Rigor
8433
9126
  end
8434
9127
 
8435
9128
  def record_alias_call_entry(accumulator, class_name, names, node)
9129
+ if names.nil?
9130
+ # Issue #2006 — an unread `alias_method` maps nothing, but still replaces a method ({AliasMap#copy_into}).
9131
+ new_name = Source::AliasNames.literal_name(node.arguments&.arguments&.first)
9132
+ accumulator.wrote(class_name, new_name, nil, node) if accumulator.is_a?(AliasMap)
9133
+ return
9134
+ end
9135
+
8436
9136
  (accumulator[class_name] ||= {})[names.first] = names.last
8437
- accumulator.wrote(class_name, names.first, node) if accumulator.is_a?(AliasMap)
9137
+ accumulator.wrote(class_name, names.first, names.last, node) if accumulator.is_a?(AliasMap)
8438
9138
  end
8439
9139
 
8440
9140
  def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
8441
9141
  return if qualified_prefix.empty?
8442
9142
 
8443
9143
  names = Source::AliasNames.keyword_names(alias_node)
8444
- return if names.nil?
8445
-
8446
9144
  class_name = qualified_prefix.join("::")
9145
+ if names.nil?
9146
+ # Issue #2006 — an `alias` whose names are not plain symbols still replaces a method ({AliasMap#copy_into}).
9147
+ new_name = Source::AliasNames.symbol_name(alias_node.new_name)
9148
+ accumulator.wrote(class_name, new_name, nil, alias_node) if accumulator.is_a?(AliasMap)
9149
+ return
9150
+ end
9151
+
8447
9152
  new_name, old_name = names
8448
9153
  (accumulator[class_name] ||= {})[new_name] = old_name
8449
- accumulator.wrote(class_name, new_name, alias_node) if accumulator.is_a?(AliasMap)
9154
+ accumulator.wrote(class_name, new_name, old_name, alias_node) if accumulator.is_a?(AliasMap)
8450
9155
  end
8451
9156
 
8452
9157
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
@@ -8474,8 +9179,9 @@ module Rigor
8474
9179
  return if qualified_prefix.empty?
8475
9180
 
8476
9181
  kind = in_singleton_class ? :singleton : :instance
8477
- reader = call_node.name != :attr_writer
8478
- writer = call_node.name != :attr_reader
9182
+ macro = attr_macro(call_node)
9183
+ reader = macro != :attr_writer
9184
+ writer = macro != :attr_reader
8479
9185
  class_name = qualified_prefix.join("::")
8480
9186
  each_attr_macro_name(call_node) do |base|
8481
9187
  record_method(accumulator, class_name, base, kind) if reader
@@ -8483,18 +9189,34 @@ module Rigor
8483
9189
  end
8484
9190
  end
8485
9191
 
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.
9192
+ # Yields each name an `attr_*` call declares ({#attr_macro}): a Symbol or String literal argument. A
9193
+ # non-literal one (`attr_writer(*names)`, `send(:attr_writer, name)`) names nothing Rigor can read.
8488
9194
  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?
9195
+ return if attr_macro(call_node).nil? || call_node.arguments.nil?
8491
9196
 
8492
- call_node.arguments.arguments.each do |arg|
9197
+ arguments = call_node.arguments.arguments
9198
+ arguments = arguments.drop(1) if SURFACE_SEND_CALLS.include?(call_node.name)
9199
+ arguments.each do |arg|
8493
9200
  base = literal_method_name(arg)
8494
9201
  yield base unless base.nil?
8495
9202
  end
8496
9203
  end
8497
9204
 
9205
+ # Issue #2097 — the {ATTR_MACROS} entry a call declares members with on the lexical class, or nil. The
9206
+ # call is on the body's self, implicitly or as `self.` (`self.attr_accessor :x`), and names the macro
9207
+ # directly or as the literal first argument of `send` / `public_send` / `__send__`
9208
+ # (`send(:attr_accessor, :x)`); the same spellings `SigGen::Generator#attr_writer_names` reads. Any
9209
+ # other receiver defines on another object.
9210
+ def attr_macro(call_node)
9211
+ return nil unless call_node.is_a?(Prism::CallNode)
9212
+ return nil unless call_node.receiver.nil? || call_node.receiver.is_a?(Prism::SelfNode)
9213
+ return call_node.name if ATTR_MACROS.include?(call_node.name)
9214
+ return nil unless SURFACE_SEND_CALLS.include?(call_node.name)
9215
+
9216
+ macro = literal_method_name(call_node.arguments&.arguments&.first)
9217
+ macro if ATTR_MACROS.include?(macro)
9218
+ end
9219
+
8498
9220
  # Issue #736 — the module-level accessor macros, which introduce methods on BOTH sides of the class.
8499
9221
  # ActiveSupport defines them on `Module`, so every class and module body can use them, and Rails codebases
8500
9222
  # do: `mattr_accessor` / `cattr_accessor` (an alias of it) and `class_attribute` account for 92 false
@@ -8616,14 +9338,15 @@ module Rigor
8616
9338
  # {#discovered_project_index_incremental}'s changed-file branch) yields the live def nodes the signature
8617
9339
  # reads their parameter structure from. Issue #1120 — and each file's own refinement table, which the merged
8618
9340
  # def-index cannot attribute back to a file, so the session can diff it against the file's seed bundle.
8619
- # Issue #1459 — and each file's {#defined_method_names}, diffed the same way.
8620
- # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements:, defined_method_names: }`.
9341
+ # Issue #1459 — and each file's {#defined_method_names}, diffed the same way. Issue #2134 — and each file's
9342
+ # {#visibility_entries}. Issue #2158 — and each file's `global_write_census`.
9343
+ # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements:, defined_method_names:,
9344
+ # visibility_entries:, global_write_census: }`.
8621
9345
  def scan_summary_for_paths(paths, buffer: nil)
8622
9346
  acc = new_def_index_accumulator
8623
9347
  code_fingerprints = {}
8624
9348
  declaration_signatures = {}
8625
- refinements = {}
8626
- defined_method_names = {}
9349
+ per_file = { refinements: {}, defined_method_names: {}, visibility_entries: {}, global_write_census: {} }
8627
9350
  paths.each do |path|
8628
9351
  physical = buffer ? buffer.resolve(path) : path
8629
9352
  source = File.read(physical)
@@ -8632,16 +9355,21 @@ module Rigor
8632
9355
  fold_file_index(acc, file_index)
8633
9356
  code_fingerprints[path] = code_fingerprint(source, parsed.comments)
8634
9357
  declaration_signatures[path] = declaration_signature(file_index)
8635
- refinements[path] = file_index[:refinements] if file_index[:refinements]
8636
- defined_method_names[path] = defined_method_names(file_index)
9358
+ file_summary(file_index).each { |key, table| per_file[key][path] = table unless table.nil? }
8637
9359
  rescue DeclarationWalk::ContractError
8638
9360
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
8639
9361
  rescue StandardError
8640
9362
  next
8641
9363
  end
8642
9364
  { def_index: finalize_def_index(acc), code_fingerprints: code_fingerprints,
8643
- declaration_signatures: declaration_signatures, refinements: refinements,
8644
- defined_method_names: defined_method_names }
9365
+ declaration_signatures: declaration_signatures, **per_file }
9366
+ end
9367
+
9368
+ # One file's tables {#scan_summary_for_paths} hands the session to diff against the file's seed bundle (nil for
9369
+ # a table the file does not have).
9370
+ def file_summary(file_index)
9371
+ { refinements: file_index[:refinements], defined_method_names: defined_method_names(file_index),
9372
+ visibility_entries: visibility_entries(file_index), global_write_census: file_index[:global_write_census] }
8645
9373
  end
8646
9374
 
8647
9375
  # Issue #1459 — every method name one file's index defines, on any class or module and either side: the keys of
@@ -8655,9 +9383,39 @@ module Rigor
8655
9383
  %i[methods def_nodes singleton_def_nodes].each do |key|
8656
9384
  file_index[key]&.each_value { |table| names.merge(table.keys) }
8657
9385
  end
9386
+ # Issue #2184 — a named deferred-range row with no side (a `def` in a block, a `define_singleton_method`) is a
9387
+ # definition no table above holds.
9388
+ file_index[:deferred_ranges]&.each_value { |rows| rows.each { |row| names << row[2] if row[2] && row[3].nil? } }
8658
9389
  names.freeze
8659
9390
  end
8660
9391
 
9392
+ # Issue #2134 — one file's contributions to the visibility table and its contested sibling, as a Set of
9393
+ # `[class, slot, visibility]` entries and `[:contested, class, slot]` contests, where a slot is a method name or
9394
+ # `[:singleton, name]`. Takes a live file index or a seed bundle, which hold the same keys.
9395
+ #
9396
+ # Issue #2161 — and a `[:module_function, owner, name]` entry for each of the file's deferred-range rows that
9397
+ # `DefinerResolution.module_function_row?` counts (a named def row whose kind is not `:instance`): a module
9398
+ # function's singleton copy is public whatever its instance slot says, so the row is a visibility fact the
9399
+ # table itself does not hold.
9400
+ def visibility_entries(file_index)
9401
+ entries = Set.new
9402
+ file_index[:method_visibilities]&.each do |class_name, table|
9403
+ table.each { |slot, visibility| entries << [class_name, slot, visibility] }
9404
+ end
9405
+ contested = (file_index[:siblings] || {})[:contested_discovered_method_visibilities]
9406
+ contested&.each { |(class_name, slot)| entries << [:contested, class_name, slot] }
9407
+ add_module_function_entries(file_index[:deferred_ranges], entries)
9408
+ entries.freeze
9409
+ end
9410
+
9411
+ def add_module_function_entries(deferred_ranges, entries)
9412
+ deferred_ranges&.each_value do |rows|
9413
+ rows.each do |(_start, _finish, name, kind, owner)|
9414
+ entries << [:module_function, owner, name] if name && owner && kind != :instance
9415
+ end
9416
+ end
9417
+ end
9418
+
8661
9419
  # ADR-89 WD1 — a per-file digest of every cross-file DECLARATION surface an ancestry / file-level
8662
9420
  # dependent consumes, EXCLUDING method bodies (which only symbol dependents consume, via the ADR-46
8663
9421
  # symbol edges the change-detection already fingerprints per method). A body edit that touches no
@@ -8875,14 +9633,21 @@ module Rigor
8875
9633
  # Error degradation is identical to the two independent loops it replaces: a read / parse failure (the
8876
9634
  # rescue's real target) contributes nothing to either table. The subset-scoped callers
8877
9635
  # ({IncrementalSession}, `coverage --protection`) keep calling the individual methods unchanged.
8878
- def discovered_project_index_for_paths(paths, buffer: nil)
9636
+ #
9637
+ # @param uncertain — issue #2021: the paths whose every method contribution is `possible`
9638
+ # ({Certainty.every_contribution}).
9639
+ def discovered_project_index_for_paths(paths, buffer: nil, uncertain: EMPTY_PATH_SET)
8879
9640
  classes = {}
8880
9641
  acc = new_def_index_accumulator
8881
9642
  paths.each do |path|
8882
9643
  physical = buffer ? buffer.resolve(path) : path
8883
9644
  root = Prism.parse(File.read(physical), filepath: path).value
8884
9645
  collect_class_decls(root, [], classes)
8885
- accumulate_project_index(acc, path, root)
9646
+ if uncertain.include?(path)
9647
+ fold_file_index(acc, without_written_slots(build_file_index(path, root, uncertain: true), acc))
9648
+ else
9649
+ accumulate_project_index(acc, path, root)
9650
+ end
8886
9651
  rescue DeclarationWalk::ContractError
8887
9652
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
8888
9653
  rescue StandardError
@@ -8930,14 +9695,19 @@ module Rigor
8930
9695
  # @param paths — project file paths, in canonical order.
8931
9696
  # @param seed_bundles — the prior run's per-file bundles, keyed by logical path.
8932
9697
  # @return `{ classes:, def_index:, bundles: }`.
8933
- def discovered_project_index_incremental(paths, seed_bundles:, buffer: nil)
9698
+ # @param uncertain — issue #2021: as {#discovered_project_index_for_paths}. Such a file is always re-walked and
9699
+ # keeps no bundle, since a bundle does not record that its contributions were all possible.
9700
+ def discovered_project_index_incremental(paths, seed_bundles:, buffer: nil, uncertain: EMPTY_PATH_SET) # rubocop:disable Metrics/AbcSize
8934
9701
  classes = {}
8935
9702
  acc = new_def_index_accumulator
8936
9703
  bundles = {}
8937
9704
  paths.each do |path|
8938
9705
  physical = buffer ? buffer.resolve(path) : path
8939
9706
  digest = Cache::FileDigest.hexdigest(physical)
8940
- cached = seed_bundles[path]
9707
+ walked = uncertain.include?(path)
9708
+ # A walked file neither reuses nor stores a bundle. Both are guards no spec separates: no bundle is ever
9709
+ # stored for a walked file, so the reuse guard matters only if a path changes role within one session.
9710
+ cached = walked ? nil : seed_bundles[path]
8941
9711
  if cached && cached[:digest] == digest
8942
9712
  bundles[path] = cached
8943
9713
  classes.merge!(cached[:classes])
@@ -8946,13 +9716,13 @@ module Rigor
8946
9716
  source = File.read(physical)
8947
9717
  parsed = Prism.parse(source, filepath: path)
8948
9718
  root = parsed.value
8949
- file_index = build_file_index(path, root)
9719
+ file_index = build_file_index(path, root, uncertain: walked)
8950
9720
  file_classes = {}
8951
9721
  collect_class_decls(root, [], file_classes)
8952
- bundles[path] = build_seed_bundle(file_index, file_classes, digest,
8953
- code_fingerprint(source, parsed.comments))
9722
+ fingerprint = code_fingerprint(source, parsed.comments)
9723
+ bundles[path] = build_seed_bundle(file_index, file_classes, digest, fingerprint) unless walked
8954
9724
  classes.merge!(file_classes)
8955
- fold_file_index(acc, file_index)
9725
+ fold_file_index(acc, walked ? without_written_slots(file_index, acc) : file_index)
8956
9726
  end
8957
9727
  rescue DeclarationWalk::ContractError
8958
9728
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
@@ -8966,12 +9736,43 @@ module Rigor
8966
9736
  # Builds ONE file's isolated def-index contribution (live `Prism::DefNode`s) by folding it into a fresh
8967
9737
  # accumulator — recon Q2(a)'s "constructible in isolation" property. Shared by the cold-baseline bundle
8968
9738
  # build and the incremental changed-file re-walk.
8969
- def build_file_index(path, root)
9739
+ def build_file_index(path, root, uncertain: false)
8970
9740
  file_acc = new_def_index_accumulator
8971
- accumulate_project_index(file_acc, path, root)
9741
+ accumulate_project_index(file_acc, path, root, uncertain: uncertain)
8972
9742
  file_acc
8973
9743
  end
8974
9744
 
9745
+ # Issue #2021 — the single-valued slot tables an `uncertain` file writes, each with its contest sibling.
9746
+ UNCERTAIN_SLOT_TABLES = {
9747
+ def_nodes: :contested_discovered_def_nodes,
9748
+ singleton_def_nodes: :contested_discovered_singleton_def_nodes,
9749
+ method_visibilities: :contested_discovered_method_visibilities,
9750
+ parameter_envelopes: :contested_discovered_parameter_envelopes
9751
+ }.freeze
9752
+
9753
+ # Issue #2021 — an `uncertain` file's isolated index without the slots `acc` already holds. The walked files fold
9754
+ # after every other discovered file, so `acc` holds what the configured (certain) files wrote: a walked reopening
9755
+ # of such a class adds the names it defines (existence) and leaves each written def node, visibility and
9756
+ # envelope, and its contest, as the certain writer left it. Without this, the walked file's possible write was
9757
+ # the slot's last and contested it, and `rigor check lib/user.rb plug/x.rb` withheld what `rigor check
9758
+ # lib/user.rb` reports. A slot no earlier file wrote is taken, contested.
9759
+ def without_written_slots(file_index, acc)
9760
+ siblings = (file_index[:siblings] || {}).dup
9761
+ tables = UNCERTAIN_SLOT_TABLES.to_h do |table, sibling|
9762
+ written = acc[table]
9763
+ kept = {}
9764
+ (file_index[table] || {}).each do |class_name, entries|
9765
+ sitting = written[class_name]
9766
+ entries = entries.reject { |key, _| sitting.key?(key) } if sitting
9767
+ kept[class_name] = entries unless entries.empty?
9768
+ end
9769
+ contests = siblings[sibling]
9770
+ siblings[sibling] = contests.select { |class_name, key| kept[class_name]&.key?(key) }.to_set if contests
9771
+ [table, kept]
9772
+ end
9773
+ file_index.merge(tables).merge(siblings: siblings)
9774
+ end
9775
+
8975
9776
  # ADR-85 WD2 — folds a single file's isolated def-index contribution into the cross-file accumulator,
8976
9777
  # applying EXACTLY the merge semantics {#accumulate_project_index} applies incrementally (def_nodes
8977
9778
  # later-wins, def_sources first-wins, includes / class_sources accumulate, everything else later-wins).
@@ -9031,7 +9832,7 @@ module Rigor
9031
9832
  when :contested_discovered_singleton_def_nodes
9032
9833
  fold_slot_contests(target.fetch(name), singleton_def_nodes, table)
9033
9834
  when :contested_discovered_method_visibilities
9034
- fold_slot_contests(target.fetch(name), method_visibilities, table)
9835
+ fold_visibility_contests(target.fetch(name), acc[:method_visibilities], method_visibilities, table)
9035
9836
  else union_sibling_value(target.fetch(name), table)
9036
9837
  end
9037
9838
  end
@@ -9104,6 +9905,35 @@ module Rigor
9104
9905
  result.merge(file_contested)
9105
9906
  end
9106
9907
 
9908
+ # The visibility table's {#fold_slot_contests}, where `recorded` is the table the file folds into as it stood.
9909
+ # Issue #1984 — a singleton slot (`[:singleton, name]`) does not follow its writer: file order is not load
9910
+ # order, so two files that give it different visibilities (`private_class_method :x` here,
9911
+ # `class << self; public :x; end` in a reopening) leave it contested, and a contest on it stays whichever
9912
+ # file folds later. The instance slots fold as before, except (issue #2013) one whose contest carries a
9913
+ # replacement's mark ({ScopeIndexer.replaced_visibility_mark}): a `define_method` or an alias in one file and a
9914
+ # `private def` in another leave the visibility to load order, so that contest stays too.
9915
+ def fold_visibility_contests(sitting, recorded, file_slots, file_contested)
9916
+ stale = sitting.select do |class_name, name|
9917
+ !name.is_a?(Array) && file_slots[class_name]&.key?(name) &&
9918
+ !sitting.include?([class_name, ScopeIndexer.replaced_visibility_mark(name)])
9919
+ end
9920
+ disagreeing = singleton_visibility_disagreements(recorded, file_slots)
9921
+ return sitting if stale.empty? && file_contested.empty? && disagreeing.empty?
9922
+
9923
+ (sitting - stale).merge(file_contested).merge(disagreeing)
9924
+ end
9925
+
9926
+ def singleton_visibility_disagreements(recorded, file_slots)
9927
+ file_slots.flat_map do |class_name, table|
9928
+ sitting = recorded[class_name]
9929
+ next [] if sitting.nil?
9930
+
9931
+ table.filter_map do |slot, visibility|
9932
+ [class_name, slot] if slot.is_a?(Array) && sitting.key?(slot) && sitting[slot] != visibility
9933
+ end
9934
+ end
9935
+ end
9936
+
9107
9937
  def union_paths(sitting, arriving)
9108
9938
  return sitting if arriving.empty?
9109
9939
  return arriving if sitting.empty?
@@ -9509,12 +10339,12 @@ module Rigor
9509
10339
  # Folds one file's class-keyed indexes into the cross-file accumulator. `method_visibilities` (ADR-35) is
9510
10340
  # collected here so the override-visibility-reduced rule can read an ancestor's visibility declared in a sibling
9511
10341
  # file.
9512
- def accumulate_project_index(acc, path, root)
10342
+ def accumulate_project_index(acc, path, root, uncertain: false)
9513
10343
  # One combined descent yields both the methods existence table and the def-node table; the latter is also
9514
10344
  # consumed by `record_class_sources`, so a def-dense file is walked once here instead of three times (methods +
9515
10345
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
9516
10346
  file_methods, file_def_nodes, file_envelopes, file_refinements, file_singleton_def_nodes, certainty =
9517
- walk_def_contributions(acc, path, root)
10347
+ walk_def_contributions(acc, path, root, uncertain: uncertain)
9518
10348
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
9519
10349
  fold_parameter_envelopes(acc, file_envelopes)
9520
10350
  fold_refinements(acc, file_refinements)
@@ -9541,8 +10371,8 @@ module Rigor
9541
10371
  # file's siblings folded into the accumulator BEFORE its members join it (ADR-119 WD3: the `possible` rule
9542
10372
  # reads the accumulator's member as it stood). Returns `[methods, def_nodes, envelopes, refinements,
9543
10373
  # singleton_def_nodes, certainty]`, the classification passed on for the visibility walk.
9544
- def walk_def_contributions(acc, path, root)
9545
- certainty = Certainty.possible_nodes(root)
10374
+ def walk_def_contributions(acc, path, root, uncertain: false)
10375
+ certainty = uncertain ? Certainty.every_contribution(root) : Certainty.possible_nodes(root)
9546
10376
  file_methods, file_def_nodes, file_envelopes, file_refinements, file_siblings =
9547
10377
  build_methods_and_def_nodes(root, path, certainty: certainty)
9548
10378
  file_singleton_def_nodes, file_singleton_contested = build_singleton_def_tables(root, certainty: certainty)
@@ -9588,7 +10418,15 @@ module Rigor
9588
10418
  census.writes.each do |name, descriptor|
9589
10419
  (acc[:constant_writes][name] ||= {})[path] = descriptor
9590
10420
  end
9591
- acc[:global_write_census].merge(census.write_census.census)
10421
+ acc[:global_write_census].merge(attribute_own_writes(census.write_census.census, path))
10422
+ end
10423
+
10424
+ # #1154 — tags each `[:own_write, class, ivar]` entry with the file that records it, so a reader of the merged
10425
+ # census can tell a second file writing the same ivar of the same class (`UnseenIvarWriters`).
10426
+ def attribute_own_writes(census, path)
10427
+ return census unless census.any? { |entry| entry.first == :own_write }
10428
+
10429
+ census.to_set { |entry| entry.first == :own_write ? [*entry, path].freeze : entry }
9592
10430
  end
9593
10431
 
9594
10432
  # Folds one file's Data + Struct member-layout tables into the cross-file accumulator (kept out of
@@ -9607,8 +10445,9 @@ module Rigor
9607
10445
  def merge_class_keyed_index_tables(acc, root, file_methods, certainty)
9608
10446
  file_visibilities, file_contested = build_method_visibility_tables(root, certainty: certainty)
9609
10447
  siblings = acc[:siblings]
9610
- siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
9611
- siblings[:contested_discovered_method_visibilities], file_visibilities, file_contested
10448
+ siblings[:contested_discovered_method_visibilities] = fold_visibility_contests(
10449
+ siblings[:contested_discovered_method_visibilities], acc[:method_visibilities], file_visibilities,
10450
+ file_contested
9612
10451
  )
9613
10452
  file_visibilities.each do |class_name, table|
9614
10453
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
@@ -9710,8 +10549,12 @@ module Rigor
9710
10549
  singleton_cref: false)
9711
10550
  return unless node.is_a?(Prism::Node)
9712
10551
 
10552
+ # #1154 — the class whose instance `def`s the ivar-writer census credits; none under `class << self`.
10553
+ ivar_owner = census_ivar_owner(qualified_prefix, self_owner, singleton_cref)
9713
10554
  case node
9714
10555
  when Prism::SingletonClassNode
10556
+ # #1154 — a `class << self` inside an instance `def` opens the instance's singleton class.
10557
+ tables.write_census.visit(node, top_level: qualified_prefix.empty?, owner: ivar_owner)
9715
10558
  return walk_census_singleton_class(node, qualified_prefix, tables, self_owner,
9716
10559
  meta_owner, singleton_cref)
9717
10560
  when Prism::ClassNode, Prism::ModuleNode
@@ -9719,7 +10562,7 @@ module Rigor
9719
10562
  singleton_cref)
9720
10563
  else
9721
10564
  census_constant_write(node, qualified_prefix, tables, self_owner, singleton_cref: singleton_cref)
9722
- tables.write_census.visit(node, top_level: qualified_prefix.empty?)
10565
+ tables.write_census.visit(node, top_level: qualified_prefix.empty?, owner: ivar_owner)
9723
10566
  end
9724
10567
 
9725
10568
  rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner, self_owner)
@@ -9751,6 +10594,16 @@ module Rigor
9751
10594
  end
9752
10595
  end
9753
10596
 
10597
+ # #1154 — the class whose instance `def`s the ivar-writer census credits: the class a `Struct.new` /
10598
+ # `Data.define` / `Class.new` constant or a `K.class_eval` block opens (`self_owner`), else the lexical class;
10599
+ # none under `class << self` or an opaque block. The prefix Array is joined only when an entry is recorded.
10600
+ def census_ivar_owner(qualified_prefix, self_owner, singleton_cref)
10601
+ return self_owner if self_owner.is_a?(String)
10602
+ return nil if self_owner || singleton_cref || qualified_prefix.empty?
10603
+
10604
+ qualified_prefix
10605
+ end
10606
+
9754
10607
  # Under an unnameable cref a bare/`self::` header pushes `#<singleton>::Name` — still
9755
10608
  # unnameable — so the body keeps the OPAQUE self and the cref flag; nameable
9756
10609
  # headers re-anchor at a real cref.
@@ -10838,6 +11691,7 @@ module Rigor
10838
11691
  table[node] = parent_scope
10839
11692
  parent_scope
10840
11693
  end
11694
+ evaluate_unentered_flow(node, table, current_scope)
10841
11695
 
10842
11696
  case node
10843
11697
  when Prism::IfNode
@@ -10853,7 +11707,7 @@ module Rigor
10853
11707
  child_scope = entered ? current_scope : closure_scope(node, current_scope)
10854
11708
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
10855
11709
  when Prism::CallNode
10856
- propagate_call(node, table, current_scope)
11710
+ propagate_call(node, table, current_scope, recorded: recorded)
10857
11711
  when Prism::RescueNode, Prism::EnsureNode
10858
11712
  # Issue #1360 — a clause the evaluator did not enter (a `begin` in a value position) reads `$!`, `$@` and
10859
11713
  # `$?` unbound, not the enclosing clause's; one it entered keeps what it recorded.
@@ -10867,6 +11721,84 @@ module Rigor
10867
11721
  end
10868
11722
  end
10869
11723
 
11724
+ # Issues #2196 and #2056 — the constructs whose parts run under a narrowing their own condition or left operand
11725
+ # makes, keyed to the part {StatementEvaluator} records first when it enters one. Issue #2214 — a `&&=` on a
11726
+ # variable runs its value under the variable's truthy narrowing (`[c &&= c + 1]`).
11727
+ UNENTERED_FLOW_ENTRY = {
11728
+ Prism::AndNode => :left, Prism::OrNode => :left,
11729
+ Prism::IfNode => :predicate, Prism::UnlessNode => :predicate,
11730
+ Prism::WhileNode => :predicate, Prism::UntilNode => :predicate,
11731
+ Prism::LocalVariableAndWriteNode => :value, Prism::InstanceVariableAndWriteNode => :value,
11732
+ Prism::ClassVariableAndWriteNode => :value, Prism::GlobalVariableAndWriteNode => :value,
11733
+ Prism::CaseNode => :predicate
11734
+ }.freeze
11735
+ private_constant :UNENTERED_FLOW_ENTRY
11736
+
11737
+ UNENTERED_EVALUATOR_KEY = :rigor_scope_index_unentered_evaluator
11738
+ private_constant :UNENTERED_EVALUATOR_KEY
11739
+
11740
+ # Issues #2196 and #2056 — an `&&` / `||` / `and` / `or`, an `if` / `unless` / ternary or a `while` / `until`
11741
+ # in a value position (an array element, a hash value, a call argument, an interpolation, a multi-value
11742
+ # `return`) is typed by `ExpressionTyper` and never entered, so no part of it was recorded and this walk handed
11743
+ # every part one scope. A walk that narrows by the condition's edges cannot follow a write inside the narrowed
11744
+ # part (`[c && (c = c.size; c.even?)]`, `[x || (set!; @x.upcase)]`) nor one inside a block in an earlier
11745
+ # conjunct (`[x.nil? && [1].each { x = "d" } && x.upcase]`). Such a construct is evaluated here instead, from
11746
+ # the scope this walk reached it with, under the index's own recorder, so each part is recorded with the scope
11747
+ # the statement form records it with. What it does not visit (a dead arm) is walked as before.
11748
+ def evaluate_unentered_flow(node, table, current_scope)
11749
+ entry = UNENTERED_FLOW_ENTRY[node.class]
11750
+ return if entry.nil?
11751
+
11752
+ # An entered construct is already recorded; skipping it is a performance guard, not a behaviour. A `case`
11753
+ # without a subject records its first `when` test first.
11754
+ part = node.public_send(entry) || (node.conditions.first&.conditions&.first if node.is_a?(Prism::CaseNode))
11755
+ return if part.nil? || table.key?(part)
11756
+
11757
+ evaluate = Thread.current[UNENTERED_EVALUATOR_KEY]
11758
+ evaluate&.call(node, current_scope, code_body_nodes(node, current_scope.source_path))
11759
+ end
11760
+
11761
+ # The evaluation of an unentered construct starts with no class frame and no lexical nesting, which only a code
11762
+ # body observes: a `def`, a `class` / `module` / `class << …` body, and the block of a class-creating call
11763
+ # (`Class.new { … }`, `Struct.new`, `Data.define`, `Module.new`) or of a `refine`, whose `def`s and constants
11764
+ # resolve against the class the walk tracks and the evaluation does not. Every node in one is left out of the
11765
+ # recording, so the walk reaches it unrecorded and handles it as it did before #2196 ({#unentered_block_entry},
11766
+ # {#record_class_body_defs}).
11767
+ # Answers nil, allocating nothing, for a construct holding no code body: the common case.
11768
+ def code_body_nodes(node, source_path, acc = nil, inside: false)
11769
+ inside ||= CODE_BODY_NODES.include?(node.class)
11770
+ (acc ||= Set.new.compare_by_identity) << node if inside
11771
+ if !inside && node.is_a?(Prism::CallNode) && class_body_block_call?(node, source_path)
11772
+ acc = code_body_nodes(node.block, source_path, acc, inside: true)
11773
+ node.rigor_each_child do |child|
11774
+ acc = code_body_nodes(child, source_path, acc) unless child.equal?(node.block)
11775
+ end
11776
+ return acc
11777
+ end
11778
+ node.rigor_each_child { |child| acc = code_body_nodes(child, source_path, acc, inside: inside) }
11779
+ acc
11780
+ end
11781
+
11782
+ CODE_BODY_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
11783
+ private_constant :CODE_BODY_NODES
11784
+
11785
+ def class_body_block_call?(node, source_path)
11786
+ node.block.is_a?(Prism::BlockNode) &&
11787
+ (!AnonymousMetaClass.name_for(node, source_path).nil? || !refine_target(node).nil?)
11788
+ end
11789
+
11790
+ # The evaluation {#evaluate_unentered_flow} runs for the walk of one {#index}, installed for that walk only.
11791
+ # Index walks nest (a rule may re-index a file while another file's walk is suspended), so the outer one is
11792
+ # restored on exit. A `propagate` called directly while an outer index walk is running would record into the
11793
+ # OUTER index's table through it; nothing outside {#index} calls `propagate`.
11794
+ def with_unentered_evaluator(evaluate)
11795
+ outer = Thread.current[UNENTERED_EVALUATOR_KEY]
11796
+ Thread.current[UNENTERED_EVALUATOR_KEY] = evaluate
11797
+ yield
11798
+ ensure
11799
+ Thread.current[UNENTERED_EVALUATOR_KEY] = outer
11800
+ end
11801
+
10870
11802
  SELF_OPENING_BODIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
10871
11803
  private_constant :SELF_OPENING_BODIES
10872
11804
 
@@ -10901,11 +11833,11 @@ module Rigor
10901
11833
  # argument one scope and threads none, so arguments that write keep the call's: an earlier one may rebind the
10902
11834
  # receiver a later one reads (`return y&.concat((y = nil).to_s, y.upcase)`, where the evaluator threads no
10903
11835
  # operand).
10904
- def propagate_call(node, table, current_scope)
11836
+ def propagate_call(node, table, current_scope, recorded: false)
10905
11837
  block = node.block
10906
11838
  operands =
10907
11839
  OperandEffects.any?(node.arguments) ? current_scope : Narrowing.safe_navigation_scope(node, current_scope)
10908
- entry = unentered_block_entry(node, block, table, current_scope)
11840
+ entry = unentered_block_entry(node, block, table, current_scope, call_recorded: recorded)
10909
11841
  if entry.equal?(current_scope) && operands.equal?(current_scope)
10910
11842
  node.rigor_each_child { |child| propagate(child, table, current_scope) }
10911
11843
  return
@@ -10929,7 +11861,7 @@ module Rigor
10929
11861
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
10930
11862
  end
10931
11863
 
10932
- def unentered_block_entry(node, block, table, current_scope)
11864
+ def unentered_block_entry(node, block, table, current_scope, call_recorded: false)
10933
11865
  return current_scope unless block.is_a?(Prism::BlockNode) && !table.key?(block)
10934
11866
 
10935
11867
  narrowing = unentered_block_narrowing(node, current_scope)
@@ -10942,6 +11874,12 @@ module Rigor
10942
11874
  else
10943
11875
  LastLine.block_entry(FreshFrameBlocks.closure_entry(current_scope, block, node), block, node)
10944
11876
  end
11877
+ # Issue #2196 — a class-body block whose call the evaluator recorded but did not enter (one inside a construct
11878
+ # {#evaluate_unentered_flow} evaluated, or a later operand) starts from the scope the call was evaluated in,
11879
+ # which may narrow a captured local the body writes; this walk runs the body's statements from one scope, so
11880
+ # such a local reads `Dynamic[top]` in it: `[c && Class.new { c = c.size; c.even? }]`. A block whose call was
11881
+ # not recorded keeps the enclosing scope as before.
11882
+ entry = widen_condition_writes(block, entry) if call_recorded && class_body_block_call?(node, entry.source_path)
10945
11883
  anonymous = AnonymousMetaClass.name_for(node, current_scope.source_path)
10946
11884
  return unentered_meta_class_body_entry(block, entry, anonymous, table) if anonymous
10947
11885
 
@@ -11073,20 +12011,28 @@ module Rigor
11073
12011
  scope.local(name).nil? ? scope : scope.with_local(name, Type::Combinator.untyped)
11074
12012
  end
11075
12013
 
12014
+ # Issue #1924 — an `if` / `elsif` / `unless` / ternary the evaluator did not enter (`[if (x ||= "d").empty?
12015
+ # then 0 else x.upcase end]`) applies no write its condition makes, so its edges are taken from a scope where
12016
+ # every bound local the condition writes reads `Dynamic[top]` ({#widen_condition_writes}), as
12017
+ # {#propagate_subjectless_case} takes a `when` condition's. The condition itself reads the scope before it.
11076
12018
  def propagate_if_branches(node, table, current_scope)
11077
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
12019
+ truthy_scope, falsey_scope = conditional_edges(node.predicate, current_scope)
11078
12020
  propagate(node.predicate, table, current_scope) if node.predicate
11079
12021
  propagate(node.statements, table, truthy_scope) if node.statements
11080
12022
  propagate(node.subsequent, table, falsey_scope) if node.subsequent
11081
12023
  end
11082
12024
 
11083
12025
  def propagate_unless_branches(node, table, current_scope)
11084
- truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, current_scope)
12026
+ truthy_scope, falsey_scope = conditional_edges(node.predicate, current_scope)
11085
12027
  propagate(node.predicate, table, current_scope) if node.predicate
11086
12028
  propagate(node.statements, table, falsey_scope) if node.statements
11087
12029
  propagate(node.else_clause, table, truthy_scope) if node.else_clause
11088
12030
  end
11089
12031
 
12032
+ def conditional_edges(predicate, scope)
12033
+ Narrowing.predicate_scopes(predicate, widen_condition_writes(predicate, scope))
12034
+ end
12035
+
11090
12036
  # Issue #1495 — a `case` without a subject the evaluator did not enter (`[case; when x.nil? then 0; else x.size
11091
12037
  # end]`) narrows by its conditions' edges, as {StatementEvaluator#subjectless_when_scopes} does: each condition
11092
12038
  # under the falsey edges of the conditions before it, each clause under the join of its conditions' truthy edges,
@@ -11120,7 +12066,8 @@ module Rigor
11120
12066
  # A test that calls a method may rebind an instance-variable or global subject before the body reads it
11121
12067
  # (`when String then 1 when reset! then @x.upcase`, where `reset!` writes `@x`), and the walk cannot tell. From
11122
12068
  # the first such test on, the subject reads `Dynamic[top]` on both edges ({#forget_called_subject}). The
11123
- # evaluator keeps the narrowing there (a separate defect); the walk declines it rather than add a report.
12069
+ # evaluator forgets a global subject there too (issue #2211). A `case` / `when` the evaluator did not enter is
12070
+ # evaluated ({#evaluate_unentered_flow}); this walk handles only a clause that evaluation does not visit.
11124
12071
  def propagate_subject_case(node, table, current_scope)
11125
12072
  subject = node.predicate
11126
12073
  propagate(subject, table, current_scope)