rigortype 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -8,6 +8,7 @@ require_relative "../project_environment"
8
8
  require_relative "../scope"
9
9
  require_relative "../reflection"
10
10
  require_relative "../type"
11
+ require_relative "../inference/reflective_ivar_write"
11
12
  require_relative "../source/constant_path"
12
13
  require_relative "../source/literals"
13
14
  require_relative "../source/node_children"
@@ -25,6 +26,7 @@ require_relative "inline_declarations"
25
26
  require_relative "layout_index"
26
27
  require_relative "method_candidate"
27
28
  require_relative "writer"
29
+ require_relative "../rooted_glob"
28
30
 
29
31
  module Rigor
30
32
  module SigGen
@@ -98,6 +100,7 @@ module Rigor
98
100
  # (`build_candidate`, `method_def_prefix`) a never-nil invariant without per-call-site defensive guards.
99
101
  @namespace_kinds = {}
100
102
  @module_function_methods = Set.new
103
+ @uncertain_module_function_defs = Set.new.compare_by_identity
101
104
  @class_shells = Set.new
102
105
  @class_superclasses = {}
103
106
  @meta_layouts = {}
@@ -446,7 +449,7 @@ module Rigor
446
449
  def resolve_paths(args)
447
450
  args.flat_map do |arg|
448
451
  if File.directory?(arg)
449
- Dir.glob(File.join(arg, "**/*.rb"), sort: true)
452
+ RootedGlob.glob(arg, "**/*.rb")
450
453
  elsif File.file?(arg) && arg.end_with?(".rb")
451
454
  [arg]
452
455
  else
@@ -463,10 +466,7 @@ module Rigor
463
466
  base_scope = Scope.empty(environment: environment)
464
467
  scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
465
468
 
466
- @namespace_kinds = {}
467
- @module_function_methods = Set.new
468
- @class_shells = Set.new
469
- @class_superclasses = {}
469
+ reset_file_maps
470
470
  @meta_layouts = collect_meta_layouts(scope_index)
471
471
  register_meta_classes
472
472
  defs = collect_method_definitions(parse_result.value, scope_index)
@@ -488,6 +488,15 @@ module Rigor
488
488
  collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
489
489
  end
490
490
 
491
+ # The per-file scratch state {#analyse_file} starts each file with.
492
+ def reset_file_maps
493
+ @namespace_kinds = {}
494
+ @module_function_methods = Set.new
495
+ @uncertain_module_function_defs = Set.new.compare_by_identity
496
+ @class_shells = Set.new
497
+ @class_superclasses = {}
498
+ end
499
+
491
500
  # Walks the AST collecting `(def_node, class_name, kind)` tuples for every `def` Rigor can re-type. Slice 1
492
501
  # covered instance `def foo` methods inside a nameable `class` / `module` body. Slice 4 extends this to
493
502
  # singleton-side methods via `def self.foo` and `class << self; def foo; end`. A plain top-level `def` is
@@ -732,6 +741,8 @@ module Rigor
732
741
  class_name = prefix.join("::")
733
742
  if Inference::ModuleFunctionState.sig_gen_module_function?(module_function_active, kind)
734
743
  @module_function_methods << [class_name, node.name]
744
+ elsif Inference::ModuleFunctionState.sig_gen_uncertain?(module_function_active, kind)
745
+ @uncertain_module_function_defs << node
735
746
  end
736
747
  out << [node, class_name, kind]
737
748
  end
@@ -783,9 +794,13 @@ module Rigor
783
794
  # private methods are implementation details, not part of the type contract downstream consumers (Steep,
784
795
  # IDE, gem users) read. The default now skips private and protected methods; the `:include_private` flag
785
796
  # restores the slice-4 behaviour for callers that want every method.
797
+ #
798
+ # A module function renders as `def self?.name`, which RBS reads as a public singleton method and a private
799
+ # instance method, so a `private` written before the `module_function` toggle does not exclude it (#1968).
786
800
  def visibility_excludes?(def_node, class_name, kind, scope_index)
787
801
  return false if kind == :singleton
788
802
  return false if @include_private
803
+ return false if @module_function_methods.include?([class_name, def_node.name])
789
804
 
790
805
  scope = scope_index[def_node] || scope_index.each_value.first
791
806
  return false if scope.nil?
@@ -960,6 +975,9 @@ module Rigor
960
975
  # #1676 — a top-level `def` is collected with no class; it is reported, never typed.
961
976
  def classify_collected_def(path, def_node, class_name, kind, scope_index)
962
977
  return skipped(path, def_node, class_name, kind, :top_level_def) if class_name.nil?
978
+ if @uncertain_module_function_defs.include?(def_node)
979
+ return skipped(path, def_node, class_name, kind, :uncertain_module_function)
980
+ end
963
981
 
964
982
  classify_def(path, def_node, class_name, kind, scope_index)
965
983
  end
@@ -1421,12 +1439,17 @@ module Rigor
1421
1439
  # same position a method with no declaration at all is in. Over a declaration the author wrote, though, a
1422
1440
  # VALUE literal the body proves (`-> 1`, `-> "x"`, `-> :k`) is an implementation detail the `untyped` was
1423
1441
  # abstracting over, so it is widened to its class (`1 | 2` to `Integer`, `"S" | nil` to `(String | nil)`; #1489).
1424
- # `nil`, `true` and `false` are honest types and stay, as does a computed `Float`. An `untyped` that is only
1425
- # the inline reader's synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the
1426
- # strictest carrier. `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag
1427
- # says a declaration existed.
1442
+ # `true` and `false` widen to `bool` (#1891): a predicate's base `-> true` made every subclass override
1443
+ # returning `false` a `def.override-return-widened`. A return that is only `nil` has no such widening — it is
1444
+ # the shape of a template method a subclass fills in — so it is declined and the `untyped` stays. `nil` in a
1445
+ # union (`T?`) and a computed `Float` are honest types and stay. An `untyped` that is only the inline reader's
1446
+ # synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the strictest carrier.
1447
+ # `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag says a declaration
1448
+ # existed.
1428
1449
  def declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: nil)
1429
1450
  unless synthesized_declaration?(method_def)
1451
+ return equivalent(path, def_node, class_name, kind, inferred, "untyped") if bare_nil?(inferred)
1452
+
1430
1453
  widened = widen_value_literals(inferred)
1431
1454
  unless widened.equal?(inferred)
1432
1455
  # A `rendered` line was spelled from the unwidened type; there is nothing to rebuild it from.
@@ -1446,6 +1469,7 @@ module Rigor
1446
1469
  def widen_value_literals(type)
1447
1470
  case type
1448
1471
  when Type::Constant
1472
+ return widen_boolean_literal if [true, false].include?(type.value)
1449
1473
  return type unless VALUE_LITERAL_CLASSES.any? { |klass| type.value.instance_of?(klass) }
1450
1474
 
1451
1475
  Type::Combinator.nominal_of(type.value.class.name)
@@ -1457,6 +1481,14 @@ module Rigor
1457
1481
  end
1458
1482
  end
1459
1483
 
1484
+ def widen_boolean_literal
1485
+ Type::Combinator.union(Type::Combinator.constant_of(true), Type::Combinator.constant_of(false))
1486
+ end
1487
+
1488
+ def bare_nil?(type)
1489
+ type.is_a?(Type::Constant) && type.value.nil?
1490
+ end
1491
+
1460
1492
  # A declaration only the inline reader's synthesis (a `virtual:` buffer) supplies — ADR-93 gives every `def` an
1461
1493
  # `untyped` member, annotated or not — is no word of the author's about the return, so it keeps the proposal
1462
1494
  # a method with no declaration at all gets: the strictest carrier the body proves.
@@ -1590,11 +1622,13 @@ module Rigor
1590
1622
  end
1591
1623
 
1592
1624
  # `Type::Constant#erase_to_rbs` spells the values RBS writes as literal types (`true` / `false` /
1593
- # `nil`, an Integer, a Symbol, a String) as that literal and every other carrier as its value's class
1594
- # name. Asking which of the two branches it took keeps the list of literal-spellable classes from
1595
- # forking a second copy into this file.
1625
+ # `nil`, an Integer, a Symbol, a String) as that literal and every other carrier as a nominal. Asking
1626
+ # which of the two branches it took keeps the list of literal-spellable classes from forking a second
1627
+ # copy into this file. A Range's nominal carries its element type (`Range[Integer]`, #1891), so it is
1628
+ # excluded by class rather than by comparing the erasure with the class name: like a Float it is a
1629
+ # nominal, not a pinned literal.
1596
1630
  def rbs_literal?(type)
1597
- type.is_a?(Type::Constant) && type.erase_to_rbs != type.value.class.name
1631
+ type.is_a?(Type::Constant) && !type.value.is_a?(Range) && type.erase_to_rbs != type.value.class.name
1598
1632
  end
1599
1633
 
1600
1634
  # Heuristic added after the third-round self-dogfood: `FallbackTracer#size` body is `@events.size`, where
@@ -1837,7 +1871,8 @@ module Rigor
1837
1871
  # pre-pass resolved the type to `nil` or `Dynamic[top]` — typically because `@ivar = param` inside
1838
1872
  # `initialize` typed the param as `untyped` — the observation-derived type is substituted. This lets
1839
1873
  # `attr_reader :name` emit a concrete type when `ClassName.new("alice")` call sites are visible to the
1840
- # observation scan.
1874
+ # observation scan. {#build_observed_ivar_map} leaves out an ivar anything else writes, so a writer-backed
1875
+ # field is skipped (issue #1410).
1841
1876
  def ivar_type_lookup(scope_index, class_name, obs_ivar_map = {})
1842
1877
  any_scope = scope_index.each_value.first
1843
1878
  return ->(_) {} if any_scope.nil?
@@ -1854,12 +1889,107 @@ module Rigor
1854
1889
  # ivars assigned directly from `def initialize` parameters. Only populated when `@observations` is
1855
1890
  # non-empty (i.e. `--params=observed` was supplied). Matches the pattern `@ivar_name = param_name` where
1856
1891
  # `param_name` is a required / optional positional or keyword parameter of `initialize`.
1892
+ #
1893
+ # Issue #1410: the observed arguments describe the field only while those `@ivar = param_name` writes are
1894
+ # all that writes it. An `attr_writer` / `attr_accessor`, a write in another instance method or block, or
1895
+ # any other write in `initialize` lets the field hold values the observation never saw, so such an ivar is
1896
+ # left out and its accessor is skipped as `untyped_return`, as a hand-written setter is.
1857
1897
  def build_observed_ivar_map(root)
1858
1898
  return {} if @observations.empty?
1859
1899
 
1860
1900
  result = {}
1861
1901
  collect_init_ivar_obs(root, [], result)
1862
- result
1902
+ other_writes = Hash.new { |h, k| h[k] = Set.new }
1903
+ collect_other_ivar_writes(root, [], false, other_writes)
1904
+ result.to_h do |class_name, map|
1905
+ others = other_writes[class_name]
1906
+ [class_name, others.include?(ANY_IVAR) ? {} : map.reject { |attr_name, _| others.include?(:"@#{attr_name}") }]
1907
+ end
1908
+ end
1909
+
1910
+ IVAR_WRITE_NODES = [
1911
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
1912
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
1913
+ ].freeze
1914
+ ANY_IVAR = :*
1915
+ private_constant :IVAR_WRITE_NODES, :ANY_IVAR
1916
+
1917
+ # Records into `other_writes[class_name]` every instance ivar a class writes other than through an
1918
+ # `initialize`'s `@ivar = param_name`: the ivars an implicit-self `attr_writer` / `attr_accessor` names,
1919
+ # and every write inside an instance `def` or a block, an `instance_variable_set` on `self` included
1920
+ # ({#written_ivar}). `def self.x`, a `class << self` body and a bare class-body write set the class
1921
+ # object's ivars and do not count.
1922
+ def collect_other_ivar_writes(node, prefix, in_block, other_writes)
1923
+ return unless node.is_a?(Prism::Node)
1924
+
1925
+ case node
1926
+ when Prism::ClassNode, Prism::ModuleNode
1927
+ child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path)
1928
+ return collect_other_ivar_writes(node.body, child_prefix, false, other_writes) if child_prefix
1929
+ when Prism::SingletonClassNode then return
1930
+ when Prism::DefNode then return record_def_ivar_writes(node, prefix, other_writes)
1931
+ when Prism::BlockNode, Prism::LambdaNode then in_block = true
1932
+ end
1933
+ record_class_level_write(node, prefix, in_block, other_writes) unless prefix.empty?
1934
+
1935
+ node.rigor_each_child { |child| collect_other_ivar_writes(child, prefix, in_block, other_writes) }
1936
+ end
1937
+
1938
+ # The ivars an `attr_writer` / `attr_accessor` on `self` names, or the ivar a write in a block sets.
1939
+ def record_class_level_write(node, prefix, in_block, other_writes)
1940
+ writes = other_writes[prefix.join("::")]
1941
+ attr_writer_names(node).each { |name| writes << :"@#{name}" }
1942
+ name = written_ivar(node) if in_block
1943
+ writes << name if name
1944
+ end
1945
+
1946
+ ATTR_WRITER_DECLARATIONS = %i[attr_writer attr_accessor].freeze
1947
+ SEND_NAMES = %i[send public_send __send__].freeze
1948
+ private_constant :ATTR_WRITER_DECLARATIONS, :SEND_NAMES
1949
+
1950
+ # The literal names `attr_writer :x` / `attr_accessor :x` declares when called on `self`, implicitly or as
1951
+ # `self.`, or through `send` / `public_send` / `__send__` with the declaration's name as a literal first
1952
+ # argument (`send(:attr_writer, :x)`). Any other call declares none this check sees.
1953
+ def attr_writer_names(node)
1954
+ return [] unless node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
1955
+
1956
+ names = Source::Literals.symbol_arguments(node)
1957
+ return names if ATTR_WRITER_DECLARATIONS.include?(node.name)
1958
+ return [] unless SEND_NAMES.include?(node.name)
1959
+
1960
+ ATTR_WRITER_DECLARATIONS.include?(Source::Literals.symbol_arg(node, 0)) ? names.drop(1) : []
1961
+ end
1962
+
1963
+ # Every ivar write in an instance `def` counts, except, in `initialize`, the `@ivar = param_name` write
1964
+ # {#scan_ivar_param_assignments} keys the observation on.
1965
+ def record_def_ivar_writes(def_node, prefix, other_writes)
1966
+ return if def_node.body.nil? || def_node.receiver || prefix.empty?
1967
+
1968
+ credited = Set.new.compare_by_identity
1969
+ if def_node.name == :initialize && def_node.parameters
1970
+ params = build_init_param_index(def_node.parameters).keys.to_set
1971
+ scan_ivar_param_assignments(def_node.body, params, {}, credited)
1972
+ end
1973
+ each_node(def_node.body) do |node|
1974
+ name = written_ivar(node)
1975
+ other_writes[prefix.join("::")] << name if name && !credited.include?(node)
1976
+ end
1977
+ end
1978
+
1979
+ # The ivar `node` writes: a write node's name, an `instance_variable_set` on `self`'s literal name, or
1980
+ # `ANY_IVAR` for one whose name the analysis cannot read ({Inference::ReflectiveIvarWrite}).
1981
+ def written_ivar(node)
1982
+ return node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
1983
+ return nil unless Inference::ReflectiveIvarWrite.self_write?(node)
1984
+
1985
+ Inference::ReflectiveIvarWrite.literal_name(node) || ANY_IVAR
1986
+ end
1987
+
1988
+ def each_node(node, &)
1989
+ return unless node.is_a?(Prism::Node)
1990
+
1991
+ yield node
1992
+ node.rigor_each_child { |child| each_node(child, &) }
1863
1993
  end
1864
1994
 
1865
1995
  def collect_init_ivar_obs(node, prefix, result)
@@ -1945,21 +2075,36 @@ module Rigor
1945
2075
 
1946
2076
  # Walk a def body for direct `@ivar = local_var` assignments where `local_var` is one of the listed
1947
2077
  # parameter names. Records ivar_name (Symbol with `@` prefix) → param_name. Does not recurse into nested
1948
- # defs / classes / modules.
1949
- def scan_ivar_param_assignments(node, param_names, result)
2078
+ # defs / classes / modules. A read counts only when it reaches the method's own parameter: its
2079
+ # `LocalVariableReadNode#depth` equals the number of blocks / lambdas between it and the method, so a
2080
+ # block parameter of the same name (`each { |count| @count = count }`) is not the constructor argument
2081
+ # (#1410). `credited`, when given, collects every write node the result is keyed on.
2082
+ def scan_ivar_param_assignments(node, param_names, result, credited = nil, scopes = 0)
1950
2083
  return unless node.is_a?(Prism::Node)
1951
2084
 
1952
- if node.is_a?(Prism::InstanceVariableWriteNode) &&
1953
- node.value.is_a?(Prism::LocalVariableReadNode) &&
1954
- param_names.include?(node.value.name)
1955
- result[node.name] ||= node.value.name
2085
+ ivar, param = param_assignment(node, param_names, scopes)
2086
+ if ivar
2087
+ result[ivar] ||= param
2088
+ credited << node if credited && result[ivar] == param
1956
2089
  end
1957
2090
 
1958
2091
  return if node.is_a?(Prism::DefNode) ||
1959
2092
  node.is_a?(Prism::ClassNode) ||
1960
2093
  node.is_a?(Prism::ModuleNode)
1961
2094
 
1962
- node.rigor_each_child { |c| scan_ivar_param_assignments(c, param_names, result) }
2095
+ scopes += 1 if node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
2096
+ node.rigor_each_child { |c| scan_ivar_param_assignments(c, param_names, result, credited, scopes) }
2097
+ end
2098
+
2099
+ # `[ivar, param]` for `@ivar = param` whose read reaches the method's own parameter `param`, else nil.
2100
+ def param_assignment(node, param_names, scopes)
2101
+ return nil unless node.is_a?(Prism::InstanceVariableWriteNode)
2102
+
2103
+ read = node.value
2104
+ return nil unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == scopes
2105
+ return nil unless param_names.include?(read.name)
2106
+
2107
+ [node.name, read.name]
1963
2108
  end
1964
2109
 
1965
2110
  def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbs"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  module SigGen
@@ -20,7 +21,28 @@ module Rigor
20
21
  # First-found wins on duplicate declarations across files; RBS itself allows the same class to be declared
21
22
  # in multiple files for additive member contributions, but the writer only needs one canonical target per
22
23
  # class.
24
+ #
25
+ # ADR-122 WD2 adds the member level: `(class_name, method_name, kind) → sig_file_path`, so a member the project
26
+ # already declares in one file is updated there rather than inserted a second time into the class's
27
+ # first-found file (#1488).
23
28
  class LayoutIndex
29
+ # The `[method_name, kind]` keys a member declaration answers for, `kind` being `:instance` or
30
+ # `:singleton`. A `def self?.f` answers for both kinds; an `attr_accessor` answers for its reader and its
31
+ # writer; an `alias new old` (`alias self.new self.old`) answers for its new name only, under its own kind
32
+ # (#1975). Shared with {Writer}, which asks the same question of the target file it is merging into.
33
+ def self.member_keys(member)
34
+ case member
35
+ when RBS::AST::Members::MethodDefinition
36
+ kinds = member.kind == :singleton_instance ? %i[instance singleton] : [member.kind]
37
+ kinds.map { |kind| [member.name, kind] }
38
+ when RBS::AST::Members::Alias then [[member.new_name, member.kind]]
39
+ when RBS::AST::Members::AttrReader then [[member.name, member.kind]]
40
+ when RBS::AST::Members::AttrWriter then [[:"#{member.name}=", member.kind]]
41
+ when RBS::AST::Members::AttrAccessor then [[member.name, member.kind], [:"#{member.name}=", member.kind]]
42
+ else []
43
+ end
44
+ end
45
+
24
46
  # @param signature_paths —
25
47
  # the `.rigor.yml`-configured signature directories.
26
48
  # When `nil` or empty, falls back to `<project_root>/sig`
@@ -29,6 +51,7 @@ module Rigor
29
51
  def initialize(signature_paths:, project_root: Dir.pwd)
30
52
  @signature_paths = resolve_paths(signature_paths, project_root)
31
53
  @index = nil
54
+ @members = nil
32
55
  end
33
56
 
34
57
  # @param class_name — fully-qualified Ruby class
@@ -40,6 +63,13 @@ module Rigor
40
63
  index[class_name]
41
64
  end
42
65
 
66
+ # @return absolute path of the first sig file (in the same order as {#file_for}) that declares
67
+ # `method_name` of `kind` on `class_name`, or `nil` when no file does.
68
+ def member_file_for(class_name, method_name, kind)
69
+ index
70
+ @members[[class_name, method_name, kind]]
71
+ end
72
+
43
73
  def empty?
44
74
  index.empty?
45
75
  end
@@ -60,14 +90,16 @@ module Rigor
60
90
 
61
91
  def build_index
62
92
  accumulator = {}
93
+ @members = {}
63
94
  Array(@signature_paths).each do |dir|
64
95
  base = Pathname(dir)
65
96
  next unless base.directory?
66
97
 
67
- Dir.glob(File.join(base.to_s, "**/*.rbs"), sort: true).each do |rbs_file|
98
+ RootedGlob.glob(base, "**/*.rbs").each do |rbs_file|
68
99
  index_file(Pathname(rbs_file), accumulator)
69
100
  end
70
101
  end
102
+ @members.freeze
71
103
  accumulator.freeze
72
104
  end
73
105
 
@@ -97,6 +129,13 @@ module Rigor
97
129
  local_name = decl.name.to_s.sub(/\A::/, "")
98
130
  full = prefix.empty? ? local_name : "#{prefix.join('::')}::#{local_name}"
99
131
  accumulator[full] ||= rbs_path
132
+ decl.members.each do |member|
133
+ # An `overloading?` member (`def f: ... | ...`) adds to a declaration elsewhere; routing a candidate to
134
+ # it would replace the user's overload.
135
+ next if member.is_a?(RBS::AST::Members::MethodDefinition) && member.overloading?
136
+
137
+ self.class.member_keys(member).each { |name, kind| @members[[full, name, kind]] ||= rbs_path }
138
+ end
100
139
 
101
140
  record_decls(decl.members, prefix + [local_name], rbs_path, accumulator)
102
141
  end
@@ -68,6 +68,14 @@ module Rigor
68
68
  @declared_annotations + @annotations + [@rbs].compact
69
69
  end
70
70
 
71
+ # The RBS member kind `rbs` declares: `:singleton_instance` for a `def self?.` line (a `module_function`
72
+ # method, whose `kind` stays `:instance`), `kind` otherwise. The writer replaces only a member of this
73
+ # kind, so an overwrite neither drops a `self?.` member's singleton half nor adds one beside a split
74
+ # `def self.f` / `def f` pair (#1488).
75
+ def rbs_kind
76
+ kind == :instance && rbs.to_s.start_with?("def self?.") ? :singleton_instance : kind
77
+ end
78
+
71
79
  # A copy carrying a different annotation decision. The generator's effect pass rebuilds rather than
72
80
  # mutates, because a candidate is frozen the moment it is built.
73
81
  def with_effect_annotation(annotations, reason)
@@ -10,6 +10,7 @@ require_relative "../source/constant_path"
10
10
  require_relative "../source/literals"
11
11
  require_relative "../source/node_children"
12
12
  require_relative "../inference/scope_indexer"
13
+ require_relative "../rooted_glob"
13
14
 
14
15
  module Rigor
15
16
  module SigGen
@@ -32,6 +33,7 @@ module Rigor
32
33
  # `let` / `subject` bindings ride on slice 5's optional `rigor-rspec` integration.
33
34
  # - Calls whose receiver does not type as a `Type::Nominal` (e.g. `(some_dynamic).bar(...)`) are skipped —
34
35
  # the collector cannot attribute them to a specific class.
36
+ # - A `T | nil` receiver with one `Nominal` / `Singleton` non-nil member counts as that member (#1389).
35
37
  # - Zero-argument calls give no observation; methods are matched by `(class_name, method_name)` only.
36
38
  class ObservationCollector # rubocop:disable Metrics/ClassLength
37
39
  # @param paths — observe paths (files /
@@ -62,6 +64,12 @@ module Rigor
62
64
  observations.transform_values(&:freeze).freeze
63
65
  end
64
66
 
67
+ # Whether `arg` names something {#collect} reads: a directory, or a `.rb` file. The CLI's
68
+ # `observation` report splits roots on this, so "observed" means what the collector does (#1398).
69
+ def self.readable_root?(arg)
70
+ File.directory?(arg) || (File.file?(arg) && arg.end_with?(".rb"))
71
+ end
72
+
65
73
  private
66
74
 
67
75
  # Issue #821 — same universe as the generator and as `rigor check`; see
@@ -74,8 +82,8 @@ module Rigor
74
82
  def resolve_paths(args)
75
83
  args.flat_map do |arg|
76
84
  if File.directory?(arg)
77
- Dir.glob(File.join(arg, "**/*.rb"), sort: true)
78
- elsif File.file?(arg) && arg.end_with?(".rb")
85
+ RootedGlob.glob(arg, "**/*.rb")
86
+ elsif self.class.readable_root?(arg)
79
87
  [arg]
80
88
  else
81
89
  []
@@ -92,8 +100,9 @@ module Rigor
92
100
  base_scope = base_scope.with_discovery(base_scope.discovery.with(discovered_classes: discovered_classes))
93
101
  scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
94
102
  bindings = collect_rspec_bindings(parse_result.value, scope_index)
103
+ @before_ivars = collect_before_ivars(parse_result.value, scope_index)
95
104
 
96
- walk_calls(parse_result.value, scope_index, bindings, observations)
105
+ walk_calls(parse_result.value, scope_index, bindings, observations, [])
97
106
  end
98
107
 
99
108
  # Pre-walks `@source_paths` to collect every qualified class / module declaration. The result seeds the
@@ -145,22 +154,64 @@ module Rigor
145
154
  end
146
155
  end
147
156
 
148
- def walk_calls(node, scope_index, bindings, observations)
157
+ # `blocks` is the chain of enclosing RSpec group / example / hook blocks, innermost last — the blocks a
158
+ # `before` ivar can reach the call from ({#before_ivar_type}). See {#enclosing_blocks} for where it is cut.
159
+ def walk_calls(node, scope_index, bindings, observations, blocks)
149
160
  return unless node.is_a?(Prism::Node)
150
161
 
151
- record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
152
- node.rigor_each_child { |child| walk_calls(child, scope_index, bindings, observations) }
162
+ record_call(node, scope_index, bindings, observations, blocks) if node.is_a?(Prism::CallNode)
163
+ node.rigor_each_child do |child|
164
+ walk_calls(child, scope_index, bindings, observations, enclosing_blocks(node, child, blocks))
165
+ end
166
+ end
167
+
168
+ # Group, example and hook calls whose block runs with the example's ivars. `RSpec.` may prefix a group call.
169
+ RSPEC_GROUP_CALLS = %i[
170
+ describe context feature shared_examples shared_examples_for shared_context
171
+ ].freeze
172
+ RSPEC_EXAMPLE_OR_HOOK_CALLS = %i[
173
+ it specify example scenario its before prepend_before append_before
174
+ ].freeze
175
+ private_constant :RSPEC_GROUP_CALLS, :RSPEC_EXAMPLE_OR_HOOK_CALLS
176
+
177
+ # The chain `child` of `parent` sees. A group / example / hook call's block extends it; every other block or
178
+ # lambda (`Class.new { … }`, `instance_eval { … }`, `define_method(:m) { … }`, `each { … }`), a class / module /
179
+ # singleton-class body, and a `def` that is not a direct statement of a group block cut it, since their `self`
180
+ # may be another object.
181
+ def enclosing_blocks(parent, child, blocks)
182
+ case child
183
+ when Prism::BlockNode then rspec_scope_call?(parent) ? [*blocks, child] : []
184
+ when Prism::LambdaNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then []
185
+ when Prism::DefNode then group_statement?(child, blocks.last) ? blocks : []
186
+ else blocks
187
+ end
188
+ end
189
+
190
+ def rspec_scope_call?(node)
191
+ return false unless node.is_a?(Prism::CallNode)
192
+ return rspec_group_receiver?(node.receiver) if RSPEC_GROUP_CALLS.include?(node.name)
193
+
194
+ node.receiver.nil? && RSPEC_EXAMPLE_OR_HOOK_CALLS.include?(node.name)
153
195
  end
154
196
 
155
- def record_call(call_node, scope_index, bindings, observations)
197
+ def rspec_group_receiver?(receiver)
198
+ receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec)
199
+ end
200
+
201
+ def group_statement?(node, block)
202
+ body = block&.body
203
+ body.is_a?(Prism::StatementsNode) && body.body.any? { |statement| statement.equal?(node) }
204
+ end
205
+
206
+ def record_call(call_node, scope_index, bindings, observations, blocks)
156
207
  receiver = call_node.receiver
157
208
  return if receiver.nil?
158
209
 
159
210
  scope = scope_index[call_node] || scope_index[receiver]
160
211
  return if scope.nil?
161
212
 
162
- receiver_type = resolve_receiver_type(receiver, scope, bindings)
163
- key = observation_key(call_node, receiver_type)
213
+ receiver_type = resolve_receiver_type(receiver, scope, bindings, blocks)
214
+ key = observation_key(call_node, observed_receiver(receiver_type, call_node, scope))
164
215
  return if key.nil?
165
216
 
166
217
  observation = collect_args(call_node, scope)
@@ -181,6 +232,36 @@ module Rigor
181
232
  end
182
233
  end
183
234
 
235
+ # Issue #1389 — a receiver typed `T | nil` whose one non-nil member is a `Nominal` or `Singleton` is observed as
236
+ # that member. A Minitest `setup` ivar read in a test method types as `Foo?` (setup is not `initialize`), and a
237
+ # call of a method `nil` does not answer can only have reached `Foo#bar` with its arguments: on `nil` it raises
238
+ # `NoMethodError`, and `&.` skips the call. A method `NilClass`'s RBS declares (`==`, `respond_to?`, `is_a?`)
239
+ # may have run on `nil` instead, so a plain `.` call of one keeps the union unobserved. The sole member still
240
+ # has to pass {#observation_key}'s `Nominal` / `Singleton` checks, so `untyped | nil` stays unobserved, and a
241
+ # union with two non-nil members (`Foo | Bar | nil`) passes through unchanged. Observation only: the
242
+ # receiver's inferred type elsewhere is untouched.
243
+ def observed_receiver(receiver_type, call_node, scope)
244
+ return receiver_type unless receiver_type.is_a?(Type::Union)
245
+
246
+ non_nil = receiver_type.members.reject { |member| nil_type?(member) }
247
+ return receiver_type unless non_nil.size == 1
248
+ return receiver_type if !call_node.safe_navigation? && nil_answers?(call_node.name, scope)
249
+
250
+ non_nil.first
251
+ end
252
+
253
+ # Whether `nil` may answer `method_name`: `NilClass`'s RBS declares it, or that RBS is not available to ask.
254
+ def nil_answers?(method_name, scope)
255
+ loader = scope.environment.rbs_loader
256
+ return true if loader.nil? || loader.instance_definition("NilClass").nil?
257
+
258
+ !loader.instance_method(class_name: "NilClass", method_name: method_name).nil?
259
+ end
260
+
261
+ def nil_type?(type)
262
+ (type.is_a?(Type::Constant) && type.value.nil?) || (type.is_a?(Type::Nominal) && type.class_name == "NilClass")
263
+ end
264
+
184
265
  # ADR-14 slice 5 — RSpec-aware receiver typing. Resolves a CallNode receiver against the collected
185
266
  # `bindings` map (built by {#collect_rspec_bindings}) before falling back to ordinary `scope.type_of`. The
186
267
  # three RSpec-shaped receivers we recognise:
@@ -190,11 +271,23 @@ module Rigor
190
271
  # - `described_class.new(...)` chain — when the surrounding `describe Foo do … end` resolved `Foo`,
191
272
  # return `Type::Nominal[Foo]`.
192
273
  # - Anything else — pass through to `scope.type_of`, matching slice-3 behaviour.
193
- def resolve_receiver_type(receiver, scope, bindings)
274
+ def resolve_receiver_type(receiver, scope, bindings, blocks)
194
275
  return resolve_described_class_new(bindings) if described_class_new?(receiver)
195
276
  return bindings[receiver.name] if bound_call?(receiver, bindings)
196
277
 
197
- safe_type_of(scope, receiver)
278
+ type = safe_type_of(scope, receiver)
279
+ return type unless receiver.is_a?(Prism::InstanceVariableReadNode) && (type.nil? || type.is_a?(Type::Dynamic))
280
+
281
+ before_ivar_type(receiver.name, blocks) || type
282
+ end
283
+
284
+ # Issue #1389 — an ivar read inside an `it` block types as `Dynamic` (the block's ivars are not a class's), so
285
+ # a `before { @foo = Foo.new }` write stands in for it. Only a `before` whose example-group block encloses the
286
+ # read counts — a sibling `describe`, an un-included `shared_context` or a class body never reaches it — and
287
+ # when those `before`s write the name with more than one type, no type is answered.
288
+ def before_ivar_type(name, blocks)
289
+ types = blocks.flat_map { |block| @before_ivars.dig(block, name) || [] }.uniq
290
+ types.size == 1 ? types.first : nil
198
291
  end
199
292
 
200
293
  def bound_call?(receiver, bindings)
@@ -279,6 +372,39 @@ module Rigor
279
372
  bindings[name] = body_type if body_type
280
373
  end
281
374
 
375
+ # `before { @foo = Foo.new }` / `before(:each) do … end` inside an example-group block: records each ivar the
376
+ # `before` block's top-level statements write, with the written value's type, under that enclosing group
377
+ # block — `{ BlockNode => { :@foo => [Type] } }`, keyed by node identity. A `before` outside every group
378
+ # block ({#enclosing_blocks}), and `config.before`, record nothing.
379
+ def collect_before_ivars(root, scope_index)
380
+ index = {}.compare_by_identity
381
+ walk_before_ivars(root, [], index, scope_index)
382
+ index
383
+ end
384
+
385
+ def walk_before_ivars(node, blocks, index, scope_index)
386
+ return unless node.is_a?(Prism::Node)
387
+
388
+ record_before_ivars(node, blocks.last, index, scope_index) if blocks.last
389
+ node.rigor_each_child do |child|
390
+ walk_before_ivars(child, enclosing_blocks(node, child, blocks), index, scope_index)
391
+ end
392
+ end
393
+
394
+ def record_before_ivars(node, group, index, scope_index)
395
+ return unless node.is_a?(Prism::CallNode) && node.name == :before && node.receiver.nil?
396
+
397
+ body = block_body_node(node.block)
398
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body].compact
399
+ statements.each do |statement|
400
+ next unless statement.is_a?(Prism::InstanceVariableWriteNode)
401
+
402
+ scope = scope_index[statement.value] || scope_index[statement]
403
+ type = scope && safe_type_of(scope, statement.value)
404
+ ((index[group] ||= {})[statement.name] ||= []) << type if type
405
+ end
406
+ end
407
+
282
408
  def binding_name_for(call_node)
283
409
  first_arg = call_node.arguments&.arguments&.first
284
410
  return call_node.name == :subject ? :subject : nil if first_arg.nil?