rigortype 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -10,6 +10,7 @@ require_relative "../rbs_extended"
10
10
  require_relative "../analysis/fact_store"
11
11
  require_relative "../builtins/regex_refinement"
12
12
  require_relative "guard_rebinding"
13
+ require_relative "intervening_ivar_reset"
13
14
  require_relative "key_presence_guard"
14
15
  require_relative "last_line"
15
16
  require_relative "operand_effects"
@@ -511,8 +512,9 @@ module Rigor
511
512
  # none of the condition shapes are recognised, both returned scopes equal the input
512
513
  # scope. The catalogue mirrors {.case_equality_target_class}: static class/module
513
514
  # constants narrow as `is_a?`; integer/float-endpoint ranges narrow to `Numeric`;
514
- # string-endpoint ranges and regexp literals narrow to `String`; a `nil`, `true` or
515
- # `false` literal narrows as its class does ({.when_condition_class}).
515
+ # string-endpoint ranges narrow to `String`; regexp literals narrow as
516
+ # {.regexp_case_equality_type} says; a `nil`, `true` or `false` literal narrows as its
517
+ # class does ({.when_condition_class}).
516
518
  def case_when_scopes(subject, conditions, scope)
517
519
  # C1 — `case x when /re/` runs `/re/ === x`, which sets the regex match-data globals
518
520
  # exactly as a successful `=~` does. Narrow `$~`/`$&`/`$1..$N` on the clause body (the
@@ -522,7 +524,7 @@ module Rigor
522
524
 
523
525
  # Issue #1429 — a global or constant subject (`case $stdout when StringIO`) narrows as a local does, and so
524
526
  # does an instance variable (#1446).
525
- slot = receiver_slot(subject, scope, kinds: CASE_SUBJECT_KINDS)
527
+ slot = case_subject_slot(subject, scope)
526
528
  return [body_scope, scope] if slot.nil?
527
529
 
528
530
  truthy_type, falsey_type = case_when_types(scope, slot.current, conditions)
@@ -536,6 +538,32 @@ module Rigor
536
538
  CASE_SUBJECT_KINDS = %i[local ivar global constant].freeze
537
539
  private_constant :CASE_SUBJECT_KINDS
538
540
 
541
+ # The slot a `case` subject narrows. Issue #2211 — a subject that writes a local or instance variable
542
+ # (`case (x = v)`, `case x = v`) tests the value it stores, which the variable holds in every clause, so the
543
+ # clauses narrow the variable as they narrow a bare read of it: `case (x = s ? String(s) : nil) when String then
544
+ # x.upcase end` reads `x` as `String`. A single-statement `(…)` is unwrapped.
545
+ def case_subject_slot(subject, scope)
546
+ subject = unwrap_case_subject(subject)
547
+ case subject
548
+ when Prism::LocalVariableWriteNode
549
+ variable_slot(:local, subject.name, scope.local(subject.name), CASE_SUBJECT_KINDS)
550
+ when Prism::InstanceVariableWriteNode
551
+ variable_slot(:ivar, subject.name, scope.ivar(subject.name), CASE_SUBJECT_KINDS)
552
+ else receiver_slot(subject, scope, kinds: CASE_SUBJECT_KINDS)
553
+ end
554
+ end
555
+ private_class_method :case_subject_slot
556
+
557
+ # `subject` with each single-statement `(…)` around it removed: the node {.case_subject_slot} narrows through,
558
+ # which `StatementEvaluator#when_test_rebinds_subject?` reads the same way.
559
+ def unwrap_case_subject(subject)
560
+ while subject.is_a?(Prism::ParenthesesNode) && subject.body.is_a?(Prism::StatementsNode) &&
561
+ subject.body.body.size == 1
562
+ subject = subject.body.body.first
563
+ end
564
+ subject
565
+ end
566
+
539
567
  # Returns `[body_scope, falsey_scope]` for one clause of a `case` with a subject: a `when` clause narrows
540
568
  # through {.case_when_scopes}, and an `in` clause only for a bare class pattern ({.bare_class_pattern_node}),
541
569
  # which matches exactly when `subject.is_a?(C)`. Every other pattern can fail to match where a class test
@@ -1797,9 +1825,13 @@ module Rigor
1797
1825
  # passes through unchanged (failing one pattern proves nothing about the string's shape).
1798
1826
  # A non-literal / unanchored / `\Z` / line-anchored / `//x` pattern falls through to no
1799
1827
  # narrowing.
1828
+ #
1829
+ # The refinement names a String, so it shares `=~`'s gate (#1955): a receiver that may be a Symbol, or an
1830
+ # untyped one, is left as it is ({#refinable_operand?}).
1800
1831
  def analyse_whole_regex_match_predicate(node, scope)
1801
1832
  return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
1802
1833
  return nil if node.arguments.nil? || node.arguments.arguments.size != 1
1834
+ return nil unless refinable_operand?(node, scope)
1803
1835
 
1804
1836
  pattern = regex_operand_pattern(node.arguments.arguments.first, scope)
1805
1837
  return nil unless pattern.is_a?(RegexMatchPattern)
@@ -2636,10 +2668,10 @@ module Rigor
2636
2668
  # iff the endpoints compare it. We conservatively narrow `x` to `Numeric` for
2637
2669
  # integer-endpoint ranges and to `String` for string-endpoint ranges; other endpoint
2638
2670
  # types fall through.
2639
- # - **Regexp literal receiver** (`/foo/ === x`): the match operator coerces `x` to a
2640
- # String, so the truthy edge narrows `x` to `String`. The falsey edge keeps the entry
2641
- # type unchanged because `Regexp#===` returns false for non-Strings AND for Strings
2642
- # that simply do not match.
2671
+ # - **Regexp literal receiver** (`/foo/ === x`): the truthy edge narrows `x` as
2672
+ # {#regexp_case_equality_type} says (its String and Symbol parts, #1955). The falsey edge
2673
+ # keeps the entry type unchanged because `Regexp#===` returns false for non-Strings AND
2674
+ # for Strings that simply do not match.
2643
2675
  #
2644
2676
  # Anything else — non-local LHS argument, dynamic receiver, custom `===` method — falls
2645
2677
  # through to the no-narrowing branch (nil), preserving the entry scope on both edges.
@@ -2703,6 +2735,9 @@ module Rigor
2703
2735
  if (class_name = lexical_class_name(receiver, scope))
2704
2736
  return class_predicate_scopes(scope, local_name, current, class_name, exact: false)
2705
2737
  end
2738
+ if regexp_literal?(receiver)
2739
+ return [scope.with_local(local_name, regexp_case_equality_type(current, scope)), scope]
2740
+ end
2706
2741
 
2707
2742
  target_class = case_equality_target_class(receiver)
2708
2743
  return nil if target_class.nil?
@@ -2716,7 +2751,7 @@ module Rigor
2716
2751
  end
2717
2752
 
2718
2753
  # The class a `when` condition narrows the subject to, as `is_a?` would, or nil when the condition is no class
2719
- # test Rigor reads: a class or module constant, a range or regexp literal ({.case_equality_target_class}),
2754
+ # test Rigor reads: a class or module constant, a range literal ({.case_equality_target_class}),
2720
2755
  # or (#1925) a `nil` / `true` / `false` literal. `nil === x` runs `NilClass#===`, which holds only for `nil`
2721
2756
  # itself whatever the subject's class defines, and `nil` is `NilClass`'s only instance, so `when nil` is
2722
2757
  # `when NilClass`; `true` and `false` likewise.
@@ -2733,8 +2768,9 @@ module Rigor
2733
2768
  # Maps a case-equality literal receiver to the class whose membership is implied by the
2734
2769
  # truthy edge. `Prism::ParenthesesNode` wrappers are transparently unwrapped (`(1..10)
2735
2770
  # === x` is parsed with the range inside parentheses). Range literals: integer
2736
- # endpoints → `Numeric`; string endpoints → `String`. Regexp literals → `String`. Other
2737
- # shapes return nil so the caller falls through.
2771
+ # endpoints → `Numeric`; string endpoints → `String`. Regexp literals → `String`, read only
2772
+ # as the class a dead `when` arm names: their narrowing is {#regexp_case_equality_type}'s,
2773
+ # which the callers consult first. Other shapes return nil so the caller falls through.
2738
2774
  def case_equality_target_class(receiver)
2739
2775
  receiver = unwrap_parens(receiver)
2740
2776
  case receiver
@@ -2743,6 +2779,68 @@ module Rigor
2743
2779
  end
2744
2780
  end
2745
2781
 
2782
+ def regexp_literal?(node)
2783
+ case unwrap_parens(node)
2784
+ when Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode then true
2785
+ else false
2786
+ end
2787
+ end
2788
+
2789
+ # Issue #1955 — the subject on the match edge of `/re/ === x` (and of `case x when /re/`). `Regexp#===`
2790
+ # matches a String, a Symbol by its name, and an object that converts with `to_str`, and answers false for
2791
+ # anything else. A member is kept whole when its class defines `to_str` (#2002) — a String and its
2792
+ # subclasses, a class whose signature, ancestry or project source declares it — or when no signature
2793
+ # declares the class, and a `Dynamic` member is kept, since it may also be a Symbol at run time. Any other
2794
+ # member narrows to its String and Symbol parts, which are only what can match: a `String | nil` subject
2795
+ # reads `String`, a Symbol one stays `Symbol`, an Integer one drops out. An `Object`, `BasicObject` or
2796
+ # module member narrows too (to `String | Symbol`): its instances may be anything, and reading the edge as
2797
+ # the String it most often is keeps `s.length` on it from reporting. The no-match edge keeps the entry type:
2798
+ # a String that does not match reaches it too.
2799
+ def regexp_case_equality_type(current, scope)
2800
+ members = current.is_a?(Type::Union) ? current.members : [current]
2801
+ Type::Combinator.union(*members.map { |member| regexp_case_equality_member(member, scope) })
2802
+ end
2803
+
2804
+ def regexp_case_equality_member(member, scope)
2805
+ return member if member.is_a?(Type::Dynamic)
2806
+ return member if converts_with_to_str?(member, scope)
2807
+
2808
+ parts = %w[String Symbol].map do |class_name|
2809
+ narrow_class(member, class_name, exact: false, environment: scope.environment, scope: scope)
2810
+ end
2811
+ Type::Combinator.union(*parts)
2812
+ end
2813
+
2814
+ # True when a class `member` dispatches through declares `to_str`, or is declared by no signature
2815
+ # ({#class_lacks_method?} answers false for it). `Object`, `BasicObject` and a module String or Symbol
2816
+ # includes (`Kernel`, `Comparable`) are read as their String and Symbol parts instead: their instances may be
2817
+ # anything, and the String they most often are keeps a String method on the arm from reporting. Any other
2818
+ # module is kept whole, since a matching value may include it; so is one of those that declares `to_str`.
2819
+ def converts_with_to_str?(member, scope)
2820
+ targets = ProjectMethodOwnership.targets(member)
2821
+ return false if targets.nil? || targets.empty?
2822
+
2823
+ targets.any? { |class_name, kind| class_converts_with_to_str?(class_name, kind, scope) }
2824
+ end
2825
+
2826
+ # The modules String's or Symbol's own ancestry includes, read from the running Ruby.
2827
+ STRING_SYMBOL_MODULES = (String.ancestors + Symbol.ancestors).grep_v(Class).filter_map(&:name).uniq.freeze
2828
+ private_constant :STRING_SYMBOL_MODULES
2829
+
2830
+ def class_converts_with_to_str?(class_name, kind, scope)
2831
+ return false if kind == :instance && OPEN_SURFACE_CLASSES.include?(class_name)
2832
+ if kind == :instance && scope.environment.rbs_module?(class_name)
2833
+ return module_declares_to_str?(class_name, scope) || !STRING_SYMBOL_MODULES.include?(class_name)
2834
+ end
2835
+
2836
+ !class_lacks_method?(class_name, :to_str, kind, scope)
2837
+ end
2838
+
2839
+ def module_declares_to_str?(module_name, scope)
2840
+ ProjectMethodOwnership.defines?(module_name, :to_str, :instance, scope) ||
2841
+ !ExternalAncestorResolution.method_definition(module_name, :to_str, :instance, scope: scope).nil?
2842
+ end
2843
+
2746
2844
  def unwrap_parens(node)
2747
2845
  while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode) &&
2748
2846
  node.body.body.size == 1
@@ -2971,6 +3069,10 @@ module Rigor
2971
3069
  return integer_literal_when_result(current, int_literal, falsey_acc)
2972
3070
  end
2973
3071
 
3072
+ if regexp_literal?(condition)
3073
+ return { truthy: regexp_case_equality_type(current, scope), falsey: falsey_acc, fully_narrowable: true }
3074
+ end
3075
+
2974
3076
  target = when_condition_class(condition, scope)
2975
3077
  return class_when_result(scope, current, target, falsey_acc) if target
2976
3078
 
@@ -3793,9 +3895,12 @@ module Rigor
3793
3895
  # that edge, so a `$_` the earlier operand narrowed is forgotten when the operand may set it
3794
3896
  # (`gets && log(items.each { gets })`), before the operand's own edges narrow it again.
3795
3897
  # Issue #1429 — likewise a guard's narrowing of a global or constant the operand may rebind
3796
- # (`$sep && reset_sep && $sep.length`).
3898
+ # (`$sep && reset_sep && $sep.length`). Issue #1963 — and an instance variable's narrowing an implicit-self
3899
+ # or `self.` call in the operand may write ({InterveningIvarReset.after_operand}): `@x.nil? && reset!` does
3900
+ # not leave `@x` `nil` when `reset!` assigns it.
3797
3901
  def operand_edges(node, edge_scope)
3798
3902
  edge_scope = LastLine.forget_if_set(edge_scope, node)
3903
+ edge_scope = InterveningIvarReset.after_operand(node, edge_scope)
3799
3904
  if edge_scope.guard_narrowed? && GuardRebinding.may_rebind?(node, edge_scope)
3800
3905
  edge_scope = edge_scope.forget_guard_narrowings
3801
3906
  end
@@ -123,9 +123,14 @@ module Rigor
123
123
  # all.
124
124
  def answer_for_entry(scope, entry, method_name)
125
125
  unless entry.external?
126
- return source_defines?(scope, entry.name, method_name) ? [:source, entry.name] : nil
126
+ defines = source_defines?(scope, entry.name, method_name) || macro_named?(scope, entry.name, method_name)
127
+ return defines ? [:source, entry.name] : nil
127
128
  end
128
129
 
130
+ # Issue #2094 — a project module with no `def` is held as an external; a macro of its body may name it.
131
+ named = entry.candidates.find { |candidate| macro_named?(scope, candidate, method_name) }
132
+ return [:source, named] if named
133
+
129
134
  entry.candidates.each do |candidate|
130
135
  definition = Rigor::Reflection.instance_method_definition(candidate, method_name, scope: scope)
131
136
  return [private_definition?(definition) ? :private_rbs : :rbs, candidate, definition] if definition
@@ -137,6 +142,13 @@ module Rigor
137
142
  end
138
143
  private_class_method :answer_for_entry
139
144
 
145
+ # Issue #2094 — whether a class-body macro of `owner` was handed `method_name` as a literal
146
+ # (`dsl_method :xdyn`, `def_delegators :@target, :xdyn`). The indexer files every such name as an opaque
147
+ # envelope, since what the macro defines under it is unknown, so the module may define the name.
148
+ def macro_named?(scope, owner, method_name)
149
+ scope.parameter_envelopes_of(owner).key?([:instance, method_name.to_sym])
150
+ end
151
+
140
152
  def private_definition?(definition)
141
153
  definition.respond_to?(:accessibility) && definition.accessibility == :private
142
154
  end
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "scope_indexer"
6
+ require_relative "project_seed_tables"
6
7
  require_relative "fork_map"
7
8
  require_relative "../source/node_walker"
8
9
 
@@ -238,18 +239,10 @@ module Rigor
238
239
  base.with_discovery(base.discovery.with(**tables))
239
240
  end
240
241
 
242
+ # Issue #1614 — the tables a `check` run seeds, from the one list {ProjectSeedTables} keeps for every path that
243
+ # runs no `check`, so an argument types here as it does in the file `check` analyses.
241
244
  def discovery_seed_tables
242
- classes = ScopeIndexer.discovered_classes_for_paths(@files)
243
- def_index = ScopeIndexer.discovered_def_index_for_paths(@files)
244
- tables = {}
245
- tables[:discovered_classes] = classes unless classes.empty?
246
- DISCOVERY_FIELD.each do |index_key, field|
247
- table = def_index.fetch(index_key)
248
- tables[field] = table unless table.empty? && !Scope::DiscoveryIndex.paired?(field)
249
- end
250
- # ADR-119 WD1 — the pairs travel together: a pair is dropped only when both halves are empty. A sibling whose
251
- # member this collector does not seed completes to that member's empty value, which the base scope holds.
252
- Scope::DiscoveryIndex.compact_pairs(tables.merge(def_index.fetch(:siblings)))
245
+ ProjectSeedTables.for_paths(@files)
253
246
  rescue StandardError => e
254
247
  # Discovery is best-effort; a malformed corner of the project must not crash the
255
248
  # protection scan. Without discovery the collector simply resolves fewer call sites. A broken
@@ -257,21 +250,6 @@ module Rigor
257
250
  e.is_a?(DeclarationWalk::ContractError) ? raise : {}
258
251
  end
259
252
 
260
- DISCOVERY_FIELD = {
261
- def_nodes: :discovered_def_nodes,
262
- singleton_def_nodes: :discovered_singleton_def_nodes,
263
- def_sources: :discovered_def_sources,
264
- superclasses: :discovered_superclasses,
265
- header_nestings: :discovered_header_nestings,
266
- includes: :discovered_includes, unpositioned_mixins: :unpositioned_mixins,
267
- class_sources: :discovered_class_sources,
268
- method_visibilities: :discovered_method_visibilities,
269
- methods: :discovered_methods,
270
- data_member_layouts: :data_member_layouts,
271
- struct_member_layouts: :struct_member_layouts, deferred_ranges: :discovered_deferred_ranges
272
- }.freeze
273
- private_constant :DISCOVERY_FIELD
274
-
275
253
  # Built once over the whole project and inherited by the fork workers via copy-on-write; a frozen Set
276
254
  # for O(1) membership in the {#record_call} hot loop.
277
255
  def user_method_names(discovery)
@@ -27,25 +27,57 @@ module Rigor
27
27
  # alias earlier in the `pre_eval:` files binds `old`, the alias's entry copies that entry's `return_type` (and
28
28
  # its `alias_of`); otherwise `alias_of` names `old`, a method the class had before the patch, and the
29
29
  # dispatcher answers the alias with what that method answers. `alias_of` is nil for a `def`.
30
- Entry = Data.define(:class_name, :method_name, :kind, :source_path, :source_line, :return_type, :alias_of) do
31
- def initialize(class_name:, method_name:, kind:, source_path:, source_line:, return_type: nil, alias_of: nil)
30
+ #
31
+ # Issue #1708 — `visibility` is `:public`, `:private` or `:protected`, as the patch file leaves the method: a
32
+ # `private def x`, a `def` after a bare `private` in the same body, a later `private :x`, and the instance copy
33
+ # a `module_function` makes are `:private` ({#callable?} reads it). An alias takes its old name's visibility,
34
+ # as Ruby's does; one whose old name no patch binds is `:public`.
35
+ #
36
+ # `farther` is set only on an entry {#lookup_through_ancestors} returns: the other definers of the name farther
37
+ # on the walk (entries, or {SOURCE_DEFINER} for a project-source `def`), which {#callable?} reads before it
38
+ # refuses the call.
39
+ Entry = Data.define(:class_name, :method_name, :kind, :source_path, :source_line, :return_type, :alias_of,
40
+ :visibility, :farther) do
41
+ def initialize(class_name:, method_name:, kind:, source_path:, source_line:, return_type: nil, alias_of: nil,
42
+ visibility: :public, farther: nil)
32
43
  super
33
44
  end
34
45
  end
35
46
 
36
- attr_reader :by_key, :write_census
47
+ # Issue #1708 — one `include` or `prepend` a `pre_eval:` file writes (`Numeric.include(M)`, or `include M` in
48
+ # `class Numeric`): `owner` gains `module_name` as an ancestor, `kind` `:include` or `:prepend`. Names are as
49
+ # written, with no lexical resolution, as a patch's class name is.
50
+ Mixin = Data.define(:owner, :module_name, :kind)
51
+
52
+ attr_reader :by_key, :write_census, :mixins, :overrides
53
+
54
+ # Each owner's prepended and included modules, the most recent first.
55
+ def self.mixins_by_owner(mixins)
56
+ table = mixins.each_with_object({}) do |mixin, acc|
57
+ edges = (acc[mixin.owner] ||= { prepend: [], include: [] })
58
+ edges[mixin.kind].unshift(mixin.module_name)
59
+ end
60
+ table.each_value { |edges| edges.each_value(&:freeze) }.freeze
61
+ end
37
62
 
38
63
  # @param entries — flat list of declarations observed during the pre-pass.
39
64
  # Last-write-wins on `(class_name, method_name, kind)` duplicates, as at runtime (ADR-17); the
40
65
  # `pre-eval.duplicate-declaration` diagnostic emission stays decoupled from registry behaviour.
41
66
  # @param write_census — issue #1367: the `pre_eval:` files' {GlobalWriteCensus}, which the `global.*` write
42
67
  # rules join with the project's, since a patch file is loaded ahead of the code it patches.
43
- def initialize(entries: [], write_census: GlobalWriteCensus::EMPTY)
68
+ # @param mixins — issue #1708: the {Mixin} edges the `pre_eval:` files write, in the order they run.
69
+ # @param overrides — issue #1708: `[class_name, method_name, kind] => visibility` for a named visibility call
70
+ # (`public :kp` in `class Integer`) on a method the class inherits rather than binds, which Ruby copies onto
71
+ # the class with that visibility.
72
+ def initialize(entries: [], write_census: GlobalWriteCensus::EMPTY, mixins: [], overrides: {})
44
73
  @by_key = entries.each_with_object({}) do |entry, acc|
45
74
  key = [entry.class_name, entry.method_name, entry.kind]
46
75
  acc[key] = entry
47
76
  end.freeze
48
77
  @write_census = write_census.frozen? ? write_census : write_census.dup.freeze
78
+ @mixins = mixins.dup.freeze
79
+ @mixins_of = ProjectPatchedMethods.mixins_by_owner(@mixins)
80
+ @overrides = overrides.dup.freeze
49
81
  freeze
50
82
  end
51
83
 
@@ -55,11 +87,143 @@ module Rigor
55
87
  @by_key[[class_name, method_name, kind]]
56
88
  end
57
89
 
90
+ # Issue #1708 — the entry a `kind` receiver of `class_name` reaches through its ancestors, searched in Ruby's
91
+ # order: a patch on `Object`, `Kernel`, `Numeric`, `Comparable` or `Enumerable` answers an `Integer`, `Array`
92
+ # or `String` receiver, and a patch on a nearer ancestor wins over one on a farther. A class object's chain
93
+ # is its singleton classes, then `Class`, `Module`, `Object`, `Kernel` and `BasicObject`, so `def zz` in
94
+ # `class Object` answers `Integer.zz` too. The ancestors are the class's RBS ones; a class RBS does not
95
+ # declare is searched as itself only, as {#lookup} searches it.
96
+ #
97
+ # It answers only which patch the chain holds. A method a nearer ancestor's RBS declares wins over the patch
98
+ # in Ruby, so the callers ask once that RBS has come back without the method. A method the project's own
99
+ # source defines on the way wins too: `source_defines` is called with each `owner, side` BEFORE that owner's
100
+ # patch is read, and when it answers true the walk stops with no entry, leaving the call to what types that
101
+ # method. That includes an owner holding both a patch and a source `def` (a `lib/` reopen loaded after the
102
+ # patch, or a module the project prepends to it), unlike {#lookup}, which answers the patch.
103
+ #
104
+ # A module the `pre_eval:` files include into or prepend to an owner on the way ({Mixin}) is searched where Ruby
105
+ # puts it: prepends ahead of the owner, includes after it, the most recent first, each with its own mixins. A
106
+ # named visibility call on an owner the walk passes before the body (`public :kp` in `class Integer`, an
107
+ # override) gives the entry it returns that visibility, and that owner for {#callable?}'s protected check.
108
+ # @return the entry, or `nil` when no ancestor's patch declares the method ahead of a source `def`.
109
+ def lookup_through_ancestors(class_name:, method_name:, kind:, rbs_loader:, source_defines: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
110
+ return nil if empty?
111
+
112
+ override = nil
113
+ found = nil
114
+ farther = []
115
+ ancestor_keys(class_name.to_s, kind, rbs_loader).each do |owner, side|
116
+ if source_defines&.call(owner, side)
117
+ return nil if found.nil?
118
+
119
+ farther << SOURCE_DEFINER
120
+ break
121
+ end
122
+
123
+ entry = @by_key[[owner, method_name, side]]
124
+ if found
125
+ farther << entry if entry
126
+ elsif entry
127
+ found = override ? entry.with(class_name: override.first, visibility: override.last) : entry
128
+ elsif (visibility = @overrides[[owner, method_name, side]])
129
+ override ||= [owner, visibility]
130
+ end
131
+ end
132
+ farther.empty? || found.nil? ? found : found.with(farther: farther.freeze)
133
+ end
134
+
135
+ # A project-source `def` of the name farther on the walk than the entry it returns, in {Entry#farther}.
136
+ SOURCE_DEFINER = Object.new.freeze
137
+
138
+ # Issue #1708 — whether Ruby lets a call reach `entry`. A public method answers any call; a private one only a
139
+ # call with no receiver or with `self` as it (`explicit` is false for those); a protected one also a call from
140
+ # a method whose `self` is an instance of the entry's owner. `self_type` is the caller's `self` (`nil` at the
141
+ # top level, where it is `main`, an Object); a `self` this cannot name (the default) may be one, so a
142
+ # protected entry answers it.
143
+ #
144
+ # It never refuses a call some other definer of the name may answer: the walk's order is an approximation
145
+ # (a module two owners mix in is spliced at the nearer one, where Ruby keeps the first include and skips the
146
+ # second), so when {Entry#farther} holds a definer the call may reach (a project-source `def`, or an entry this
147
+ # would let it reach), it answers nil: decline, neither refused nor typed from `entry`.
148
+ # @return true (the call reaches `entry`), false (Ruby refuses it), or nil (declined).
149
+ def callable?(entry, explicit:, self_type: :unknown, rbs_loader: nil)
150
+ return true unless explicit
151
+
152
+ allowed =
153
+ case entry.visibility
154
+ when :private, :module_function then false
155
+ when :protected then self_kind_of?(self_type, entry.class_name, rbs_loader)
156
+ else true
157
+ end
158
+ return true if allowed
159
+
160
+ reachable = (entry.farther || []).any? do |other|
161
+ other.equal?(SOURCE_DEFINER) || callable?(other, explicit: true, self_type: self_type, rbs_loader: rbs_loader)
162
+ end
163
+ reachable ? nil : false # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
164
+ end
165
+
58
166
  def empty?
59
167
  @by_key.empty?
60
168
  end
61
169
 
62
170
  EMPTY = new.freeze
171
+
172
+ private
173
+
174
+ # Whether `self_type` may be an instance of `owner`: false only when the ancestors of a class RBS knows,
175
+ # with the patches' mixins, do not hold it.
176
+ def self_kind_of?(self_type, owner, rbs_loader)
177
+ name, kind =
178
+ if self_type.nil? then ["Object", :instance]
179
+ elsif self_type.respond_to?(:class_name) && self_type.class_name
180
+ [self_type.class_name.to_s, self_type.is_a?(Type::Singleton) ? :singleton : :instance]
181
+ end
182
+ return true if name.nil? || rbs_loader.nil?
183
+
184
+ keys = ancestor_keys(name, kind, rbs_loader)
185
+ keys == [[name, kind]] || keys.include?([owner, :instance])
186
+ end
187
+
188
+ def ancestor_keys(class_name, kind, rbs_loader)
189
+ pairs =
190
+ if kind == :singleton
191
+ sides = rbs_loader ? rbs_loader.singleton_ancestors_for(class_name) : []
192
+ sides.empty? ? [[class_name, :singleton]] : sides.map { |side, owner| [owner, side] }
193
+ else
194
+ names = rbs_loader ? rbs_loader.ancestor_names_for(class_name) : []
195
+ (names.empty? ? [class_name] : names).map { |owner| [owner, :instance] }
196
+ end
197
+ @mixins_of.empty? ? pairs : with_mixins(pairs)
198
+ end
199
+
200
+ # The RBS ancestor pairs with each instance-side owner's `pre_eval:` mixins spliced in. A module already on
201
+ # the chain is not added again, as Ruby skips a module the ancestors already hold.
202
+ def with_mixins(pairs)
203
+ chain = pairs.filter_map { |owner, side| owner if side == :instance }.to_set
204
+ seen = Set.new
205
+ pairs.flat_map do |owner, side|
206
+ side == :instance ? expand_mixins(owner, chain, seen).map { |name| [name, :instance] } : [[owner, side]]
207
+ end
208
+ end
209
+
210
+ # `owner` with the modules the patches prepend to it ahead and the ones they include after, each expanded the
211
+ # same way: the order Ruby's `ancestors` lists them in. An include of a module the RBS chain already holds
212
+ # changes nothing, as Ruby skips it (`Integer.include(Comparable)`); a prepend of one is spliced ahead all the
213
+ # same, as Ruby adds it again (`Integer.prepend(Comparable)`). `seen` holds the modules already spliced, so a
214
+ # module is spliced once, at the nearest owner that mixes it in, and an include cycle ends. Ruby skips
215
+ # `Integer.include(A)` run after `Numeric.include(A)`, leaving A after Numeric; the registry, which does not read
216
+ # the order the two edges ran in, splices A after Integer either way.
217
+ def expand_mixins(owner, chain, seen)
218
+ edges = @mixins_of[owner]
219
+ return [owner] if edges.nil?
220
+
221
+ ahead = edges[:prepend].flat_map { |name| seen.add?(name) ? expand_mixins(name, chain, seen) : [] }
222
+ after = edges[:include].flat_map do |name|
223
+ !chain.include?(name) && seen.add?(name) ? expand_mixins(name, chain, seen) : []
224
+ end
225
+ ahead + [owner] + after
226
+ end
63
227
  end
64
228
  end
65
229
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "project_patched_methods"
6
+ require_relative "../source/constant_path"
7
+
8
+ module Rigor
9
+ module Inference
10
+ module ProjectPatchedScanner
11
+ # Issue #1708 — the `include` / `prepend` edges a `pre_eval:` file writes, so a module it mixes into an
12
+ # ancestor (`Numeric.include(M)`) answers the receivers below that ancestor through
13
+ # {ProjectPatchedMethods#lookup_through_ancestors}. Ruby 4.0.5: after `Numeric.include(NumExt)`, `1.inc_m` and
14
+ # `1.5.inc_m` run.
15
+ module Mixins
16
+ # An `include` / `prepend` the walk met, with the lexical prefix {.resolve} resolves its module's written
17
+ # name in.
18
+ Pending = Data.define(:owner, :module_name, :kind, :prefix)
19
+
20
+ CALLS = %i[include prepend].freeze
21
+ private_constant :CALLS
22
+
23
+ module_function
24
+
25
+ # The {Pending} edges `node` writes, or none: `Numeric.include(M)` on a constant, or `include M` /
26
+ # `self.include(M)` in a body. A top-level `include M` includes into `Object`, as Ruby's does. One in a
27
+ # `class << self` body is an `extend`, which this does not read; a module argument that is not a constant is
28
+ # skipped.
29
+ def pending(node, qualified_prefix, in_singleton_class)
30
+ return [] unless CALLS.include?(node.name) && node.arguments
31
+
32
+ owner = owner(node.receiver, qualified_prefix, in_singleton_class)
33
+ return [] if owner.nil?
34
+
35
+ node.arguments.arguments.filter_map do |arg|
36
+ name = Source::ConstantPath.qualified_name_or_nil(arg)
37
+ next if name.nil?
38
+
39
+ prefix = arg.is_a?(Prism::ConstantPathNode) && arg.parent.nil? ? [] : qualified_prefix
40
+ Pending.new(owner: owner, module_name: name, kind: node.name, prefix: prefix)
41
+ end
42
+ end
43
+
44
+ def owner(receiver, qualified_prefix, in_singleton_class)
45
+ case receiver
46
+ when nil, Prism::SelfNode
47
+ return nil if in_singleton_class
48
+ return "Object" if qualified_prefix.empty? && receiver.nil?
49
+
50
+ qualified_prefix.join("::") unless qualified_prefix.empty?
51
+ else
52
+ Source::ConstantPath.qualified_name_or_nil(receiver)
53
+ end
54
+ end
55
+ private_class_method :owner
56
+
57
+ # The `include` / `prepend` call an idempotence guard runs only while its module is not yet an ancestor of its
58
+ # receiver, or nil. The guards are `X.include?(M)`, `X.ancestors.include?(M)` and `X < M`, written after the
59
+ # call as `unless` or as `if !`; the guard only skips a second include, so the call is read as a direct
60
+ # statement would be (#2109). The guard names the call's own receiver and module; any other condition, or a
61
+ # guard naming another receiver or module, leaves the call unread.
62
+ #
63
+ # `top_level` says the conditional is a direct statement of the file's top level, where a receiverless call
64
+ # includes into Object; see {guarded_call?}. The `CALLS` check is defensive: the scanner applies a call's mixin
65
+ # edge only when {Mixins.pending} reads it as include or prepend, so a call of another name changes nothing.
66
+ def guarded(node, top_level)
67
+ return nil unless node.is_a?(Prism::UnlessNode) || node.is_a?(Prism::IfNode)
68
+
69
+ call = guarded_body(node)
70
+ return nil unless call.is_a?(Prism::CallNode) && CALLS.include?(call.name) && guarded_call?(call, top_level)
71
+
72
+ guard = when_undefined(node)
73
+ call if guard && guards?(guard, call)
74
+ end
75
+
76
+ # Whether `call` takes exactly one module, and is not receiverless at the file's top level. Such an include's
77
+ # guard would be `include?` on main, which Ruby does not define: the guard raises, so it is not read as an
78
+ # include into Object. A receiverless call in a class or module body names that body's own module.
79
+ def guarded_call?(call, top_level)
80
+ call.arguments&.arguments&.size == 1 && !(top_level && call.receiver.nil?)
81
+ end
82
+ private_class_method :guarded_call?
83
+
84
+ # The sole statement of an `unless` or `if` body, or nil when the conditional has an else branch or another
85
+ # body.
86
+ def guarded_body(node)
87
+ return nil if node.is_a?(Prism::UnlessNode) ? node.else_clause : node.subsequent
88
+
89
+ body = node.statements&.body
90
+ body.first if body&.size == 1
91
+ end
92
+ private_class_method :guarded_body
93
+
94
+ # The predicate the body runs under: `unless P` runs its body when P is false; `if !P` does too.
95
+ def when_undefined(node)
96
+ predicate = unwrap(node.predicate)
97
+ return predicate if node.is_a?(Prism::UnlessNode)
98
+ return nil unless predicate.is_a?(Prism::CallNode) && predicate.name == :! && predicate.receiver
99
+
100
+ unwrap(predicate.receiver)
101
+ end
102
+ private_class_method :when_undefined
103
+
104
+ def unwrap(node)
105
+ body = node.body.body if node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode)
106
+ body&.size == 1 ? body.first : node
107
+ end
108
+ private_class_method :unwrap
109
+
110
+ # Whether `guard` is `X.include?(M)`, `X.ancestors.include?(M)` or `X < M`, where `X` and `M` are the receiver
111
+ # and the argument of `call`; {guarded} passes only a call with exactly one argument.
112
+ def guards?(guard, call)
113
+ return false unless guard.is_a?(Prism::CallNode) && guard.arguments&.arguments&.size == 1
114
+
115
+ subject, named = guard_subject(guard)
116
+ named && same_constant?(subject, call.receiver) && same_constant?(named, call.arguments.arguments.first)
117
+ end
118
+ private_class_method :guards?
119
+
120
+ # The `[receiver, module]` a guard names, or nil.
121
+ def guard_subject(guard)
122
+ named = guard.arguments.arguments.first
123
+ case guard.name
124
+ when :<
125
+ [guard.receiver, named] if guard.receiver
126
+ when :include?
127
+ receiver = guard.receiver
128
+ if receiver.is_a?(Prism::CallNode) && receiver.name == :ancestors && receiver.arguments.nil?
129
+ [receiver.receiver, named]
130
+ else
131
+ [receiver, named]
132
+ end
133
+ end
134
+ end
135
+ private_class_method :guard_subject
136
+
137
+ # Whether two receivers or modules name the same constant; two absent receivers (self) match.
138
+ def same_constant?(left, right)
139
+ return left.nil? && right.nil? if left.nil? || right.nil?
140
+
141
+ name = Source::ConstantPath.qualified_name_or_nil(left)
142
+ !name.nil? && name == Source::ConstantPath.qualified_name_or_nil(right)
143
+ end
144
+ private_class_method :same_constant?
145
+
146
+ # Each {Pending} edge's module name, resolved as Ruby resolves the constant from where it is written:
147
+ # `include NumExt` in `module Ext` names `Ext::NumExt` when a patch body opens that, else `NumExt`. Only the
148
+ # names the `pre_eval:` files open or mix into are candidates; one they do not name stays as written.
149
+ def resolve(pending, entries)
150
+ known = entries.to_set(&:class_name).merge(pending.map(&:owner))
151
+ pending.map do |item|
152
+ candidates = item.prefix.size.downto(1).map do |depth|
153
+ [*item.prefix.first(depth), item.module_name].join("::")
154
+ end
155
+ name = candidates.find { |candidate| known.include?(candidate) } || item.module_name
156
+ ProjectPatchedMethods::Mixin.new(owner: item.owner, module_name: name, kind: item.kind)
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end