rigortype 0.4.5 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -43,6 +43,8 @@ require_relative "scope_indexer/layered_def_nestings"
43
43
  require_relative "scope_indexer/mixin_accumulator"
44
44
  require_relative "scope_indexer/certainty"
45
45
  require_relative "scope_indexer/member_layouts_collector"
46
+ require_relative "scope_indexer/declared_ivar_types"
47
+ require_relative "scope_indexer/declared_param_writes"
46
48
 
47
49
  module Rigor
48
50
  module Inference
@@ -112,7 +114,17 @@ module Rigor
112
114
  # nothing-to-re-anchor walk allocates none.
113
115
  EMPTY_RENAMES = {}.freeze
114
116
 
115
- 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)
116
128
  # Slice A-declarations. Build the declaration overrides first so every scope handed to the StatementEvaluator
117
129
  # already carries the table; structural sharing through `Scope#with_local` / `#with_fact` / `#with_self_type`
118
130
  # propagates it across every derived scope.
@@ -134,7 +146,9 @@ module Rigor
134
146
 
135
147
  # Slice 7 phase 2. Pre-pass over every class/module body to collect the per-class ivar accumulator. Seeded after
136
148
  # declared_types so the rvalue typer in the pre-pass can see declaration overrides.
137
- 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
138
152
  seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_ivars: class_ivars))
139
153
 
140
154
  # Slice 7 phase 6. Same pre-pass shape for cvars (per class) and globals (program-wide). Globals are also
@@ -197,15 +211,27 @@ module Rigor
197
211
  # share.
198
212
  seeded_scope = seed_struct_fold_safe(seeded_scope, root).with_match_frame(root)
199
213
 
200
- on_enter = ->(node, scope) { table[node] = scope }
201
- StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
202
- converged_loop_recording: converged_loop_recording,
203
- widen_clauses: widen_clauses).evaluate(root)
214
+ evaluate = recording_evaluator(table, converged_loop_recording, widen_clauses)
215
+ evaluate.call(root, seeded_scope)
204
216
 
205
- propagate(root, table, seeded_scope)
217
+ with_unentered_evaluator(evaluate) { propagate(root, table, seeded_scope) }
206
218
  table
207
219
  end
208
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
+
209
235
  # Runs the combined methods/def-nodes descent (one walk of the file), seeds the discovered-methods existence table
210
236
  # onto `seeded_scope` (merged UNDER the cross-file pre-pass seed `default_scope` carries), and returns `[scope,
211
237
  # file_def_nodes, file_envelopes]` so the caller can thread the def-node and issue #992 envelope tables into
@@ -498,7 +524,7 @@ module Rigor
498
524
  # (singleton vs instance). The rvalue is typed with NO local bindings — the pre-pass lacks statement-level
499
525
  # threading — so `@x = 1` records `Constant[1]` but `@x = some_local + 1` records `Dynamic[Top]` (since
500
526
  # `some_local` is unbound at pre-pass time). Multiple writes to the same ivar union via `Type::Combinator.union`.
501
- def build_class_ivar_index(root, default_scope)
527
+ def build_class_ivar_index(root, default_scope, declared_ivar_types: true)
502
528
  accumulator = {}
503
529
  mutated_ivars = {}
504
530
  read_before_write = {}
@@ -517,6 +543,8 @@ module Rigor
517
543
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
518
544
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
519
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
520
548
  accumulator.transform_values(&:freeze).freeze
521
549
  end
522
550
 
@@ -1024,8 +1052,11 @@ module Rigor
1024
1052
  # nodes to skip; soundness is post-domination at the top statement level, so dropping the nil never hides a real
1025
1053
  # runtime-nil read.
1026
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
1027
1058
  gather_ivar_writes(def_node.body, body_scope, class_name, accumulator,
1028
- EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes)
1059
+ EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes, declared)
1029
1060
 
1030
1061
  # B2.3 — collect per-method evidence for the read-before- write nil contribution. The accumulator-level decision
1031
1062
  # ("is this ivar truly read-before-write across the class lifetime?") is finalised at
@@ -1341,14 +1372,14 @@ module Rigor
1341
1372
  private_constant :TAIL_VALUE_DEPTH_CAP
1342
1373
  private_constant :EMPTY_GUARDED_IVARS
1343
1374
 
1344
- def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
1345
- 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)
1346
1377
  return unless node.is_a?(Prism::Node)
1347
1378
 
1348
1379
  if node.is_a?(Prism::InstanceVariableWriteNode)
1349
1380
  unless dead_writes&.include?(node.object_id)
1350
1381
  record_ivar_write(node, scope, class_name, accumulator,
1351
- guarded: guarded_ivars.include?(node.name))
1382
+ guarded: guarded_ivars.include?(node.name), declared: declared[node])
1352
1383
  end
1353
1384
  else
1354
1385
  record_compound_ivar_write(node, scope, class_name, accumulator)
@@ -1371,12 +1402,12 @@ module Rigor
1371
1402
 
1372
1403
  if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
1373
1404
  walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
1374
- mutated_ivars, dead_writes)
1405
+ mutated_ivars, dead_writes, declared)
1375
1406
  return
1376
1407
  end
1377
1408
 
1378
1409
  node.rigor_each_child do |c|
1379
- 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)
1380
1411
  end
1381
1412
  end
1382
1413
 
@@ -1452,22 +1483,22 @@ module Rigor
1452
1483
  # init; end` would otherwise be marked too — but that pattern (write @x in the else of `if @x`) is a separate
1453
1484
  # idiom whose surrounding reads of `@x` would then surface a nil-receiver FP. The ELSE branch is left ungarded so
1454
1485
  # those reads continue to type as they did before this fix.
1455
- def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
1456
- 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)
1457
1488
  then_guards = then_body_guarded_ivars(node)
1458
1489
  then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
1459
1490
 
1460
1491
  gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars,
1461
- mutated_ivars, dead_writes)
1492
+ mutated_ivars, dead_writes, declared)
1462
1493
  if node.statements
1463
1494
  gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded,
1464
- mutated_ivars, dead_writes)
1495
+ mutated_ivars, dead_writes, declared)
1465
1496
  end
1466
1497
  branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
1467
1498
  return unless branch
1468
1499
 
1469
1500
  gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars,
1470
- mutated_ivars, dead_writes)
1501
+ mutated_ivars, dead_writes, declared)
1471
1502
  end
1472
1503
 
1473
1504
  # Returns the set of ivar names that, in the THEN body of this conditional, are statically known to be in a nil /
@@ -1944,8 +1975,8 @@ module Rigor
1944
1975
  assigns.include?(target)
1945
1976
  end
1946
1977
 
1947
- def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
1948
- 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)
1949
1980
 
1950
1981
  # `@x = nil unless @x` / `@y = false unless @y` — follow-up to the polarity-aware defensive-init guard fix
1951
1982
  # (ROADMAP § Future cycles — "Defensive ivar-init with nil / false rvalue"). When the rvalue is itself a falsey
@@ -3433,7 +3464,17 @@ module Rigor
3433
3464
  # walk worked out for it ({#refine_context}).
3434
3465
  MethodTables = Struct.new(:existence, :envelopes, :refinements, :certainty, :certain, :possible,
3435
3466
  :contested_envelopes, :contested_def_nodes, :root, :in_effect, :census,
3436
- :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
+
3437
3478
  # {ScopeIndexer.module_refine_target} against this tree's {InEffectRefinements}.
3438
3479
  def refine_target_of(node)
3439
3480
  return nil if ScopeIndexer.refine_target(node).nil?
@@ -4171,7 +4212,9 @@ module Rigor
4171
4212
  anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc.at(node),
4172
4213
  source_path)
4173
4214
  unless in_singleton_class
4174
- supersede_def_nodes(call_superseded_names(node), owner_prefix, def_nodes_acc, methods_acc.at(node))
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))
4175
4218
  end
4176
4219
  tables = methods_acc.at(node)
4177
4220
  record_singleton_prepend(node, qualified_prefix, tables,
@@ -4811,6 +4854,7 @@ module Rigor
4811
4854
  accumulator[class_name] ||= {}
4812
4855
  accumulator[class_name][def_node.name] = def_node
4813
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)
4814
4858
  record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator, tables)
4815
4859
  end
4816
4860
 
@@ -4870,6 +4914,16 @@ module Rigor
4870
4914
  end
4871
4915
  end
4872
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
+
4873
4927
  # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
4874
4928
  # Before the anonymous class had a name the body was walked with an empty prefix, so every such `def`
4875
4929
  # landed there; that is the same leniency this key already grants a `def` nested in any other DSL block
@@ -5035,7 +5089,7 @@ module Rigor
5035
5089
  # nested `def` or block inside its body is still a range the containment half needs.
5036
5090
  # `def_owner_prefix` is the eval-body override described on {#walk_methods_and_def_nodes}:
5037
5091
  # an explicit owner for def rows while declarations stay lexical (`[]` = ownerless).
5038
- 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
5039
5093
  mf_offsets, ranges, def_owner_prefix = nil, singleton_cref: false,
5040
5094
  defs_singleton: false)
5041
5095
  return unless node.is_a?(Prism::Node)
@@ -5071,11 +5125,34 @@ module Rigor
5071
5125
  singleton_cref: singleton_cref)
5072
5126
  end
5073
5127
 
5128
+ record_definer_name_row(node, ranges) if node.is_a?(Prism::CallNode)
5074
5129
  walk_deferred_children(node, qualified_prefix, in_singleton_class, inside_deferred,
5075
5130
  mf_offsets, ranges, def_owner_prefix, singleton_cref: singleton_cref,
5076
5131
  defs_singleton: defs_singleton)
5077
5132
  end
5078
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
+
5079
5156
  # The `def` arm of {#walk_deferred_ranges}: records the range row — singleton-side when the
5080
5157
  # def sits in a `class <<` body or an `instance_eval` block (`defs_singleton`) — then walks
5081
5158
  # the body as a deferred range.
@@ -5586,7 +5663,9 @@ module Rigor
5586
5663
  mf_offsets, ranges)
5587
5664
  start = def_node.location.start_offset
5588
5665
  if inside_deferred
5589
- 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]
5590
5669
  return
5591
5670
  end
5592
5671
 
@@ -7564,7 +7643,8 @@ module Rigor
7564
7643
  return record_module_function(node, current_class, accumulator, in_singleton) if node.name == :module_function
7565
7644
 
7566
7645
  taint_opaque_eval(node, current_class, accumulator)
7567
- 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)
7568
7648
 
7569
7649
  kind, _arguments, via_send = mixin_call_view(node)
7570
7650
  return if kind.nil?
@@ -7572,7 +7652,8 @@ module Rigor
7572
7652
  effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
7573
7653
  .select { |_owner, side| side == :extend || in_singleton }
7574
7654
  return if effects.empty?
7575
- 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)
7576
7657
 
7577
7658
  effects.each { |owner, side| accumulator.taint(owner, side) }
7578
7659
  end
@@ -7587,7 +7668,7 @@ module Rigor
7587
7668
  # but only where it is a direct statement (a top-level statement, or one of a direct declaration body or of a
7588
7669
  # block that runs there once): there the methods walk records those marks under the same owner. In a method
7589
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.
7590
- 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)
7591
7672
  receiver = node.receiver
7592
7673
  return false unless singleton_class_of_receiver?(receiver) && surface_rewriting_call?(node)
7593
7674
  return false if singleton_class_named_call?(node) && accumulator.direct?(node)
@@ -7595,10 +7676,33 @@ module Rigor
7595
7676
  owner = singleton_class_owner(node, receiver.receiver, qualified_prefix, current_class)
7596
7677
  return false if owner.nil?
7597
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
+
7598
7684
  accumulator.taint(owner, :extend)
7599
7685
  true
7600
7686
  end
7601
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
+
7602
7706
  # A bare, `self.` or constant receiver's `singleton_class` call.
7603
7707
  def singleton_class_of_receiver?(receiver)
7604
7708
  receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil?
@@ -7657,13 +7761,29 @@ module Rigor
7657
7761
 
7658
7762
  # Records the singleton-side edges of a receiverless `extend` (or, inside `class << self`, `include` /
7659
7763
  # `prepend`). False when it recorded nothing.
7660
- def singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
7661
- 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
7662
7767
  return false unless in_singleton ? MIXIN_CALL_NAMES.include?(kind) : kind == :extend
7663
7768
 
7664
7769
  !record_extend_targets(node, current_class, accumulator).empty?
7665
7770
  end
7666
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
+
7667
7787
  # The table stores search order, not call order: a later `extend` statement prepends its module
7668
7788
  # (`extend A; extend B` → singleton ancestors `[B, A]`) while the arguments of a single
7669
7789
  # `extend A, B` keep call order (`[A, B]`). Prepending each call's argument list preserves both,
@@ -7895,6 +8015,16 @@ module Rigor
7895
8015
  end
7896
8016
  end
7897
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
+
7898
8028
  # Contests the `[class_name, name]` slot, recorded or not: a visibility call changed it in a way the walk
7899
8029
  # cannot read (`private def self.x` reaching an instance `x`).
7900
8030
  def contest_slot(class_name, name)
@@ -8231,6 +8361,12 @@ module Rigor
8231
8361
  # them public (`K.public_class_method(*names)`, `K.singleton_class.class_eval { … }`).
8232
8362
  SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
8233
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
+
8234
8370
  # Issue #1984 — a class method visibility call written on a constant (`K.public_class_method(:x)`,
8235
8371
  # `K.send(:public_class_method, :x)`), anywhere: each literal name is recorded public on every class the
8236
8372
  # constant may name, so a reopening's or another file's `private_class_method` disagrees with it and is
@@ -8501,19 +8637,21 @@ module Rigor
8501
8637
  Source::AliasNames.alias_method_call_names(call_node)&.first(1) || []
8502
8638
  end
8503
8639
 
8504
- # Issue #1989 — a `define_method` or an alias that replaces a method whose visibility slot this walk recorded
8505
- # gives the name a visibility the walk does not read (the running default for `define_method`, the old
8506
- # name's for an alias), so the slot is contested rather than left asserting the replaced `def`'s. Issue #1984
8507
- # — on the singleton side the slot is contested whether or not this file recorded it, since another file may
8508
- # have.
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}).
8509
8645
  def contest_replaced_visibility(names, qualified_prefix, accumulator, side: :instance)
8510
8646
  return if names.empty? || qualified_prefix.empty?
8511
8647
 
8512
8648
  class_name = qualified_prefix.join("::")
8513
- recorded = accumulator[class_name]
8514
8649
  names.each do |name|
8515
- slot = visibility_slot(name, side)
8516
- accumulator.contest_slot(class_name, slot) if side == :singleton || recorded&.key?(slot)
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
8517
8655
  end
8518
8656
  end
8519
8657
 
@@ -8754,48 +8892,76 @@ module Rigor
8754
8892
  #
8755
8893
  # ADR-119 WD3 — the alias is the slot's writer: the new name's slot is contested when the alias is possible
8756
8894
  # or the slot it copies is contested, and cleared otherwise.
8757
- def apply_alias_def_nodes(root, accumulator, tables = nil)
8758
- alias_map = collect_class_alias_map(root, [], AliasMap.new.track(tables&.certainty))
8759
- alias_map.each do |class_name, aliases|
8760
- class_defs = accumulator[class_name]
8761
- next unless class_defs
8762
-
8763
- aliases.each do |new_name, old_name|
8764
- def_node = class_defs[old_name]
8765
- next unless def_node.is_a?(Prism::DefNode)
8766
-
8767
- (accumulator[class_name] ||= {})[new_name] = def_node
8768
- next if tables.nil?
8769
-
8770
- certain = !alias_map.possible?(class_name, new_name) &&
8771
- !tables.contested_def_nodes&.include?([class_name, old_name])
8772
- follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], certain)
8773
- 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)
8774
8923
  end
8775
8924
  end
8776
8925
 
8777
- # The `{class => {new name => old name}}` map {#collect_class_alias_map} fills, which also remembers, per
8778
- # 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}).
8779
8928
  class AliasMap < Hash
8929
+ attr_reader :events
8930
+
8780
8931
  def track(certainty)
8781
8932
  @certainty = certainty || Certainty::EMPTY
8782
- @possible = nil
8933
+ @events = []
8783
8934
  self
8784
8935
  end
8785
8936
 
8786
- # Records that `node` wrote the `[class_name, new_name]` entry.
8787
- 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)
8788
8939
  return if @certainty.nil?
8789
8940
 
8790
- if Certainty.possible?(@certainty, node)
8791
- (@possible ||= Set.new) << [class_name, new_name]
8792
- else
8793
- @possible&.delete([class_name, new_name])
8794
- 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
8795
8959
  end
8796
8960
 
8797
- def possible?(class_name, new_name)
8798
- !@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 }
8799
8965
  end
8800
8966
  end
8801
8967
 
@@ -8857,12 +9023,19 @@ module Rigor
8857
9023
  # `t` alias was the corpus case). Unlike AliasMethodNode a call's children can carry further
8858
9024
  # class bodies (`Class.new do … end`), so the walk continues below it either way.
8859
9025
  names = Source::AliasNames.alias_method_call_names(node)
8860
- 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?)
8861
9027
  record_alias_call_entry(accumulator, rec_prefix.join("::"), names, node)
8862
9028
  end
8863
9029
  false
8864
9030
  end
8865
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
+
8866
9039
  # The rebinding-block call arm of {#collect_class_alias_map}: an anonymous meta-new call
8867
9040
  # (`Class.new { … }` no write names) files its block's aliases nowhere, and an eval-family
8868
9041
  # call binds them on the receiver — a nameable receiver re-anchors the owner while a
@@ -8953,20 +9126,32 @@ module Rigor
8953
9126
  end
8954
9127
 
8955
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
+
8956
9136
  (accumulator[class_name] ||= {})[names.first] = names.last
8957
- 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)
8958
9138
  end
8959
9139
 
8960
9140
  def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
8961
9141
  return if qualified_prefix.empty?
8962
9142
 
8963
9143
  names = Source::AliasNames.keyword_names(alias_node)
8964
- return if names.nil?
8965
-
8966
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
+
8967
9152
  new_name, old_name = names
8968
9153
  (accumulator[class_name] ||= {})[new_name] = old_name
8969
- 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)
8970
9155
  end
8971
9156
 
8972
9157
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
@@ -9153,14 +9338,15 @@ module Rigor
9153
9338
  # {#discovered_project_index_incremental}'s changed-file branch) yields the live def nodes the signature
9154
9339
  # reads their parameter structure from. Issue #1120 — and each file's own refinement table, which the merged
9155
9340
  # def-index cannot attribute back to a file, so the session can diff it against the file's seed bundle.
9156
- # Issue #1459 — and each file's {#defined_method_names}, diffed the same way.
9157
- # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements:, defined_method_names: }`.
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: }`.
9158
9345
  def scan_summary_for_paths(paths, buffer: nil)
9159
9346
  acc = new_def_index_accumulator
9160
9347
  code_fingerprints = {}
9161
9348
  declaration_signatures = {}
9162
- refinements = {}
9163
- defined_method_names = {}
9349
+ per_file = { refinements: {}, defined_method_names: {}, visibility_entries: {}, global_write_census: {} }
9164
9350
  paths.each do |path|
9165
9351
  physical = buffer ? buffer.resolve(path) : path
9166
9352
  source = File.read(physical)
@@ -9169,16 +9355,21 @@ module Rigor
9169
9355
  fold_file_index(acc, file_index)
9170
9356
  code_fingerprints[path] = code_fingerprint(source, parsed.comments)
9171
9357
  declaration_signatures[path] = declaration_signature(file_index)
9172
- refinements[path] = file_index[:refinements] if file_index[:refinements]
9173
- 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? }
9174
9359
  rescue DeclarationWalk::ContractError
9175
9360
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
9176
9361
  rescue StandardError
9177
9362
  next
9178
9363
  end
9179
9364
  { def_index: finalize_def_index(acc), code_fingerprints: code_fingerprints,
9180
- declaration_signatures: declaration_signatures, refinements: refinements,
9181
- 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] }
9182
9373
  end
9183
9374
 
9184
9375
  # Issue #1459 — every method name one file's index defines, on any class or module and either side: the keys of
@@ -9192,9 +9383,39 @@ module Rigor
9192
9383
  %i[methods def_nodes singleton_def_nodes].each do |key|
9193
9384
  file_index[key]&.each_value { |table| names.merge(table.keys) }
9194
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? } }
9195
9389
  names.freeze
9196
9390
  end
9197
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
+
9198
9419
  # ADR-89 WD1 — a per-file digest of every cross-file DECLARATION surface an ancestry / file-level
9199
9420
  # dependent consumes, EXCLUDING method bodies (which only symbol dependents consume, via the ADR-46
9200
9421
  # symbol edges the change-detection already fingerprints per method). A body edit that touches no
@@ -9412,14 +9633,21 @@ module Rigor
9412
9633
  # Error degradation is identical to the two independent loops it replaces: a read / parse failure (the
9413
9634
  # rescue's real target) contributes nothing to either table. The subset-scoped callers
9414
9635
  # ({IncrementalSession}, `coverage --protection`) keep calling the individual methods unchanged.
9415
- 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)
9416
9640
  classes = {}
9417
9641
  acc = new_def_index_accumulator
9418
9642
  paths.each do |path|
9419
9643
  physical = buffer ? buffer.resolve(path) : path
9420
9644
  root = Prism.parse(File.read(physical), filepath: path).value
9421
9645
  collect_class_decls(root, [], classes)
9422
- 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
9423
9651
  rescue DeclarationWalk::ContractError
9424
9652
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
9425
9653
  rescue StandardError
@@ -9467,14 +9695,19 @@ module Rigor
9467
9695
  # @param paths — project file paths, in canonical order.
9468
9696
  # @param seed_bundles — the prior run's per-file bundles, keyed by logical path.
9469
9697
  # @return `{ classes:, def_index:, bundles: }`.
9470
- 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
9471
9701
  classes = {}
9472
9702
  acc = new_def_index_accumulator
9473
9703
  bundles = {}
9474
9704
  paths.each do |path|
9475
9705
  physical = buffer ? buffer.resolve(path) : path
9476
9706
  digest = Cache::FileDigest.hexdigest(physical)
9477
- 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]
9478
9711
  if cached && cached[:digest] == digest
9479
9712
  bundles[path] = cached
9480
9713
  classes.merge!(cached[:classes])
@@ -9483,13 +9716,13 @@ module Rigor
9483
9716
  source = File.read(physical)
9484
9717
  parsed = Prism.parse(source, filepath: path)
9485
9718
  root = parsed.value
9486
- file_index = build_file_index(path, root)
9719
+ file_index = build_file_index(path, root, uncertain: walked)
9487
9720
  file_classes = {}
9488
9721
  collect_class_decls(root, [], file_classes)
9489
- bundles[path] = build_seed_bundle(file_index, file_classes, digest,
9490
- 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
9491
9724
  classes.merge!(file_classes)
9492
- fold_file_index(acc, file_index)
9725
+ fold_file_index(acc, walked ? without_written_slots(file_index, acc) : file_index)
9493
9726
  end
9494
9727
  rescue DeclarationWalk::ContractError
9495
9728
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
@@ -9503,12 +9736,43 @@ module Rigor
9503
9736
  # Builds ONE file's isolated def-index contribution (live `Prism::DefNode`s) by folding it into a fresh
9504
9737
  # accumulator — recon Q2(a)'s "constructible in isolation" property. Shared by the cold-baseline bundle
9505
9738
  # build and the incremental changed-file re-walk.
9506
- def build_file_index(path, root)
9739
+ def build_file_index(path, root, uncertain: false)
9507
9740
  file_acc = new_def_index_accumulator
9508
- accumulate_project_index(file_acc, path, root)
9741
+ accumulate_project_index(file_acc, path, root, uncertain: uncertain)
9509
9742
  file_acc
9510
9743
  end
9511
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
+
9512
9776
  # ADR-85 WD2 — folds a single file's isolated def-index contribution into the cross-file accumulator,
9513
9777
  # applying EXACTLY the merge semantics {#accumulate_project_index} applies incrementally (def_nodes
9514
9778
  # later-wins, def_sources first-wins, includes / class_sources accumulate, everything else later-wins).
@@ -9645,9 +9909,14 @@ module Rigor
9645
9909
  # Issue #1984 — a singleton slot (`[:singleton, name]`) does not follow its writer: file order is not load
9646
9910
  # order, so two files that give it different visibilities (`private_class_method :x` here,
9647
9911
  # `class << self; public :x; end` in a reopening) leave it contested, and a contest on it stays whichever
9648
- # file folds later. The instance slots fold as before.
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.
9649
9915
  def fold_visibility_contests(sitting, recorded, file_slots, file_contested)
9650
- stale = sitting.select { |class_name, name| !name.is_a?(Array) && file_slots[class_name]&.key?(name) }
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
9651
9920
  disagreeing = singleton_visibility_disagreements(recorded, file_slots)
9652
9921
  return sitting if stale.empty? && file_contested.empty? && disagreeing.empty?
9653
9922
 
@@ -10070,12 +10339,12 @@ module Rigor
10070
10339
  # Folds one file's class-keyed indexes into the cross-file accumulator. `method_visibilities` (ADR-35) is
10071
10340
  # collected here so the override-visibility-reduced rule can read an ancestor's visibility declared in a sibling
10072
10341
  # file.
10073
- def accumulate_project_index(acc, path, root)
10342
+ def accumulate_project_index(acc, path, root, uncertain: false)
10074
10343
  # One combined descent yields both the methods existence table and the def-node table; the latter is also
10075
10344
  # consumed by `record_class_sources`, so a def-dense file is walked once here instead of three times (methods +
10076
10345
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
10077
10346
  file_methods, file_def_nodes, file_envelopes, file_refinements, file_singleton_def_nodes, certainty =
10078
- walk_def_contributions(acc, path, root)
10347
+ walk_def_contributions(acc, path, root, uncertain: uncertain)
10079
10348
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
10080
10349
  fold_parameter_envelopes(acc, file_envelopes)
10081
10350
  fold_refinements(acc, file_refinements)
@@ -10102,8 +10371,8 @@ module Rigor
10102
10371
  # file's siblings folded into the accumulator BEFORE its members join it (ADR-119 WD3: the `possible` rule
10103
10372
  # reads the accumulator's member as it stood). Returns `[methods, def_nodes, envelopes, refinements,
10104
10373
  # singleton_def_nodes, certainty]`, the classification passed on for the visibility walk.
10105
- def walk_def_contributions(acc, path, root)
10106
- 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)
10107
10376
  file_methods, file_def_nodes, file_envelopes, file_refinements, file_siblings =
10108
10377
  build_methods_and_def_nodes(root, path, certainty: certainty)
10109
10378
  file_singleton_def_nodes, file_singleton_contested = build_singleton_def_tables(root, certainty: certainty)
@@ -10149,7 +10418,15 @@ module Rigor
10149
10418
  census.writes.each do |name, descriptor|
10150
10419
  (acc[:constant_writes][name] ||= {})[path] = descriptor
10151
10420
  end
10152
- 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 }
10153
10430
  end
10154
10431
 
10155
10432
  # Folds one file's Data + Struct member-layout tables into the cross-file accumulator (kept out of
@@ -10272,8 +10549,12 @@ module Rigor
10272
10549
  singleton_cref: false)
10273
10550
  return unless node.is_a?(Prism::Node)
10274
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)
10275
10554
  case node
10276
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)
10277
10558
  return walk_census_singleton_class(node, qualified_prefix, tables, self_owner,
10278
10559
  meta_owner, singleton_cref)
10279
10560
  when Prism::ClassNode, Prism::ModuleNode
@@ -10281,7 +10562,7 @@ module Rigor
10281
10562
  singleton_cref)
10282
10563
  else
10283
10564
  census_constant_write(node, qualified_prefix, tables, self_owner, singleton_cref: singleton_cref)
10284
- tables.write_census.visit(node, top_level: qualified_prefix.empty?)
10565
+ tables.write_census.visit(node, top_level: qualified_prefix.empty?, owner: ivar_owner)
10285
10566
  end
10286
10567
 
10287
10568
  rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner, self_owner)
@@ -10313,6 +10594,16 @@ module Rigor
10313
10594
  end
10314
10595
  end
10315
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
+
10316
10607
  # Under an unnameable cref a bare/`self::` header pushes `#<singleton>::Name` — still
10317
10608
  # unnameable — so the body keeps the OPAQUE self and the cref flag; nameable
10318
10609
  # headers re-anchor at a real cref.
@@ -11400,6 +11691,7 @@ module Rigor
11400
11691
  table[node] = parent_scope
11401
11692
  parent_scope
11402
11693
  end
11694
+ evaluate_unentered_flow(node, table, current_scope)
11403
11695
 
11404
11696
  case node
11405
11697
  when Prism::IfNode
@@ -11415,7 +11707,7 @@ module Rigor
11415
11707
  child_scope = entered ? current_scope : closure_scope(node, current_scope)
11416
11708
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
11417
11709
  when Prism::CallNode
11418
- propagate_call(node, table, current_scope)
11710
+ propagate_call(node, table, current_scope, recorded: recorded)
11419
11711
  when Prism::RescueNode, Prism::EnsureNode
11420
11712
  # Issue #1360 — a clause the evaluator did not enter (a `begin` in a value position) reads `$!`, `$@` and
11421
11713
  # `$?` unbound, not the enclosing clause's; one it entered keeps what it recorded.
@@ -11429,6 +11721,84 @@ module Rigor
11429
11721
  end
11430
11722
  end
11431
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
+
11432
11802
  SELF_OPENING_BODIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
11433
11803
  private_constant :SELF_OPENING_BODIES
11434
11804
 
@@ -11463,11 +11833,11 @@ module Rigor
11463
11833
  # argument one scope and threads none, so arguments that write keep the call's: an earlier one may rebind the
11464
11834
  # receiver a later one reads (`return y&.concat((y = nil).to_s, y.upcase)`, where the evaluator threads no
11465
11835
  # operand).
11466
- def propagate_call(node, table, current_scope)
11836
+ def propagate_call(node, table, current_scope, recorded: false)
11467
11837
  block = node.block
11468
11838
  operands =
11469
11839
  OperandEffects.any?(node.arguments) ? current_scope : Narrowing.safe_navigation_scope(node, current_scope)
11470
- entry = unentered_block_entry(node, block, table, current_scope)
11840
+ entry = unentered_block_entry(node, block, table, current_scope, call_recorded: recorded)
11471
11841
  if entry.equal?(current_scope) && operands.equal?(current_scope)
11472
11842
  node.rigor_each_child { |child| propagate(child, table, current_scope) }
11473
11843
  return
@@ -11491,7 +11861,7 @@ module Rigor
11491
11861
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
11492
11862
  end
11493
11863
 
11494
- def unentered_block_entry(node, block, table, current_scope)
11864
+ def unentered_block_entry(node, block, table, current_scope, call_recorded: false)
11495
11865
  return current_scope unless block.is_a?(Prism::BlockNode) && !table.key?(block)
11496
11866
 
11497
11867
  narrowing = unentered_block_narrowing(node, current_scope)
@@ -11504,6 +11874,12 @@ module Rigor
11504
11874
  else
11505
11875
  LastLine.block_entry(FreshFrameBlocks.closure_entry(current_scope, block, node), block, node)
11506
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)
11507
11883
  anonymous = AnonymousMetaClass.name_for(node, current_scope.source_path)
11508
11884
  return unentered_meta_class_body_entry(block, entry, anonymous, table) if anonymous
11509
11885
 
@@ -11690,7 +12066,8 @@ module Rigor
11690
12066
  # A test that calls a method may rebind an instance-variable or global subject before the body reads it
11691
12067
  # (`when String then 1 when reset! then @x.upcase`, where `reset!` writes `@x`), and the walk cannot tell. From
11692
12068
  # the first such test on, the subject reads `Dynamic[top]` on both edges ({#forget_called_subject}). The
11693
- # evaluator keeps the narrowing there (a separate defect); the walk declines it rather than add a report.
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.
11694
12071
  def propagate_subject_case(node, table, current_scope)
11695
12072
  subject = node.predicate
11696
12073
  propagate(subject, table, current_scope)