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
@@ -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,7 +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`.
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}).
515
518
  def case_when_scopes(subject, conditions, scope)
516
519
  # C1 — `case x when /re/` runs `/re/ === x`, which sets the regex match-data globals
517
520
  # exactly as a successful `=~` does. Narrow `$~`/`$&`/`$1..$N` on the clause body (the
@@ -535,6 +538,30 @@ module Rigor
535
538
  CASE_SUBJECT_KINDS = %i[local ivar global constant].freeze
536
539
  private_constant :CASE_SUBJECT_KINDS
537
540
 
541
+ # Returns `[body_scope, falsey_scope]` for one clause of a `case` with a subject: a `when` clause narrows
542
+ # through {.case_when_scopes}, and an `in` clause only for a bare class pattern ({.bare_class_pattern_node}),
543
+ # which matches exactly when `subject.is_a?(C)`. Every other pattern can fail to match where a class test
544
+ # passes (deconstruction arity, hash keys, a guard), so it narrows neither edge. The pattern's bindings are
545
+ # the caller's to apply.
546
+ def case_clause_scopes(subject, clause, scope)
547
+ return case_when_scopes(subject, clause.conditions, scope) if clause.is_a?(Prism::WhenNode)
548
+
549
+ class_node = bare_class_pattern_node(clause.pattern)
550
+ class_node ? case_when_scopes(subject, [class_node], scope) : [scope, scope]
551
+ end
552
+
553
+ # ADR-47 WD3a — the class-constant node of a `in C` / `in C => x` pattern (the only `in` shapes whose match is
554
+ # pure `is_a?`), or nil for any pattern that deconstructs, binds, or matches a value.
555
+ def bare_class_pattern_node(pattern)
556
+ case pattern
557
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
558
+ pattern
559
+ when Prism::CapturePatternNode
560
+ value = pattern.value
561
+ value if value.is_a?(Prism::ConstantReadNode) || value.is_a?(Prism::ConstantPathNode)
562
+ end
563
+ end
564
+
538
565
  # When the clause has exactly one `RegularExpressionNode` literal condition, narrow the
539
566
  # match-data globals on the body edge (same rule as `analyse_regex_match_predicate`'s
540
567
  # truthy edge). With multiple regex conditions (`when /a/, /b/`) the body is reachable
@@ -1088,12 +1115,17 @@ module Rigor
1088
1115
  # in {Rigor::Builtins::RegexRefinement::RULES}, the truthy branch narrows further than
1089
1116
  # `String` to the matching imported refinement (e.g. `decimal-int-string` for `\d+`).
1090
1117
  # Bodies outside the table fall back to the v0.1.0 baseline (plain `String`).
1118
+ #
1119
+ # The match rebinds `$~` and the other match globals on both edges, and this rule binds none of them (#1482),
1120
+ # so each edge forgets what an earlier match bound. The statement evaluator's walk already forgets them in the
1121
+ # scope the match leaves; the per-node scope walk of a conditional it did not enter narrows from the scope
1122
+ # before the match, where the falsey edge of an earlier `=~` had bound `$1` to `nil` (#1495).
1091
1123
  def analyse_match_write(node, scope)
1092
1124
  string_t = Type::Combinator.nominal_of("String")
1093
1125
  nil_t = Type::Combinator.constant_of(nil)
1094
1126
  refinements = match_write_capture_refinements(node)
1095
- truthy = scope
1096
- falsey = scope
1127
+ truthy = scope.forget_match_globals
1128
+ falsey = truthy
1097
1129
  node.targets.each do |target|
1098
1130
  next unless target.is_a?(Prism::LocalVariableTargetNode)
1099
1131
 
@@ -1575,23 +1607,59 @@ module Rigor
1575
1607
  end
1576
1608
 
1577
1609
  # Survey item (b): `/regex/ =~ str` and `str =~ /regex/` bind the regex match-data
1578
- # globals on each edge.
1610
+ # globals on each edge ({#regex_match_edges}), gated on the subject (#1494): an edge is
1611
+ # narrowed only when the receiver proves what the frame holds on it
1612
+ # ({#match_receiver_edges}), and an edge it cannot vouch for keeps the match globals
1613
+ # forgotten. A `String?` subject keeps the match edge only, since `NilClass#=~` answers
1614
+ # nil with the previous match still bound; an untyped one is read the same way.
1615
+ #
1616
+ # Returns nil (no narrowing) when the receiver / argument pair does not resolve to exactly one
1617
+ # regex pattern we can count (see {#regex_match_pattern}).
1618
+ def analyse_regex_match_predicate(node, scope)
1619
+ gated_regex_match_edges(node, scope)
1620
+ end
1621
+
1622
+ # Issue #1379 — `subject !~ pattern` is `Kernel#!~`, which returns `!(subject =~ pattern)`. It runs the same
1623
+ # match in the caller's frame, so its edges are {#analyse_regex_match_predicate}'s swapped: the falsey edge is
1624
+ # the match edge (`$~` a `MatchData`, an unconditional `$N` a `String`, and the whole-receiver refinement), the
1625
+ # truthy edge the no-match edge (`$~` and `$N` nil). The same patterns are recognised and the same ones
1626
+ # declined (a non-regex operand, an ambiguous constant, `//x`), and the receiver gates the edges alike.
1627
+ def analyse_regex_not_match_predicate(node, scope)
1628
+ gated_regex_match_edges(node, scope)&.reverse
1629
+ end
1630
+
1631
+ # `[match_edge, no_match_edge]` of a `=~` / `!~` predicate, each kept only where
1632
+ # {#match_receiver_edges} lets it be; an edge it cannot vouch for is the entry scope with the
1633
+ # match globals forgotten, since the call may still have rebound them. The whole-receiver
1634
+ # refinement is kept only when the local it would narrow is known to be a String on the
1635
+ # match edge ({#refinable_operand?}).
1636
+ def gated_regex_match_edges(node, scope)
1637
+ edges = regex_match_edges(node, scope, refine: refinable_operand?(node, scope))
1638
+ return nil if edges.nil?
1639
+
1640
+ match, no_match = edges
1641
+ forgotten = scope.forget_match_globals
1642
+ case match_receiver_edges(node, scope)
1643
+ when :both then [match, no_match]
1644
+ when :match then [match, forgotten]
1645
+ else [forgotten, forgotten]
1646
+ end
1647
+ end
1648
+
1649
+ # The ungated edges of a match:
1579
1650
  #
1580
- # - Truthy edge (`=~` returned an Integer position — the match succeeded): `$~` to
1651
+ # - Match edge (`=~` returned an Integer position — the match succeeded): `$~` to
1581
1652
  # `Nominal[MatchData]`; `$&` and `$1..$N` (where N is the number of capture groups in
1582
1653
  # the regex source) to `Nominal[String]`. This is the same optimistic-narrowing shape
1583
1654
  # the existing `analyse_match_write` uses for named captures inside `if /(?<x>...)/
1584
1655
  # =~ str` — optional groups in the regex source (`(\d+)?`) would bind `$N` to `nil`
1585
1656
  # at runtime, but the floor here matches the common idiom (required captures) and
1586
1657
  # lets `unless /(\d+)/ =~ s; raise; end; $1.to_i` resolve cleanly.
1587
- # - Falsey edge (`=~` returned nil — no match): `$~` and every numbered / back-reference
1658
+ # - No-match edge (`=~` returned nil): `$~` and every numbered / back-reference
1588
1659
  # global bound to `Constant<nil>`.
1589
1660
  #
1590
- # Returns nil (no narrowing) when the receiver / argument pair does not resolve to exactly one
1591
- # regex pattern we can count (see {#regex_match_pattern}).
1592
- #
1593
1661
  # `refine: false` leaves out the whole-receiver refinement, which names a String.
1594
- def analyse_regex_match_predicate(node, scope, refine: true)
1662
+ def regex_match_edges(node, scope, refine:)
1595
1663
  return nil if node.arguments.nil?
1596
1664
  return nil unless node.arguments.arguments.size == 1
1597
1665
 
@@ -1615,90 +1683,113 @@ module Rigor
1615
1683
  [truthy, falsey]
1616
1684
  end
1617
1685
 
1618
- # Issue #1379 — `subject !~ pattern` is `Kernel#!~`, which returns `!(subject =~ pattern)`. It runs the same
1619
- # match in the caller's frame, so its edges are {#analyse_regex_match_predicate}'s swapped: the falsey edge is
1620
- # the match edge (`$~` a `MatchData`, an unconditional `$N` a `String`, and the whole-receiver refinement), the
1621
- # truthy edge the no-match edge (`$~` and `$N` nil). The same patterns are recognised and the same ones
1622
- # declined (a non-regex operand, an ambiguous constant, `//x`).
1623
- #
1624
- # Unlike `=~`'s, an edge here is narrowed only when the receiver proves what the frame holds on it
1625
- # ({#not_match_receiver_edges}). An edge it cannot vouch for keeps the match globals forgotten, since the call
1626
- # may still have rebound them. The refinement is kept only when the local it would narrow is known to be a
1627
- # String on the match edge ({#refinable_operand?}).
1628
- def analyse_regex_not_match_predicate(node, scope)
1629
- edges = analyse_regex_match_predicate(node, scope, refine: refinable_operand?(node, scope))
1630
- return nil if edges.nil?
1631
-
1632
- match, no_match = edges
1633
- forgotten = scope.forget_match_globals
1634
- case not_match_receiver_edges(node, scope)
1635
- when :both then [no_match, match]
1636
- when :match then [forgotten, match]
1637
- else [forgotten, forgotten]
1638
- end
1639
- end
1640
-
1641
- # Which edges of `recv !~ pattern` the receiver lets {#analyse_regex_not_match_predicate} narrow. `Kernel#!~`
1642
- # dispatches `=~` on the receiver:
1686
+ # Which edges of `recv =~ pattern` / `recv !~ pattern` the receiver lets {#gated_regex_match_edges} narrow:
1687
+ # `:both`, `:match` (the match edge only) or nil. `Kernel#!~` dispatches `=~` on the receiver:
1643
1688
  #
1644
1689
  # - A Regexp receiver (the pattern on the left) runs `Regexp#=~`, which sets `$~` on both outcomes, a `nil`
1645
1690
  # argument included: `:both`.
1646
- # - `String#=~` and `Symbol#=~` do the same: `:both` when every member of the receiver's type is a String or a
1647
- # Symbol.
1691
+ # - `String#=~` and `Symbol#=~` do the same: `:both` when every member of the receiver's type is a String
1692
+ # (a project subclass of String included) or a Symbol.
1648
1693
  # - `NilClass#=~` answers nil and leaves `$~` alone, so a nil receiver reaches the no-match edge with the
1649
1694
  # previous match still bound; it never reaches the match edge. A `Dynamic` receiver may be nil at run time
1650
- # whatever its static facet says: `:match` when every member is a String, a Symbol, nil, or a `Dynamic`
1651
- # that is untyped or whose facet holds only those.
1652
- # - Any other receiver may define `=~` in Ruby, which binds `$~` in its own frame, not the caller's: nil.
1695
+ # whatever its static facet says. So does a safe-navigation call, which answers nil without matching on a
1696
+ # nil receiver.
1653
1697
  #
1654
- # A safe-navigation `recv&.!~(pattern)` answers nil without matching on a nil receiver, so it declines too.
1655
- def not_match_receiver_edges(node, scope)
1656
- return nil if node.receiver.nil? || node.safe_navigation?
1657
- return :both if regex_operand_pattern(node.receiver, scope).is_a?(RegexMatchPattern)
1698
+ # The two operators part over a receiver that is none of those ({#match_operator_edges},
1699
+ # {#not_match_operator_edges}).
1700
+ def match_receiver_edges(node, scope)
1701
+ return nil if node.receiver.nil?
1702
+
1703
+ edges =
1704
+ if regex_operand_pattern(node.receiver, scope).is_a?(RegexMatchPattern)
1705
+ :both
1706
+ else
1707
+ kinds = match_member_kinds(scope.type_of(node.receiver), scope)
1708
+ node.name == :=~ ? match_operator_edges(kinds) : not_match_operator_edges(kinds)
1709
+ end
1710
+ return edges unless node.safe_navigation?
1711
+
1712
+ :match if edges && node.name == :=~
1713
+ end
1658
1714
 
1659
- kinds = not_match_member_kinds(scope.type_of(node.receiver), scope)
1660
- return :both if kinds.all? { |kind| %i[string symbol].include?(kind) }
1715
+ STRING_MATCH_KINDS = %i[string string_subclass symbol].freeze
1716
+ private_constant :STRING_MATCH_KINDS
1661
1717
 
1662
- :match if kinds.all? { |kind| %i[string symbol nil dynamic].include?(kind) }
1718
+ # `recv =~ pattern`'s match edge is the truthy one. Only a member known to define its own `=~` in the
1719
+ # project keeps it out (#1494): that method binds `$~` in its own frame, not the caller's. Any other member
1720
+ # either matches in the caller's frame, answers nil (`NilClass`), or has no `=~` at all (`Integer` since
1721
+ # Ruby 3.2 drops `Object#=~`) and so never reaches the edge; a receiver the analyzer cannot classify keeps the
1722
+ # edge, as every `=~` did before #1494.
1723
+ def match_operator_edges(kinds)
1724
+ return nil if kinds.include?(:custom)
1725
+ return :both if kinds.all? { |kind| STRING_MATCH_KINDS.include?(kind) }
1726
+
1727
+ :match
1728
+ end
1729
+
1730
+ # `recv !~ pattern`'s match edge is the falsey one, which a receiver whose `=~` is not the caller's match
1731
+ # also reaches, so it is kept only when every member is a String, a Symbol, nil, or a `Dynamic` that is
1732
+ # untyped or whose facet holds only those (#1379).
1733
+ def not_match_operator_edges(kinds)
1734
+ return :both if kinds.all? { |kind| STRING_MATCH_KINDS.include?(kind) }
1735
+
1736
+ :match if kinds.all? { |kind| (STRING_MATCH_KINDS + %i[nil dynamic]).include?(kind) }
1663
1737
  end
1664
1738
 
1665
1739
  # Whether the local operand {#apply_whole_receiver_refinement} would narrow holds only Strings and nil, which
1666
1740
  # the match edge excludes. The refinement names a String, so a member that may be a Symbol — a Symbol, or an
1667
- # untyped `Dynamic` that `Kernel#!~` accepts as the receiver or `Regexp#=~` as the argument — keeps it out:
1668
- # `return if raw !~ /\A\d+\z/` on an untyped `raw` must not make `raw.to_proc` an undefined method.
1741
+ # untyped `Dynamic` that may be one at run time — keeps it out: `return unless raw =~ /\A\d+\z/` on an untyped
1742
+ # `raw` must not make `raw.to_proc` an undefined method (#1379, #1494).
1669
1743
  def refinable_operand?(node, scope)
1670
1744
  operand = [node.receiver, *node.arguments&.arguments].find { |o| o.is_a?(Prism::LocalVariableReadNode) }
1671
1745
  type = operand && scope.local(operand.name)
1672
1746
  return true if type.nil?
1673
1747
 
1674
- not_match_member_kinds(type, scope).all? { |kind| %i[string nil].include?(kind) }
1748
+ match_member_kinds(type, scope).all? { |kind| %i[string nil].include?(kind) }
1675
1749
  end
1676
1750
 
1677
- def not_match_member_kinds(type, scope)
1678
- (type.is_a?(Type::Union) ? type.members : [type]).map { |member| not_match_member_kind(member, scope) }
1751
+ def match_member_kinds(type, scope)
1752
+ (type.is_a?(Type::Union) ? type.members : [type]).map { |member| match_member_kind(member, scope) }
1679
1753
  end
1680
1754
 
1681
- # `:string` or `:symbol` for a member {.narrow_not_class} proves a String or a Symbol (a value or refinement of
1682
- # either, or a class the environment orders below it; a project subclass it cannot order reads as neither),
1683
- # `:nil`, `:dynamic` for a `Dynamic` whose facet is untyped or holds only Strings, Symbols and nil, or nil for
1684
- # anything else.
1685
- def not_match_member_kind(member, scope)
1686
- return not_match_dynamic_kind(member, scope) if member.is_a?(Type::Dynamic)
1755
+ # `:custom` for a member whose class the project gives a `=~` of its own (in its ancestry, a reopened
1756
+ # `String` included); `:string` or `:symbol` for a member {.narrow_not_class} proves a String or a Symbol (a
1757
+ # value or refinement of either, or a class the environment orders below it); `:string_subclass` for a
1758
+ # project class whose superclass chain reaches `String`, which the environment cannot order; `:nil`;
1759
+ # `:dynamic` for a `Dynamic` whose facet is untyped or holds only Strings, Symbols and nil; or nil for anything
1760
+ # else.
1761
+ def match_member_kind(member, scope)
1762
+ return match_dynamic_kind(member, scope) if member.is_a?(Type::Dynamic)
1687
1763
  return :nil if narrow_nil(member) == member && !member.is_a?(Type::Bot)
1764
+ return :custom if custom_match_operator?(member, scope)
1688
1765
 
1689
1766
  environment = scope.environment
1690
1767
  { "String" => :string, "Symbol" => :symbol }.each do |class_name, kind|
1691
1768
  return kind if narrow_not_class(member, class_name, environment: environment, scope: scope).is_a?(Type::Bot)
1692
1769
  end
1693
- nil
1770
+ :string_subclass if project_string_subclass?(member, scope)
1694
1771
  end
1695
1772
 
1696
- def not_match_dynamic_kind(member, scope)
1773
+ def match_dynamic_kind(member, scope)
1697
1774
  facet = member.static_facet
1698
1775
  return :dynamic if facet.is_a?(Type::Top)
1699
1776
 
1700
- facet_kinds = not_match_member_kinds(facet, scope)
1701
- :dynamic if facet_kinds.all? { |kind| %i[string symbol nil].include?(kind) }
1777
+ facet_kinds = match_member_kinds(facet, scope)
1778
+ :dynamic if facet_kinds.all? { |kind| (STRING_MATCH_KINDS + [:nil]).include?(kind) }
1779
+ end
1780
+
1781
+ def custom_match_operator?(member, scope)
1782
+ targets = ProjectMethodOwnership.targets(member)
1783
+ return false if targets.nil?
1784
+
1785
+ targets.any? { |class_name, kind| ProjectMethodOwnership.defines?(class_name, :=~, kind, scope) }
1786
+ end
1787
+
1788
+ def project_string_subclass?(member, scope)
1789
+ return false unless member.is_a?(Type::Nominal) && scope.known_user_class?(member.class_name)
1790
+
1791
+ scope.external_ancestor_name_candidates(member.class_name, mixins: false)
1792
+ .flatten.any? { |name| name.to_s.delete_prefix("::") == "String" }
1702
1793
  end
1703
1794
 
1704
1795
  # `str.match?(/\A\d+\z/)` — `String#match?` is a pure boolean predicate whose truthy edge
@@ -1708,9 +1799,13 @@ module Rigor
1708
1799
  # passes through unchanged (failing one pattern proves nothing about the string's shape).
1709
1800
  # A non-literal / unanchored / `\Z` / line-anchored / `//x` pattern falls through to no
1710
1801
  # narrowing.
1802
+ #
1803
+ # The refinement names a String, so it shares `=~`'s gate (#1955): a receiver that may be a Symbol, or an
1804
+ # untyped one, is left as it is ({#refinable_operand?}).
1711
1805
  def analyse_whole_regex_match_predicate(node, scope)
1712
1806
  return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
1713
1807
  return nil if node.arguments.nil? || node.arguments.arguments.size != 1
1808
+ return nil unless refinable_operand?(node, scope)
1714
1809
 
1715
1810
  pattern = regex_operand_pattern(node.arguments.arguments.first, scope)
1716
1811
  return nil unless pattern.is_a?(RegexMatchPattern)
@@ -2547,10 +2642,10 @@ module Rigor
2547
2642
  # iff the endpoints compare it. We conservatively narrow `x` to `Numeric` for
2548
2643
  # integer-endpoint ranges and to `String` for string-endpoint ranges; other endpoint
2549
2644
  # types fall through.
2550
- # - **Regexp literal receiver** (`/foo/ === x`): the match operator coerces `x` to a
2551
- # String, so the truthy edge narrows `x` to `String`. The falsey edge keeps the entry
2552
- # type unchanged because `Regexp#===` returns false for non-Strings AND for Strings
2553
- # that simply do not match.
2645
+ # - **Regexp literal receiver** (`/foo/ === x`): the truthy edge narrows `x` as
2646
+ # {#regexp_case_equality_type} says (its String and Symbol parts, #1955). The falsey edge
2647
+ # keeps the entry type unchanged because `Regexp#===` returns false for non-Strings AND
2648
+ # for Strings that simply do not match.
2554
2649
  #
2555
2650
  # Anything else — non-local LHS argument, dynamic receiver, custom `===` method — falls
2556
2651
  # through to the no-narrowing branch (nil), preserving the entry scope on both edges.
@@ -2614,6 +2709,9 @@ module Rigor
2614
2709
  if (class_name = lexical_class_name(receiver, scope))
2615
2710
  return class_predicate_scopes(scope, local_name, current, class_name, exact: false)
2616
2711
  end
2712
+ if regexp_literal?(receiver)
2713
+ return [scope.with_local(local_name, regexp_case_equality_type(current, scope)), scope]
2714
+ end
2617
2715
 
2618
2716
  target_class = case_equality_target_class(receiver)
2619
2717
  return nil if target_class.nil?
@@ -2626,11 +2724,27 @@ module Rigor
2626
2724
  ]
2627
2725
  end
2628
2726
 
2727
+ # The class a `when` condition narrows the subject to, as `is_a?` would, or nil when the condition is no class
2728
+ # test Rigor reads: a class or module constant, a range literal ({.case_equality_target_class}),
2729
+ # or (#1925) a `nil` / `true` / `false` literal. `nil === x` runs `NilClass#===`, which holds only for `nil`
2730
+ # itself whatever the subject's class defines, and `nil` is `NilClass`'s only instance, so `when nil` is
2731
+ # `when NilClass`; `true` and `false` likewise.
2732
+ def when_condition_class(condition, scope)
2733
+ lexical_class_name(condition, scope) || case_equality_target_class(condition) ||
2734
+ SINGLETON_WHEN_LITERAL_CLASSES[unwrap_parens(condition).class]
2735
+ end
2736
+
2737
+ SINGLETON_WHEN_LITERAL_CLASSES = {
2738
+ Prism::NilNode => "NilClass", Prism::TrueNode => "TrueClass", Prism::FalseNode => "FalseClass"
2739
+ }.freeze
2740
+ private_constant :SINGLETON_WHEN_LITERAL_CLASSES
2741
+
2629
2742
  # Maps a case-equality literal receiver to the class whose membership is implied by the
2630
2743
  # truthy edge. `Prism::ParenthesesNode` wrappers are transparently unwrapped (`(1..10)
2631
2744
  # === x` is parsed with the range inside parentheses). Range literals: integer
2632
- # endpoints → `Numeric`; string endpoints → `String`. Regexp literals → `String`. Other
2633
- # shapes return nil so the caller falls through.
2745
+ # endpoints → `Numeric`; string endpoints → `String`. Regexp literals → `String`, read only
2746
+ # as the class a dead `when` arm names: their narrowing is {#regexp_case_equality_type}'s,
2747
+ # which the callers consult first. Other shapes return nil so the caller falls through.
2634
2748
  def case_equality_target_class(receiver)
2635
2749
  receiver = unwrap_parens(receiver)
2636
2750
  case receiver
@@ -2639,6 +2753,33 @@ module Rigor
2639
2753
  end
2640
2754
  end
2641
2755
 
2756
+ def regexp_literal?(node)
2757
+ case unwrap_parens(node)
2758
+ when Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode then true
2759
+ else false
2760
+ end
2761
+ end
2762
+
2763
+ # Issue #1955 — the subject on the match edge of `/re/ === x` (and of `case x when /re/`). `Regexp#===`
2764
+ # matches a String, a Symbol by its name, and an object that converts with `to_str`, and answers false for
2765
+ # anything else. Each member narrows to its String and Symbol parts: a `String | nil` subject reads `String`, a
2766
+ # Symbol one stays `Symbol`. The `to_str` conversion is not modelled, so a member that defines `to_str` and is
2767
+ # neither drops out of the edge. A `Dynamic` member is left as it is, since it may be a Symbol at run time. The
2768
+ # no-match edge keeps the entry type: a String that does not match reaches it too.
2769
+ def regexp_case_equality_type(current, scope)
2770
+ members = current.is_a?(Type::Union) ? current.members : [current]
2771
+ Type::Combinator.union(*members.map { |member| regexp_case_equality_member(member, scope) })
2772
+ end
2773
+
2774
+ def regexp_case_equality_member(member, scope)
2775
+ return member if member.is_a?(Type::Dynamic)
2776
+
2777
+ parts = %w[String Symbol].map do |class_name|
2778
+ narrow_class(member, class_name, exact: false, environment: scope.environment, scope: scope)
2779
+ end
2780
+ Type::Combinator.union(*parts)
2781
+ end
2782
+
2642
2783
  def unwrap_parens(node)
2643
2784
  while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode) &&
2644
2785
  node.body.body.size == 1
@@ -2818,15 +2959,15 @@ module Rigor
2818
2959
  # narrowed type for the subject as if `condition === subject`, and accumulates them.
2819
2960
  # The body's narrowed type is the union across all conditions; the falsey type is the
2820
2961
  # running result after subtracting every condition's class. Conditions whose shape we
2821
- # cannot statically classify are treated as "no narrowing": the body falls back to the
2822
- # union of what we did learn (or the entry type when nothing learned), and the falsey
2823
- # edge is the entry type (because we cannot prove the unknown condition didn't match).
2962
+ # cannot statically classify narrow neither edge: the body reads as
2963
+ # {.case_when_body_type} says, and the falsey edge is the entry type.
2824
2964
  #
2825
2965
  # `[body type, next-clause type]` of a `when` clause's conditions over the subject type `current`.
2826
2966
  def case_when_types(scope, current, conditions)
2827
2967
  truthy_members = []
2828
2968
  falsey_type = current
2829
2969
  fully_narrowable = true
2970
+ unread = false
2830
2971
 
2831
2972
  conditions.each do |condition|
2832
2973
  applied = apply_case_when_condition(scope, current, condition, falsey_type)
@@ -2835,12 +2976,24 @@ module Rigor
2835
2976
  falsey_type = applied[:falsey]
2836
2977
  fully_narrowable &&= applied[:fully_narrowable]
2837
2978
  else
2979
+ unread = true
2838
2980
  fully_narrowable = false
2839
2981
  end
2840
2982
  end
2841
2983
 
2842
- truthy = truthy_members.empty? ? current : Type::Combinator.union(*truthy_members)
2843
- [truthy, fully_narrowable ? falsey_type : current]
2984
+ [case_when_body_type(current, truthy_members, unread), fully_narrowable ? falsey_type : current]
2985
+ end
2986
+
2987
+ # #1925 — the body type of a `when` clause. A clause whose conditions are all unread keeps the entry type. A
2988
+ # clause mixing read conditions with an unread one reads `Dynamic[top]`: the body may run because the unread
2989
+ # condition matched, so the read conditions' union is unsound (`when nil, ""` is not `nil`), and the entry
2990
+ # type is wider than what reaches the body when the unread condition is itself a test
2991
+ # (`when /a/, ->(v) { !v.nil? }` never enters with `nil`), so reading it would report a call no `nil` reaches.
2992
+ def case_when_body_type(current, truthy_members, unread)
2993
+ return current if truthy_members.empty?
2994
+ return Type::Combinator.untyped if unread
2995
+
2996
+ Type::Combinator.union(*truthy_members)
2844
2997
  end
2845
2998
 
2846
2999
  # Per-condition rule. Returns `nil` when the condition shape is not recognised (caller
@@ -2855,7 +3008,11 @@ module Rigor
2855
3008
  return integer_literal_when_result(current, int_literal, falsey_acc)
2856
3009
  end
2857
3010
 
2858
- target = lexical_class_name(condition, scope) || case_equality_target_class(condition)
3011
+ if regexp_literal?(condition)
3012
+ return { truthy: regexp_case_equality_type(current, scope), falsey: falsey_acc, fully_narrowable: true }
3013
+ end
3014
+
3015
+ target = when_condition_class(condition, scope)
2859
3016
  return class_when_result(scope, current, target, falsey_acc) if target
2860
3017
 
2861
3018
  nil
@@ -3677,9 +3834,12 @@ module Rigor
3677
3834
  # that edge, so a `$_` the earlier operand narrowed is forgotten when the operand may set it
3678
3835
  # (`gets && log(items.each { gets })`), before the operand's own edges narrow it again.
3679
3836
  # Issue #1429 — likewise a guard's narrowing of a global or constant the operand may rebind
3680
- # (`$sep && reset_sep && $sep.length`).
3837
+ # (`$sep && reset_sep && $sep.length`). Issue #1963 — and an instance variable's narrowing an implicit-self
3838
+ # or `self.` call in the operand may write ({InterveningIvarReset.after_operand}): `@x.nil? && reset!` does
3839
+ # not leave `@x` `nil` when `reset!` assigns it.
3681
3840
  def operand_edges(node, edge_scope)
3682
3841
  edge_scope = LastLine.forget_if_set(edge_scope, node)
3842
+ edge_scope = InterveningIvarReset.after_operand(node, edge_scope)
3683
3843
  if edge_scope.guard_narrowed? && GuardRebinding.may_rebind?(node, edge_scope)
3684
3844
  edge_scope = edge_scope.forget_guard_narrowings
3685
3845
  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)