rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -101,13 +101,17 @@ module Rigor
101
101
  # bodies from their CONVERGED bindings so per-line probes
102
102
  # (`rigor annotate`) reflect the post-writeback state, not the
103
103
  # cap-N intermediate constants. Off for the check path.
104
+ # @param widen_clauses — check-path flag (issue #1231): a rescue or
105
+ # `ensure` clause records the variables the `begin` body writes as
106
+ # `Dynamic[top]`. Off for every other reader, so parameter inference
107
+ # and sig-gen observe the clause's plain entry.
104
108
  # @return identity-comparing
105
109
  # table whose default value is `default_scope`.
106
110
  # The no-op re-anchoring table (#722 residue 2): shared so the overwhelmingly common
107
111
  # nothing-to-re-anchor walk allocates none.
108
112
  EMPTY_RENAMES = {}.freeze
109
113
 
110
- def index(root, default_scope:, converged_loop_recording: false) # rubocop:disable Metrics/AbcSize
114
+ def index(root, default_scope:, converged_loop_recording: false, widen_clauses: false) # rubocop:disable Metrics/AbcSize
111
115
  # Slice A-declarations. Build the declaration overrides first so every scope handed to the StatementEvaluator
112
116
  # already carries the table; structural sharing through `Scope#with_local` / `#with_fact` / `#with_self_type`
113
117
  # propagates it across every derived scope.
@@ -194,7 +198,8 @@ module Rigor
194
198
 
195
199
  on_enter = ->(node, scope) { table[node] = scope }
196
200
  StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
197
- converged_loop_recording: converged_loop_recording).evaluate(root)
201
+ converged_loop_recording: converged_loop_recording,
202
+ widen_clauses: widen_clauses).evaluate(root)
198
203
 
199
204
  propagate(root, table, seeded_scope)
200
205
  table
@@ -502,6 +507,7 @@ module Rigor
502
507
  method_assign_effects = build_method_assign_effects(root)
503
508
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
504
509
  read_before_write, init_writes, method_assign_effects)
510
+ exempt_class_body_writes!(init_writes, accumulator.delete(DEFINE_METHOD_WRITES) || {})
505
511
  merge_held_ivar_writes!(accumulator)
506
512
  record_aliased_ivar_mutations!(root, mutated_ivars)
507
513
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
@@ -509,6 +515,19 @@ module Rigor
509
515
  accumulator.transform_values(&:freeze).freeze
510
516
  end
511
517
 
518
+ # Folds the class-body writes {#collect_class_body_ivar_writes} held under {CLASS_BODY_WRITES} into the
519
+ # `init_writes` exemption, except for an ivar a `define_method` block of the class writes (#1768). Such an ivar
520
+ # had no census entry before the block's writes were counted, so the exemption never applied to it; a
521
+ # class-body `@x = nil` writes the class object's ivar and says nothing about whether an instance's `@x` is set,
522
+ # and exempting it would seed `@x` non-nil from the block's write alone (`if @x` folding always-truthy on an
523
+ # instance whose `@x` is unset). It keeps the read-before-write `nil` instead, which only widens.
524
+ def exempt_class_body_writes!(init_writes, define_method_writes)
525
+ (init_writes.delete(CLASS_BODY_WRITES) || {}).each do |class_name, ivars|
526
+ kept = ivars - (define_method_writes[class_name] || EMPTY_GUARDED_IVARS)
527
+ (init_writes[class_name] ||= Set.new).merge(kept)
528
+ end
529
+ end
530
+
512
531
  # B2.3 — finalize the read-before-write nil contribution. For each class, for each ivar where SOME method body
513
532
  # observed a read-before-write AND no `initialize` write exists for that ivar, contribute `Constant[nil]` to the
514
533
  # class-wide accumulator.
@@ -849,6 +868,7 @@ module Rigor
849
868
  owner = def_owner || qualified_prefix
850
869
  collect_initializer_block_ivars(node, owner, default_scope, accumulator, mutated_ivars, init_writes)
851
870
  record_attr_writer_ivars(node, owner, accumulator) if ATTR_WRITER_MACROS.include?(node.name)
871
+ collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
852
872
  false
853
873
  else
854
874
  walk_ivars_meta_new?(node, qualified_prefix, default_scope, accumulator,
@@ -1060,6 +1080,44 @@ module Rigor
1060
1080
  end
1061
1081
  end
1062
1082
 
1083
+ # The `define_method` arm of {#walk_class_ivars} (#1768). `define_method(:m) { … }` on the implicit
1084
+ # or `self` receiver installs its block as an instance method of `owner`, and Ruby runs the body on
1085
+ # the instance, so its `@x = …` writes are the instance's ivars exactly as a `def m` body's are —
1086
+ # `DefineMethodBlockSelf` narrows the block's `self` the same way when it is evaluated. Without this
1087
+ # arm an ivar `initialize` sets to `nil` and a `define_method` block sets to a String read as `nil`
1088
+ # in every sibling `def`. The block is typed under the instance `self`, as a def body is. Only the
1089
+ # literal-block form counts: a `define_method(:m, callable)` or `&callable` argument is not walked.
1090
+ # `owner` is empty in a `class << self` body (the call defines a class method there) and in an
1091
+ # anonymous factory body, and the block contributes nothing there. A `define_method` inside a `def`
1092
+ # body is already reached by that body's walk ({#gather_ivar_writes} enters blocks).
1093
+ def collect_define_method_block_ivars(node, owner, default_scope, accumulator, mutated_ivars)
1094
+ return if owner.empty?
1095
+ return unless define_method_block_call?(node)
1096
+
1097
+ block = node.block
1098
+ return unless block.body
1099
+
1100
+ class_name = owner.join("::")
1101
+ body_scope = census_body_scope(default_scope, owner, Type::Combinator.nominal_of(class_name))
1102
+ gather_ivar_writes(block.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
1103
+ written = (accumulator[DEFINE_METHOD_WRITES] ||= {})[class_name] ||= Set.new
1104
+ define_method_written_ivars(block.body, written)
1105
+ end
1106
+
1107
+ # The ivars a `define_method` block body writes, in any write form, outside a nested `def` / `class` / `module`.
1108
+ def define_method_written_ivars(node, acc)
1109
+ return unless node.is_a?(Prism::Node)
1110
+ return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
1111
+
1112
+ case node
1113
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
1114
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
1115
+ Prism::InstanceVariableTargetNode
1116
+ acc << node.name
1117
+ end
1118
+ node.rigor_each_child { |child| define_method_written_ivars(child, acc) }
1119
+ end
1120
+
1063
1121
  # ADR-38 block-form gate: true when a loaded plugin declares `method_name` a block-form initializer for
1064
1122
  # `class_name` (or an ancestor). Mirrors `additional_initializer?` but queries `covers_block_method?` instead of
1065
1123
  # `covers_method?`.
@@ -1151,15 +1209,21 @@ module Rigor
1151
1209
  # every `@x = …` write target as a class-body init. Consumed by `contribute_read_before_write_nil!` to exempt
1152
1210
  # ivars the author already knows might be nil (the `@x = nil` at class-body level is the canonical nullability
1153
1211
  # acknowledgement; the instance @x is technically a separate store, but the pragmatic intent is unambiguous).
1212
+ #
1213
+ # A literal `define_method` block is a method body, not class-body code (#1768): its writes reach the census as
1214
+ # a `def` body's do, so they must not exempt the ivar from the read-before-write `nil` either. Only its receiver
1215
+ # and arguments are walked.
1154
1216
  def collect_class_body_ivar_writes(node, class_name, init_writes)
1155
1217
  return unless node.is_a?(Prism::Node)
1156
1218
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
1157
1219
 
1220
+ return collect_define_method_argument_writes(node, class_name, init_writes) if define_method_block_call?(node)
1221
+
1158
1222
  if node.is_a?(Prism::InstanceVariableWriteNode) ||
1159
1223
  node.is_a?(Prism::InstanceVariableOrWriteNode) ||
1160
1224
  node.is_a?(Prism::InstanceVariableAndWriteNode) ||
1161
1225
  node.is_a?(Prism::InstanceVariableOperatorWriteNode)
1162
- (init_writes[class_name] ||= Set.new) << node.name
1226
+ ((init_writes[CLASS_BODY_WRITES] ||= {})[class_name] ||= Set.new) << node.name
1163
1227
  end
1164
1228
 
1165
1229
  node.rigor_each_child do |child|
@@ -1167,6 +1231,18 @@ module Rigor
1167
1231
  end
1168
1232
  end
1169
1233
 
1234
+ # The arguments of a `define_method` block call, walked by {#collect_class_body_ivar_writes} in place of the
1235
+ # call's children so the block body stays out of the class-body init writes.
1236
+ def collect_define_method_argument_writes(node, class_name, init_writes)
1237
+ node.arguments&.arguments&.each { |arg| collect_class_body_ivar_writes(arg, class_name, init_writes) }
1238
+ end
1239
+
1240
+ # A `define_method` call with a literal block on the implicit or `self` receiver: the block is an instance
1241
+ # method body of the class the walk is in (#1768).
1242
+ def define_method_block_call?(node)
1243
+ DefineMethodBlockSelf.define_method_on_lexical_self?(node) && node.block.is_a?(Prism::BlockNode)
1244
+ end
1245
+
1170
1246
  def detect_read_before_write(node, seen_writes, read_first)
1171
1247
  return unless node.is_a?(Prism::Node)
1172
1248
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
@@ -2006,6 +2082,13 @@ module Rigor
2006
2082
  OPERATOR_WRITE_CONTRIBUTIONS = :operator_write_contributions
2007
2083
  private_constant :AND_WRITE_CONTRIBUTIONS, :OPERATOR_WRITE_CONTRIBUTIONS
2008
2084
 
2085
+ # The same Symbol-key convention for two side tables the walk fills and {#build_class_ivar_index} removes
2086
+ # before anything reads class names: the ivars each class's `define_method` blocks write (in the
2087
+ # accumulator), and the class-body writes held out of `init_writes` until {#exempt_class_body_writes!}.
2088
+ DEFINE_METHOD_WRITES = :define_method_writes
2089
+ CLASS_BODY_WRITES = :class_body_writes
2090
+ private_constant :DEFINE_METHOD_WRITES, :CLASS_BODY_WRITES
2091
+
2009
2092
  # Unions `type` into the class-ivar accumulator for `(class_name, ivar_name)`. Shared by the single-write and
2010
2093
  # multi-write (parallel-assignment) collectors.
2011
2094
  def accumulate_ivar_type(accumulator, class_name, ivar_name, type)
@@ -3990,6 +4073,7 @@ module Rigor
3990
4073
  if methods_acc.mentions_refine? && methods_acc.refine_in?(location.start_offset, location.end_offset)
3991
4074
  node.rigor_each_child { |child| census_scan(child, qualified_prefix, methods_acc) }
3992
4075
  end
4076
+ record_foreign_singleton_def(node, qualified_prefix, owner_prefix, methods_acc.at(node))
3993
4077
  unless def_owner_prefix&.empty? || defs_singleton == :unnameable ||
3994
4078
  (singleton_cref && owner_prefix.empty?)
3995
4079
  singleton_def = in_singleton_class || defs_singleton
@@ -4729,6 +4813,50 @@ module Rigor
4729
4813
  acc[:deferred_ranges][path] = build_deferred_ranges(root)
4730
4814
  record_class_sources(acc[:class_sources], path, root, superclasses, includes, file_def_nodes,
4731
4815
  acc[:compact_headers])
4816
+ record_singleton_class_sources(acc[:singleton_class_sources], path, root)
4817
+ end
4818
+
4819
+ # `{name => Set[path]}` for a bundle's name list (a bundle written before the list existed has none).
4820
+ def bundle_path_sets(names, path) = (names || []).to_h { |name| [name, Set[path]] }
4821
+
4822
+ # Issue #1752 — the constants a `class << Const` body opens in this file, and those whose singleton side a call
4823
+ # there may rewrite ({#singleton_side_call?}). Such a body records facts about
4824
+ # `Const`'s singleton class (a `remove_method`, a `def`) without declaring `Const`, so it stays out of
4825
+ # `class_sources` (whose readers take a name there as one the project declares) and joins only the per-file
4826
+ # class-declaration sets the incremental `class:` edges diff (`Runner#class_declarations`).
4827
+ def record_singleton_class_sources(sources, path, root)
4828
+ collect_singleton_class_targets(root, []) { |name| (sources[name] ||= Set.new) << path }
4829
+ end
4830
+
4831
+ def collect_singleton_class_targets(node, lexical_prefix, &)
4832
+ case node
4833
+ when Prism::ClassNode, Prism::ModuleNode
4834
+ prefix = Source::ConstantPath.declaration_prefix(lexical_prefix, node.constant_path)
4835
+ node.body && prefix && collect_singleton_class_targets(node.body, prefix, &)
4836
+ return
4837
+ when Prism::SingletonClassNode
4838
+ rendered = singleton_receiver_constant_name(node.expression)
4839
+ yield eval_constant_receiver_prefix(node, node.expression, rendered, lexical_prefix).join("::") if rendered
4840
+ when Prism::CallNode
4841
+ yield_singleton_side_constant(node, node.receiver, lexical_prefix, &) if singleton_side_call?(node)
4842
+ end
4843
+ node.rigor_each_child { |child| collect_singleton_class_targets(child, lexical_prefix, &) }
4844
+ end
4845
+
4846
+ # A call on a constant that may rewrite its singleton side (`Const.singleton_class`,
4847
+ # `Const.define_singleton_method`, `Const.extend(M)`, `Const.instance_eval { … }`) records facts about `Const`'s
4848
+ # 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.
4851
+ def singleton_side_call?(node)
4852
+ node.name == :singleton_class || surface_rewriting_call?(node)
4853
+ end
4854
+
4855
+ def yield_singleton_side_constant(node, receiver, lexical_prefix)
4856
+ return unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
4857
+
4858
+ rendered = singleton_receiver_constant_name(receiver)
4859
+ yield eval_constant_receiver_prefix(node, receiver, rendered, lexical_prefix).join("::") if rendered
4732
4860
  end
4733
4861
 
4734
4862
  def build_deferred_ranges(root)
@@ -6954,7 +7082,9 @@ module Rigor
6954
7082
  def extends_block_handled?(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6955
7083
  singleton_cref)
6956
7084
  case extends_block_plan(node, accumulator)
6957
- when :skip then true
7085
+ when :skip
7086
+ taint_skipped_singleton_rewrites(node.block, qualified_prefix, current_class, accumulator)
7087
+ true
6958
7088
  when :rebound
6959
7089
  walk_extends_block_call(node, qualified_prefix, current_class, accumulator,
6960
7090
  in_singleton, singleton_self, singleton_cref)
@@ -6963,6 +7093,31 @@ module Rigor
6963
7093
  end
6964
7094
  end
6965
7095
 
7096
+ # Issue #1906 — a block the extends walk skips may still run, and a `singleton_class` call in it, or in a `def`
7097
+ # it holds (`[1].each { D.singleton_class.alias_method :own, :other }` in a method body,
7098
+ # `to_prepare do def helper = D.singleton_class.alias_method(...) end`), reaches no mark the methods walk records
7099
+ # there. Every such call taints the side it may rewrite, as {#singleton_class_rewrite_tainted?} does for one the
7100
+ # walk visits; a constant receiver taints every name it can denote from here.
7101
+ def taint_skipped_singleton_rewrites(node, qualified_prefix, current_class, accumulator)
7102
+ return unless node.is_a?(Prism::Node)
7103
+
7104
+ if node.is_a?(Prism::CallNode) && singleton_class_of_receiver?(node.receiver) && surface_rewriting_call?(node)
7105
+ skipped_rewrite_owners(node.receiver.receiver, qualified_prefix, current_class).each do |owner|
7106
+ accumulator.taint(owner, :extend)
7107
+ end
7108
+ end
7109
+ node.rigor_each_child do |child|
7110
+ taint_skipped_singleton_rewrites(child, qualified_prefix, current_class, accumulator)
7111
+ end
7112
+ end
7113
+
7114
+ def skipped_rewrite_owners(object, qualified_prefix, current_class)
7115
+ return [current_class].compact if object.nil? || object.is_a?(Prism::SelfNode)
7116
+ return [] unless object.is_a?(Prism::ConstantReadNode) || object.is_a?(Prism::ConstantPathNode)
7117
+
7118
+ constant_receiver_candidates(object, qualified_prefix)
7119
+ end
7120
+
6966
7121
  ARRAY_ITERATORS = %i[each each_with_index map].freeze
6967
7122
  HASH_ITERATORS = %i[each each_pair each_key each_value map].freeze
6968
7123
  private_constant :ARRAY_ITERATORS, :HASH_ITERATORS
@@ -7160,6 +7315,7 @@ module Rigor
7160
7315
  return record_module_function(node, current_class, accumulator, in_singleton) if node.name == :module_function
7161
7316
 
7162
7317
  taint_opaque_eval(node, current_class, accumulator)
7318
+ return if singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7163
7319
 
7164
7320
  kind, _arguments, via_send = mixin_call_view(node)
7165
7321
  return if kind.nil?
@@ -7172,6 +7328,76 @@ module Rigor
7172
7328
  effects.each { |owner, side| accumulator.taint(owner, side) }
7173
7329
  end
7174
7330
 
7331
+ # Issue #1752 — a call that rewrites a singleton class reached through `singleton_class` (`singleton_class.
7332
+ # class_eval { def x }`, `singleton_class.send(:define_method, :x)`, `Const.singleton_class.prepend(M)`,
7333
+ # `Const.singleton_class.send(:include, M)`) puts methods or modules on that singleton class that no edge
7334
+ # records, so its singleton side is unpositioned: the owner is the enclosing class for a bare or `self.`
7335
+ # receiver, else the constant. True when it tainted.
7336
+ #
7337
+ # Issue #1906 — a naming call with literal names ({#singleton_class_named_call?}) marks only its names instead,
7338
+ # but only where it is a direct statement (a top-level statement, or one of a direct declaration body or of a
7339
+ # block that runs there once): there the methods walk records those marks under the same owner. In a method
7340
+ # 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)
7342
+ receiver = node.receiver
7343
+ return false unless singleton_class_of_receiver?(receiver) && surface_rewriting_call?(node)
7344
+ return false if singleton_class_named_call?(node) && accumulator.direct?(node)
7345
+
7346
+ owner = singleton_class_owner(node, receiver.receiver, qualified_prefix, current_class)
7347
+ return false if owner.nil?
7348
+
7349
+ accumulator.taint(owner, :extend)
7350
+ true
7351
+ end
7352
+
7353
+ # A bare, `self.` or constant receiver's `singleton_class` call.
7354
+ def singleton_class_of_receiver?(receiver)
7355
+ receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil?
7356
+ end
7357
+
7358
+ # An `attr_*`, `alias_method`, `define_method` or `remove_method` on a singleton class
7359
+ # (`singleton_class.attr_accessor :conf`, `singleton_class.alias_method :o2, :own`) rewrites only the names it
7360
+ # spells, so it makes those names' singleton envelopes opaque and contested ({#record_singleton_class_names}) and
7361
+ # leaves the rest of the singleton side readable. One whose rewritten name is not a literal Symbol or String
7362
+ # (`attr_accessor(*KEYS)`, `alias_method name, :own`, `define_method(sym)`), and any other surface call there
7363
+ # (`class_eval`, `send`, `prepend`), still taints the side: the own-definer read would otherwise answer the body
7364
+ # Ruby replaced (`definer_resolution_witness_spec.rb`).
7365
+ def singleton_class_named_call?(node)
7366
+ !singleton_class_rewritten_names(node).nil?
7367
+ end
7368
+
7369
+ # Issue #1906 — the names a singleton-class call rewrites, or nil where it is not one of the naming forms or
7370
+ # does not spell every rewritten name as a literal. `alias_method` rewrites its new name (the aliased one keeps
7371
+ # its body), `define_method` its first argument, `remove_method` and `attr_*` every argument.
7372
+ def singleton_class_rewritten_names(node)
7373
+ arguments = node.arguments&.arguments || []
7374
+ rewritten = case node.name
7375
+ when :alias_method, :define_method then arguments.first(1)
7376
+ when :remove_method then arguments
7377
+ else ATTR_MACROS.include?(node.name) && arguments
7378
+ end
7379
+ return nil if !rewritten || arguments.empty? || !arguments.all? { |argument| literal_argument?(node, argument) }
7380
+
7381
+ rewritten.map { |argument| literal_method_name(argument) }
7382
+ end
7383
+
7384
+ # `define_method(:x, body)` takes a body that is not a name; every other argument must be a literal name.
7385
+ def literal_argument?(node, argument)
7386
+ return true if node.name == :define_method && !argument.equal?(node.arguments.arguments.first)
7387
+
7388
+ !literal_method_name(argument).nil?
7389
+ end
7390
+
7391
+ def singleton_class_owner(node, object, qualified_prefix, current_class)
7392
+ return current_class if object.nil? || object.is_a?(Prism::SelfNode)
7393
+
7394
+ rendered = Source::ConstantPath.qualified_name_or_nil(object)
7395
+ return nil if rendered.nil?
7396
+ return rendered if Source::ConstantPath.rooted?(object) || qualified_prefix.empty?
7397
+
7398
+ eval_constant_receiver_prefix(node, object, rendered, qualified_prefix).join("::")
7399
+ end
7400
+
7175
7401
  # The bare `module_function` toggle: the module extends itself. A direct statement only.
7176
7402
  def record_module_function(node, current_class, accumulator, in_singleton)
7177
7403
  return unless current_class && node.receiver.nil? && !in_singleton && ModuleFunctionState.extends_self?(node)
@@ -7353,19 +7579,15 @@ module Rigor
7353
7579
 
7354
7580
  # v0.1.2 — per-class method-visibility table for the `def.method-visibility-mismatch` CheckRule.
7355
7581
  #
7356
- # Tracks two visibility-changing forms:
7582
+ # Tracks three visibility-changing forms ({#apply_visibility_call}):
7357
7583
  #
7358
- # - **Modifier blocks**: a bare `private` / `protected` /
7359
- # `public` call inside a class body switches the
7360
- # "current default" visibility for every subsequent
7361
- # `def` until another modifier flips it again.
7362
- # - **Named-argument form**: `private :foo, :bar` (or
7363
- # the same with `protected` / `public`) marks specific
7364
- # names already-recorded under the class. Symbol-only
7365
- # args are recognised; `private def foo; end` (the
7366
- # wrap-around form) is not yet — it would need
7367
- # tracking the def-call's return-value visibility,
7368
- # which is a separate slice.
7584
+ # - **Modifier blocks**: a bare `private` / `protected` / `public` call inside a class body (also written
7585
+ # `self.private`, `send(:private)` or with a block) switches the "current default" visibility for every
7586
+ # subsequent `def` and `attr_*` member until another modifier flips it again.
7587
+ # - **Named-argument form**: `private :foo, :bar` or `private %i[foo bar]` (or the same with `protected` /
7588
+ # `public`) marks specific names already recorded under the class.
7589
+ # - **Wrap-around form** (#1911, #1819, #1633): `private def foo` and `private attr_reader :foo` record the
7590
+ # members with the call's visibility and leave the default alone.
7369
7591
  #
7370
7592
  # Top-level (no surrounding class) defs do not contribute — Ruby's top-level visibility nuances (private at
7371
7593
  # top-level marks the method on `Object`) are out of scope for v0.1.2.
@@ -7422,6 +7644,26 @@ module Rigor
7422
7644
  end
7423
7645
  end
7424
7646
 
7647
+ # Contests the `[class_name, name]` slot, recorded or not: a visibility call changed it in a way the walk
7648
+ # cannot read (`private def self.x` reaching an instance `x`).
7649
+ def contest_slot(class_name, name)
7650
+ (@contested ||= Set.new) << [class_name, name].freeze
7651
+ end
7652
+
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) }
7656
+ end
7657
+
7658
+ # Runs the block with the running default uncertain, so a `def` it records is contested.
7659
+ def uncertainly
7660
+ saved = @uncertain
7661
+ @uncertain = true
7662
+ yield
7663
+ ensure
7664
+ @uncertain = saved
7665
+ end
7666
+
7425
7667
  # Runs the block as a fresh body (a class or module, `class << self`, a meta-new or eval block), whose
7426
7668
  # default starts certain and whose toggles do not reach the enclosing body.
7427
7669
  def body
@@ -7443,10 +7685,37 @@ module Rigor
7443
7685
  end
7444
7686
  end
7445
7687
 
7446
- # A bare toggle ({VISIBILITY_TOGGLES}): receiverless, with no arguments.
7688
+ # A bare toggle ({VISIBILITY_TOGGLES}): a visibility call ({#visibility_call_parts}) with no arguments.
7447
7689
  def bare_visibility_toggle?(node)
7448
- node.is_a?(Prism::CallNode) && node.receiver.nil? && VISIBILITY_TOGGLES.include?(node.name) &&
7449
- (node.arguments.nil? || node.arguments.arguments.empty?) && node.block.nil?
7690
+ parts = visibility_call_parts(node)
7691
+ !parts.nil? && parts.last.empty?
7692
+ end
7693
+
7694
+ # Whether a visibility call with these arguments may set the running default: none at all, or a splat that
7695
+ # may expand to none (`private(*[])` is a bare `private`).
7696
+ def visibility_toggle_arguments?(arguments)
7697
+ arguments.empty? || arguments.any?(Prism::SplatNode)
7698
+ end
7699
+
7700
+ VISIBILITY_SEND_CALLS = %i[send __send__].freeze
7701
+ EMPTY_VISIBILITY_ARGUMENTS = [].freeze
7702
+ private_constant :VISIBILITY_SEND_CALLS, :EMPTY_VISIBILITY_ARGUMENTS
7703
+
7704
+ # Issue #1633 — `[name, arguments]` for a call Ruby runs as one of {VISIBILITY_TOGGLES} on the body's self:
7705
+ # written receiverless or on `self` (`self.private`), or sent with a literal name (`send(:private, :x)`,
7706
+ # `__send__(:private)`). A block is ignored, as `Module#private` ignores it (`private {}` is a bare
7707
+ # `private`). nil for any other call.
7708
+ def visibility_call_parts(node)
7709
+ return nil unless node.is_a?(Prism::CallNode)
7710
+ return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
7711
+
7712
+ name = node.name
7713
+ arguments = node.arguments&.arguments || EMPTY_VISIBILITY_ARGUMENTS
7714
+ if VISIBILITY_SEND_CALLS.include?(name)
7715
+ name = Source::Literals.symbol_or_string(arguments.first)
7716
+ arguments = arguments.drop(1)
7717
+ end
7718
+ VISIBILITY_TOGGLES.include?(name) ? [name, arguments] : nil
7450
7719
  end
7451
7720
 
7452
7721
  # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
@@ -7510,16 +7779,31 @@ module Rigor
7510
7779
  defs_singleton: defs_singleton)
7511
7780
  return current_visibility
7512
7781
  end
7513
- accumulator.toggles += 1 if bare_visibility_toggle?(node)
7514
- # The visibility table is instance-side only — `private :x` inside `class <<`
7515
- # (or a receiver-eval body that re-evaluates a singleton self) marks the
7516
- # SINGLETON method, which this table cannot express.
7517
- updated = if in_singleton_class
7518
- current_visibility
7519
- else
7520
- apply_visibility_call(node, owner_prefix, current_visibility, accumulator)
7521
- end
7522
- return updated unless updated.equal?(current_visibility)
7782
+ if AnonymousMetaClass.block_form_receiver(node)
7783
+ walk_anonymous_meta_visibilities(node, qualified_prefix, in_singleton_class, current_visibility,
7784
+ accumulator, def_owner_prefix, singleton_cref: singleton_cref,
7785
+ defs_singleton: defs_singleton)
7786
+ return current_visibility
7787
+ end
7788
+ if (parts = visibility_call_parts(node))
7789
+ 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|
7798
+ walk_method_visibilities(argument, qualified_prefix, in_singleton_class, current_visibility,
7799
+ accumulator, def_owner_prefix, singleton_cref: singleton_cref,
7800
+ defs_singleton: defs_singleton)
7801
+ end
7802
+ end
7803
+ end
7804
+ unless in_singleton_class || defs_singleton
7805
+ record_attr_visibility(node, owner_prefix, current_visibility, accumulator, toggled: true)
7806
+ end
7523
7807
  end
7524
7808
 
7525
7809
  # Statement-position StatementsNode preserves left-to-right visibility flow; everything else recurses with the
@@ -7573,6 +7857,23 @@ module Rigor
7573
7857
  end
7574
7858
  end
7575
7859
 
7860
+ # A `Class.new` / `Module.new` / `Struct.new` / `Data.define` block no constant write names
7861
+ # ({#meta_new_block_split} takes the named ones): `klass = Class.new do … end`, `@k = Struct.new(:a) do … end`,
7862
+ # `register(Class.new do … end)` or a bare `Data.define(:a) do … end`. Its body is the anonymous class's, so
7863
+ # it walks as a fresh body with no owner and records nothing for the enclosing class; the receiver and the
7864
+ # arguments are walked where they stand.
7865
+ def walk_anonymous_meta_visibilities(node, qualified_prefix, in_singleton_class, current_visibility,
7866
+ accumulator, def_owner_prefix, singleton_cref:, defs_singleton:) # rubocop:disable Metrics/ParameterLists
7867
+ [node.receiver, *node.arguments&.arguments].each do |part|
7868
+ walk_method_visibilities(part, qualified_prefix, in_singleton_class, current_visibility, accumulator,
7869
+ def_owner_prefix, singleton_cref: singleton_cref, defs_singleton: defs_singleton)
7870
+ end
7871
+ accumulator.body do
7872
+ walk_method_visibilities(node.block.body, qualified_prefix, false, :public, accumulator,
7873
+ EMPTY_PREFIX, singleton_cref: singleton_cref)
7874
+ end
7875
+ end
7876
+
7576
7877
  # The eval-block arm of {#walk_method_visibilities}: the block is a fresh class body under
7577
7878
  # the receiver's def-owner — visibility starts at `:public`, and nothing inside it leaks
7578
7879
  # back to this body's modifier state. An unnameable receiver walks the body ownerless;
@@ -7610,47 +7911,80 @@ module Rigor
7610
7911
  end
7611
7912
  end
7612
7913
 
7613
- def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
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)
7614
7917
  return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
7615
7918
  return if qualified_prefix.empty?
7616
7919
 
7617
7920
  class_name = qualified_prefix.join("::")
7618
7921
  accumulator[class_name] ||= {}
7619
7922
  accumulator[class_name][def_node.name] = current_visibility
7620
- accumulator.wrote(class_name, def_node.name, def_node, toggled: true)
7621
- end
7923
+ accumulator.wrote(class_name, def_node.name, def_node, toggled: toggled)
7924
+ end
7925
+
7926
+ # Applies one `private` / `protected` / `public` call ({#visibility_call_parts}) written on the body's self
7927
+ # and returns the running default after it:
7928
+ #
7929
+ # - no arguments — the call's visibility becomes the default for subsequent defs.
7930
+ # - `private :foo, :bar`, `private %i[foo bar]` — back-patch the named methods in the accumulator; the
7931
+ # default does NOT change for this form.
7932
+ # - Issues #1911, #1819, #1633 — `private def foo` (endless or not) and `private attr_reader :foo` define
7933
+ # 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
7938
+ # ({#visibility_toggle_arguments?}).
7939
+ # - any other argument (`private memoize def foo`, `private delegate(...)`) is walked with the default
7940
+ # uncertain, so a `def` under it is contested: the call's visibility reaches it through a return value
7941
+ # the walk does not read.
7942
+ #
7943
+ # `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)
7946
+ return name if args.empty?
7622
7947
 
7623
- # Recognises modifier calls on the implicit-self receiver inside a class body. Returns the (possibly updated)
7624
- # current visibility:
7625
- #
7626
- # - `private` / `public` / `protected` (no args) —
7627
- # switch the running default for subsequent defs.
7628
- # - `private :foo, :bar` — back-patch the named methods
7629
- # in the accumulator. Returns `current_visibility`
7630
- # unchanged because the running default does NOT
7631
- # change for this form.
7632
- def apply_visibility_call(call_node, qualified_prefix, current_visibility, accumulator)
7633
- return current_visibility unless call_node.receiver.nil?
7634
- return current_visibility unless VISIBILITY_MODIFIERS.include?(call_node.name)
7635
- return current_visibility if qualified_prefix.empty?
7948
+ class_name = qualified_prefix.join("::")
7949
+ accumulator.contest_class(class_name) if args.any?(Prism::SplatNode)
7950
+ args.each do |arg|
7951
+ apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator,
7952
+ defs_singleton: defs_singleton, &walk)
7953
+ end
7954
+ current_visibility
7955
+ end
7636
7956
 
7637
- args = call_node.arguments&.arguments || []
7638
- if args.empty?
7639
- call_node.name
7957
+ def apply_visibility_argument(arg, name, call_node, qualified_prefix, accumulator, defs_singleton:, &walk)
7958
+ case arg
7959
+ when Prism::DefNode
7960
+ if arg.receiver.nil?
7961
+ record_def_visibility(arg, qualified_prefix, defs_singleton, name, accumulator, toggled: false)
7962
+ else
7963
+ accumulator.contest_slot(qualified_prefix.join("::"), arg.name)
7964
+ end
7965
+ when Prism::SymbolNode, Prism::StringNode, Prism::ArrayNode
7966
+ apply_named_visibility(arg, name, qualified_prefix, call_node, accumulator, &walk)
7640
7967
  else
7641
- apply_named_visibility(args, qualified_prefix, call_node, accumulator)
7642
- current_visibility
7968
+ # An `instance_eval` body's wrapped attr is walked like any other argument and records nothing.
7969
+ if !defs_singleton && attr_visibility_call?(arg)
7970
+ record_attr_visibility(arg, qualified_prefix, name, accumulator, toggled: false)
7971
+ else
7972
+ accumulator.uncertainly { walk.call(arg) }
7973
+ end
7643
7974
  end
7644
7975
  end
7645
7976
 
7646
- def apply_named_visibility(args, qualified_prefix, call_node, accumulator)
7977
+ # `private :foo` / `private "foo"` / `private %i[foo bar]`: each literal name the call's visibility. An array
7978
+ # 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)
7980
+ elements = arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg]
7647
7981
  class_name = qualified_prefix.join("::")
7648
- args.each do |arg|
7649
- name = visibility_target_name(arg)
7650
- next if name.nil?
7982
+ elements.each do |element|
7983
+ name = visibility_target_name(element)
7984
+ next accumulator.uncertainly { walk.call(element) } if name.nil?
7651
7985
 
7652
7986
  accumulator[class_name] ||= {}
7653
- accumulator[class_name][name] = call_node.name
7987
+ accumulator[class_name][name] = visibility
7654
7988
  accumulator.wrote(class_name, name, call_node, toggled: false)
7655
7989
  end
7656
7990
  end
@@ -7659,6 +7993,32 @@ module Rigor
7659
7993
  Source::Literals.symbol_or_string(arg)
7660
7994
  end
7661
7995
 
7996
+ # Issue #1911 — an `attr_reader` / `attr_writer` / `attr_accessor` call written on the body's self records
7997
+ # each member it defines (`x`, `x=`) with `visibility`: the running default for a statement (`toggled`), the
7998
+ # wrapping call's for `private attr_accessor :x`, which Ruby applies to the `[:x, :x=]` the macro returns.
7999
+ # 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?
8003
+
8004
+ class_name = qualified_prefix.join("::")
8005
+ each_attr_macro_name(call_node) do |base|
8006
+ members = []
8007
+ members << base unless call_node.name == :attr_writer
8008
+ members << :"#{base}=" unless call_node.name == :attr_reader
8009
+ members.each do |member|
8010
+ accumulator[class_name] ||= {}
8011
+ accumulator[class_name][member] = visibility
8012
+ accumulator.wrote(class_name, member, call_node, toggled: toggled)
8013
+ end
8014
+ end
8015
+ end
8016
+
8017
+ # A receiverless `attr_reader` / `attr_writer` / `attr_accessor` call ({#record_attr_visibility}).
8018
+ def attr_visibility_call?(node)
8019
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && ATTR_MACROS.include?(node.name)
8020
+ end
8021
+
7662
8022
  # Registers the alias name in the `discovered_methods` table so `undefined-method` diagnostics are not emitted for
7663
8023
  # calls to the aliased name. The kind mirrors the surrounding class context (instance inside a regular class body,
7664
8024
  # singleton inside `class << self`).
@@ -7758,6 +8118,41 @@ module Rigor
7758
8118
  constant_receiver_candidates(receiver, qualified_prefix).each do |name|
7759
8119
  record_surface_mark(tables, name, Scope::DiscoveryIndex::ENVELOPE_DYNAMIC_MARK)
7760
8120
  end
8121
+ elsif singleton_class_of_receiver?(receiver) && singleton_class_named_call?(node)
8122
+ record_singleton_class_names(node, receiver.receiver, qualified_prefix, tables)
8123
+ end
8124
+ end
8125
+
8126
+ # Issue #1752 — the names a {#singleton_class_named_call?} rewrites on its singleton class's owner, each
8127
+ # singleton envelope opaque and contested: the evidence the refinement typer reads as a rewritten singleton name.
8128
+ def record_singleton_class_names(node, object, qualified_prefix, tables)
8129
+ current_class = qualified_prefix.empty? ? nil : qualified_prefix.join("::")
8130
+ owner = singleton_class_owner(node, object, qualified_prefix, current_class)
8131
+ return if owner.nil?
8132
+
8133
+ singleton_class_rewritten_names(node).each do |method_name|
8134
+ names = [method_name]
8135
+ names << :"#{method_name}=" if %i[attr_writer attr_accessor].include?(node.name)
8136
+ names.each do |name|
8137
+ record_envelope(tables.envelopes, owner, [:singleton, name], Source::ParameterEnvelope::OPAQUE)
8138
+ # Contested even where the call is certain: the slot's `def` is not the body Ruby runs, so the
8139
+ # own-definer read declines on the name rather than answering it.
8140
+ contest_envelope(tables, owner, [:singleton, name])
8141
+ end
8142
+ end
8143
+ end
8144
+
8145
+ # Issue #1752 — `def Const.x` whose receiver is not the lexical `self` (written at the top level or in another
8146
+ # class's body) defines `Const`'s singleton method, which no table files under `Const`: its singleton name's
8147
+ # envelope turns opaque, the evidence the refinement typer reads as a rewritten singleton name.
8148
+ def record_foreign_singleton_def(node, qualified_prefix, owner_prefix, tables)
8149
+ receiver = node.receiver
8150
+ return unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
8151
+ return if def_receiver_targets_lexical_self?(receiver, owner_prefix)
8152
+
8153
+ constant_receiver_candidates(receiver, qualified_prefix).each do |name|
8154
+ record_envelope(tables.envelopes, name, [:singleton, node.name], Source::ParameterEnvelope::OPAQUE)
8155
+ contest_envelope(tables, name, [:singleton, node.name]) unless tables.certain
7761
8156
  end
7762
8157
  end
7763
8158
 
@@ -8221,12 +8616,14 @@ module Rigor
8221
8616
  # {#discovered_project_index_incremental}'s changed-file branch) yields the live def nodes the signature
8222
8617
  # reads their parameter structure from. Issue #1120 — and each file's own refinement table, which the merged
8223
8618
  # def-index cannot attribute back to a file, so the session can diff it against the file's seed bundle.
8224
- # @return `{ def_index:, code_fingerprints:, declaration_signatures:, refinements: }`.
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: }`.
8225
8621
  def scan_summary_for_paths(paths, buffer: nil)
8226
8622
  acc = new_def_index_accumulator
8227
8623
  code_fingerprints = {}
8228
8624
  declaration_signatures = {}
8229
8625
  refinements = {}
8626
+ defined_method_names = {}
8230
8627
  paths.each do |path|
8231
8628
  physical = buffer ? buffer.resolve(path) : path
8232
8629
  source = File.read(physical)
@@ -8236,13 +8633,29 @@ module Rigor
8236
8633
  code_fingerprints[path] = code_fingerprint(source, parsed.comments)
8237
8634
  declaration_signatures[path] = declaration_signature(file_index)
8238
8635
  refinements[path] = file_index[:refinements] if file_index[:refinements]
8636
+ defined_method_names[path] = defined_method_names(file_index)
8239
8637
  rescue DeclarationWalk::ContractError
8240
8638
  raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
8241
8639
  rescue StandardError
8242
8640
  next
8243
8641
  end
8244
8642
  { def_index: finalize_def_index(acc), code_fingerprints: code_fingerprints,
8245
- declaration_signatures: declaration_signatures, refinements: refinements }
8643
+ declaration_signatures: declaration_signatures, refinements: refinements,
8644
+ defined_method_names: defined_method_names }
8645
+ end
8646
+
8647
+ # Issue #1459 — every method name one file's index defines, on any class or module and either side: the keys of
8648
+ # `methods` (which also carries an `attr_*`, `alias` or `define_method` name) and of the instance and singleton
8649
+ # def-node tables. A single file's `methods` already holds its `def` names, since `subtract_def_methods` runs only
8650
+ # on the merged accumulator; the def-node tables are read as well so the census reads exactly the tables
8651
+ # `BlockCallTiming.project_defines_anywhere?` reads. Takes a live file index or a seed bundle, which hold the
8652
+ # same three keys.
8653
+ def defined_method_names(file_index)
8654
+ names = Set.new
8655
+ %i[methods def_nodes singleton_def_nodes].each do |key|
8656
+ file_index[key]&.each_value { |table| names.merge(table.keys) }
8657
+ end
8658
+ names.freeze
8246
8659
  end
8247
8660
 
8248
8661
  # ADR-89 WD1 — a per-file digest of every cross-file DECLARATION surface an ancestry / file-level
@@ -8804,6 +9217,9 @@ module Rigor
8804
9217
  merge_header_nestings(acc[:header_nestings], file_index[:header_nestings] || {})
8805
9218
  fold_mixin_lists(acc, file_index)
8806
9219
  file_index[:class_sources].each { |cn, files| (acc[:class_sources][cn] ||= Set.new).merge(files) }
9220
+ (file_index[:singleton_class_sources] || {}).each do |cn, files|
9221
+ (acc[:singleton_class_sources][cn] ||= Set.new).merge(files)
9222
+ end
8807
9223
  # Issue #722 residue 2 — a pre-#722 seed bundle carries no candidates; the SCHEMA bump makes such a
8808
9224
  # blob a cold rebuild, but default so any in-flight fold stays total.
8809
9225
  acc[:compact_headers].merge!(file_index[:compact_headers] || {})
@@ -8892,6 +9308,7 @@ module Rigor
8892
9308
  # data, so the bundle stays Marshal-clean and a warm file joins the envelopes its cold walk records.
8893
9309
  parameter_envelopes: file_index[:parameter_envelopes],
8894
9310
  class_source_names: file_index[:class_sources].keys,
9311
+ singleton_class_source_names: (file_index[:singleton_class_sources] || {}).keys,
8895
9312
  # Issue #722 residue 2 — plain data, so the bundle stays Marshal-clean and a warm incremental file
8896
9313
  # re-anchors its compact headers the way a cold walk of it does.
8897
9314
  compact_headers: file_index[:compact_headers],
@@ -8916,7 +9333,7 @@ module Rigor
8916
9333
  # ADR-85 WD2 — reconstitutes a cached bundle into a single-file index {#fold_file_index} folds: the
8917
9334
  # def-node triples become {DefHandle}s bound to this file's `path`, and the class-source names become a
8918
9335
  # `{name => Set[path]}` table (the shape `accumulate_project_index` produces).
8919
- def bundle_to_file_index(bundle, path) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity -- one read per bundle slot
9336
+ def bundle_to_file_index(bundle, path) # rubocop:disable Metrics/AbcSize -- one read per bundle slot
8920
9337
  {
8921
9338
  def_nodes: bundle_defs_to_handles(bundle[:def_nodes], path),
8922
9339
  singleton_def_nodes: bundle_defs_to_handles(bundle[:singleton_def_nodes], path),
@@ -8937,7 +9354,8 @@ module Rigor
8937
9354
  method_visibilities: bundle[:method_visibilities],
8938
9355
  methods: bundle[:methods],
8939
9356
  parameter_envelopes: bundle[:parameter_envelopes] || {},
8940
- class_sources: bundle[:class_source_names].to_h { |name| [name, Set[path]] },
9357
+ class_sources: bundle_path_sets(bundle[:class_source_names], path),
9358
+ singleton_class_sources: bundle_path_sets(bundle[:singleton_class_source_names], path),
8941
9359
  compact_headers: bundle[:compact_headers] || {},
8942
9360
  # Issue #644 — a pre-#644 bundle carries no census; the SCHEMA bump makes such a blob a cold
8943
9361
  # rebuild, but default so any in-flight fold stays total.
@@ -8987,7 +9405,7 @@ module Rigor
8987
9405
  method_visibilities: {},
8988
9406
  methods: {},
8989
9407
  deferred_ranges: {},
8990
- parameter_envelopes: {}, class_sources: {},
9408
+ parameter_envelopes: {}, class_sources: {}, singleton_class_sources: {},
8991
9409
  # Issue #722 residue 2 — compact-header re-anchor candidates, adjudicated in {#finalize_def_index}.
8992
9410
  compact_headers: {},
8993
9411
  constant_writes: {},
@@ -10454,8 +10872,15 @@ module Rigor
10454
10872
 
10455
10873
  # Issue #1717 — a `def` / `class` / `module` body opens its own `self`, so an enclosing unentered block's
10456
10874
  # unknown-`self` mark ({Scope#block_self_unknown?}) stops at it, as the evaluator's fresh body scope does.
10457
- # Every other node hands its children the scope as is.
10875
+ # A `case` narrows its clauses ({#propagate_subjectless_case}, {#propagate_subject_case}). Every other node
10876
+ # hands its children the scope as is.
10458
10877
  def propagate_children(node, table, current_scope)
10878
+ if node.is_a?(Prism::CaseNode) || node.is_a?(Prism::CaseMatchNode)
10879
+ return propagate_subjectless_case(node, table, current_scope) if node.predicate.nil?
10880
+
10881
+ return propagate_subject_case(node, table, current_scope)
10882
+ end
10883
+
10459
10884
  child_scope =
10460
10885
  SELF_OPENING_BODIES.include?(node.class) ? current_scope.with_block_self_unknown(false) : current_scope
10461
10886
  node.rigor_each_child { |child| propagate(child, table, child_scope) }
@@ -10661,6 +11086,85 @@ module Rigor
10661
11086
  propagate(node.statements, table, falsey_scope) if node.statements
10662
11087
  propagate(node.else_clause, table, truthy_scope) if node.else_clause
10663
11088
  end
11089
+
11090
+ # Issue #1495 — a `case` without a subject the evaluator did not enter (`[case; when x.nil? then 0; else x.size
11091
+ # end]`) narrows by its conditions' edges, as {StatementEvaluator#subjectless_when_scopes} does: each condition
11092
+ # under the falsey edges of the conditions before it, each clause under the join of its conditions' truthy edges,
11093
+ # and the `else` under the last falsey edge. Like the walk of an `if`, it applies no write a condition makes, so
11094
+ # the edges are taken from a scope where every bound local the condition writes reads `Dynamic[top]`
11095
+ # ({#widen_condition_writes}): neither the local's old type nor an earlier condition's narrowing of it reaches
11096
+ # them, as `when (x = s ? String(s) : nil) then x.upcase` after `x = 1` would.
11097
+ def propagate_subjectless_case(node, table, current_scope)
11098
+ falsey = current_scope
11099
+ node.conditions.each do |when_node|
11100
+ truthy_edges = when_node.conditions.map do |condition|
11101
+ propagate(condition, table, falsey)
11102
+ truthy, falsey = Narrowing.predicate_scopes(condition, widen_condition_writes(condition, falsey))
11103
+ truthy
11104
+ end
11105
+ propagate(when_node, table, truthy_edges.reduce(:join))
11106
+ end
11107
+ propagate(node.else_clause, table, falsey) if node.else_clause
11108
+ end
11109
+
11110
+ # Issue #1484 — a `case` with a subject the evaluator did not enter (`[case io when StringIO then io.string
11111
+ # end]`, `[case io in StringIO then io.string else nil end]`) narrows each clause as the evaluator does
11112
+ # ({Narrowing.case_clause_scopes}): the body under the clause's narrowed edge, the next clause and the `else`
11113
+ # under its falsey edge. The walk applies no write the subject, a `when` condition or an `in` pattern makes, so
11114
+ # every bound local one of them writes reads `Dynamic[top]` from that point on ({#widen_condition_writes}).
11115
+ # Nor does it run the evaluator's per-clause `LastLine.forget_if_set`: the statement holding the `case`
11116
+ # already forgets a `$_` any of its tests may set. A clause's tests are recorded under the scope the earlier
11117
+ # clauses' falsey edges left, where the evaluator's `when` tests read the post-subject scope: a test reading
11118
+ # the subject sees what the earlier clauses ruled out (`when NilClass then 0 when v.succ`).
11119
+ #
11120
+ # A test that calls a method may rebind an instance-variable or global subject before the body reads it
11121
+ # (`when String then 1 when reset! then @x.upcase`, where `reset!` writes `@x`), and the walk cannot tell. From
11122
+ # 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.
11124
+ def propagate_subject_case(node, table, current_scope)
11125
+ subject = node.predicate
11126
+ propagate(subject, table, current_scope)
11127
+ falsey = widen_condition_writes(subject, current_scope)
11128
+ called = false
11129
+ node.conditions.each do |clause|
11130
+ tests = clause.is_a?(Prism::WhenNode) ? clause.conditions : [clause.pattern]
11131
+ tests.each { |test| propagate(test, table, falsey) }
11132
+ falsey = tests.reduce(falsey) { |acc, test| widen_condition_writes(test, acc) }
11133
+ called ||= tests.any? { |test| runs_call?(test) }
11134
+ body, falsey = Narrowing.case_clause_scopes(subject, clause, falsey)
11135
+ body, falsey = [body, falsey].map { |scope| forget_called_subject(subject, scope) } if called
11136
+ propagate(clause, table, body)
11137
+ end
11138
+ propagate(node.else_clause, table, falsey) if node.else_clause
11139
+ end
11140
+
11141
+ CODE_RUNNING_NODES = [Prism::CallNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::YieldNode].freeze
11142
+ private_constant :CODE_RUNNING_NODES
11143
+
11144
+ # Whether `node` holds a method call, `super` or `yield` anywhere, a lambda body's included.
11145
+ def runs_call?(node)
11146
+ return false unless node.is_a?(Prism::Node)
11147
+ return true if CODE_RUNNING_NODES.include?(node.class)
11148
+
11149
+ node.rigor_each_child { |child| return true if runs_call?(child) }
11150
+ false
11151
+ end
11152
+
11153
+ # `scope` with an instance-variable or global `subject` rebound to `Dynamic[top]`; any other subject as is.
11154
+ def forget_called_subject(subject, scope)
11155
+ case subject
11156
+ when Prism::InstanceVariableReadNode then scope.with_ivar(subject.name, Type::Combinator.untyped)
11157
+ when Prism::GlobalVariableReadNode then scope.with_global(subject.name, Type::Combinator.untyped)
11158
+ else scope
11159
+ end
11160
+ end
11161
+
11162
+ # `scope` with every bound local `condition` writes rebound to `Dynamic[top]`.
11163
+ def widen_condition_writes(condition, scope)
11164
+ OperandEffects.written_variables(condition).reduce(scope) do |acc, name|
11165
+ acc.local(name) ? acc.with_local(name, Type::Combinator.untyped) : acc
11166
+ end
11167
+ end
10664
11168
  end
10665
11169
  end
10666
11170
  end