rigortype 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +16 -0
  5. data/docs/manual/02-cli-reference.md +4 -3
  6. data/docs/manual/03-configuration.md +16 -5
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/07-plugins.md +1 -1
  9. data/docs/manual/10-mcp-server.md +32 -3
  10. data/docs/manual/18-removing-dead-code.md +11 -1
  11. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  12. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  13. data/exe/rigor +12 -17
  14. data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
  15. data/lib/rigor/analysis/check_rules.rb +46 -36
  16. data/lib/rigor/analysis/incremental.rb +12 -0
  17. data/lib/rigor/analysis/incremental_session.rb +121 -21
  18. data/lib/rigor/analysis/reachability/graph.rb +23 -3
  19. data/lib/rigor/analysis/reachability/scan.rb +51 -9
  20. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  21. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  22. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  26. data/lib/rigor/analysis/runner.rb +35 -9
  27. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  28. data/lib/rigor/analysis/worker_session.rb +57 -1
  29. data/lib/rigor/cache/descriptor.rb +40 -6
  30. data/lib/rigor/cache/incremental_snapshot.rb +20 -4
  31. data/lib/rigor/cli/mcp_command.rb +1 -1
  32. data/lib/rigor/config_audit.rb +111 -15
  33. data/lib/rigor/configuration/dependencies.rb +19 -11
  34. data/lib/rigor/environment/rbs_loader.rb +46 -10
  35. data/lib/rigor/inference/block_call_timing.rb +5 -0
  36. data/lib/rigor/inference/captured_locals.rb +26 -3
  37. data/lib/rigor/inference/definer_resolution.rb +16 -7
  38. data/lib/rigor/inference/expression_typer.rb +59 -4
  39. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  40. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  41. data/lib/rigor/inference/module_function_state.rb +80 -13
  42. data/lib/rigor/inference/narrowing.rb +184 -68
  43. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  44. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  45. data/lib/rigor/inference/scope_indexer.rb +439 -70
  46. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  47. data/lib/rigor/inference/statement_evaluator.rb +84 -29
  48. data/lib/rigor/lens/anchor.rb +3 -3
  49. data/lib/rigor/lens/contract_reader.rb +15 -0
  50. data/lib/rigor/lens/document.rb +2 -2
  51. data/lib/rigor/lens/file_reader.rb +442 -73
  52. data/lib/rigor/lens/members.rb +65 -15
  53. data/lib/rigor/lens/run.rb +44 -2
  54. data/lib/rigor/lens.rb +2 -1
  55. data/lib/rigor/mcp/server.rb +61 -20
  56. data/lib/rigor/plugin/box_probe.rb +154 -7
  57. data/lib/rigor/project_environment.rb +6 -3
  58. data/lib/rigor/scope.rb +11 -6
  59. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  60. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  61. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  62. data/lib/rigor/sig_gen/writer.rb +47 -34
  63. data/lib/rigor/version.rb +1 -1
  64. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  65. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  66. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  67. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  68. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  69. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  70. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  71. metadata +4 -1
@@ -511,7 +511,8 @@ module Rigor
511
511
  # none of the condition shapes are recognised, both returned scopes equal the input
512
512
  # scope. The catalogue mirrors {.case_equality_target_class}: static class/module
513
513
  # constants narrow as `is_a?`; integer/float-endpoint ranges narrow to `Numeric`;
514
- # string-endpoint ranges and regexp literals narrow to `String`.
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
516
  def case_when_scopes(subject, conditions, scope)
516
517
  # C1 — `case x when /re/` runs `/re/ === x`, which sets the regex match-data globals
517
518
  # exactly as a successful `=~` does. Narrow `$~`/`$&`/`$1..$N` on the clause body (the
@@ -535,6 +536,30 @@ module Rigor
535
536
  CASE_SUBJECT_KINDS = %i[local ivar global constant].freeze
536
537
  private_constant :CASE_SUBJECT_KINDS
537
538
 
539
+ # Returns `[body_scope, falsey_scope]` for one clause of a `case` with a subject: a `when` clause narrows
540
+ # through {.case_when_scopes}, and an `in` clause only for a bare class pattern ({.bare_class_pattern_node}),
541
+ # which matches exactly when `subject.is_a?(C)`. Every other pattern can fail to match where a class test
542
+ # passes (deconstruction arity, hash keys, a guard), so it narrows neither edge. The pattern's bindings are
543
+ # the caller's to apply.
544
+ def case_clause_scopes(subject, clause, scope)
545
+ return case_when_scopes(subject, clause.conditions, scope) if clause.is_a?(Prism::WhenNode)
546
+
547
+ class_node = bare_class_pattern_node(clause.pattern)
548
+ class_node ? case_when_scopes(subject, [class_node], scope) : [scope, scope]
549
+ end
550
+
551
+ # ADR-47 WD3a — the class-constant node of a `in C` / `in C => x` pattern (the only `in` shapes whose match is
552
+ # pure `is_a?`), or nil for any pattern that deconstructs, binds, or matches a value.
553
+ def bare_class_pattern_node(pattern)
554
+ case pattern
555
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
556
+ pattern
557
+ when Prism::CapturePatternNode
558
+ value = pattern.value
559
+ value if value.is_a?(Prism::ConstantReadNode) || value.is_a?(Prism::ConstantPathNode)
560
+ end
561
+ end
562
+
538
563
  # When the clause has exactly one `RegularExpressionNode` literal condition, narrow the
539
564
  # match-data globals on the body edge (same rule as `analyse_regex_match_predicate`'s
540
565
  # truthy edge). With multiple regex conditions (`when /a/, /b/`) the body is reachable
@@ -1088,12 +1113,17 @@ module Rigor
1088
1113
  # in {Rigor::Builtins::RegexRefinement::RULES}, the truthy branch narrows further than
1089
1114
  # `String` to the matching imported refinement (e.g. `decimal-int-string` for `\d+`).
1090
1115
  # Bodies outside the table fall back to the v0.1.0 baseline (plain `String`).
1116
+ #
1117
+ # The match rebinds `$~` and the other match globals on both edges, and this rule binds none of them (#1482),
1118
+ # so each edge forgets what an earlier match bound. The statement evaluator's walk already forgets them in the
1119
+ # scope the match leaves; the per-node scope walk of a conditional it did not enter narrows from the scope
1120
+ # before the match, where the falsey edge of an earlier `=~` had bound `$1` to `nil` (#1495).
1091
1121
  def analyse_match_write(node, scope)
1092
1122
  string_t = Type::Combinator.nominal_of("String")
1093
1123
  nil_t = Type::Combinator.constant_of(nil)
1094
1124
  refinements = match_write_capture_refinements(node)
1095
- truthy = scope
1096
- falsey = scope
1125
+ truthy = scope.forget_match_globals
1126
+ falsey = truthy
1097
1127
  node.targets.each do |target|
1098
1128
  next unless target.is_a?(Prism::LocalVariableTargetNode)
1099
1129
 
@@ -1575,23 +1605,59 @@ module Rigor
1575
1605
  end
1576
1606
 
1577
1607
  # Survey item (b): `/regex/ =~ str` and `str =~ /regex/` bind the regex match-data
1578
- # globals on each edge.
1608
+ # globals on each edge ({#regex_match_edges}), gated on the subject (#1494): an edge is
1609
+ # narrowed only when the receiver proves what the frame holds on it
1610
+ # ({#match_receiver_edges}), and an edge it cannot vouch for keeps the match globals
1611
+ # forgotten. A `String?` subject keeps the match edge only, since `NilClass#=~` answers
1612
+ # nil with the previous match still bound; an untyped one is read the same way.
1579
1613
  #
1580
- # - Truthy edge (`=~` returned an Integer position — the match succeeded): `$~` to
1614
+ # Returns nil (no narrowing) when the receiver / argument pair does not resolve to exactly one
1615
+ # regex pattern we can count (see {#regex_match_pattern}).
1616
+ def analyse_regex_match_predicate(node, scope)
1617
+ gated_regex_match_edges(node, scope)
1618
+ end
1619
+
1620
+ # Issue #1379 — `subject !~ pattern` is `Kernel#!~`, which returns `!(subject =~ pattern)`. It runs the same
1621
+ # match in the caller's frame, so its edges are {#analyse_regex_match_predicate}'s swapped: the falsey edge is
1622
+ # the match edge (`$~` a `MatchData`, an unconditional `$N` a `String`, and the whole-receiver refinement), the
1623
+ # truthy edge the no-match edge (`$~` and `$N` nil). The same patterns are recognised and the same ones
1624
+ # declined (a non-regex operand, an ambiguous constant, `//x`), and the receiver gates the edges alike.
1625
+ def analyse_regex_not_match_predicate(node, scope)
1626
+ gated_regex_match_edges(node, scope)&.reverse
1627
+ end
1628
+
1629
+ # `[match_edge, no_match_edge]` of a `=~` / `!~` predicate, each kept only where
1630
+ # {#match_receiver_edges} lets it be; an edge it cannot vouch for is the entry scope with the
1631
+ # match globals forgotten, since the call may still have rebound them. The whole-receiver
1632
+ # refinement is kept only when the local it would narrow is known to be a String on the
1633
+ # match edge ({#refinable_operand?}).
1634
+ def gated_regex_match_edges(node, scope)
1635
+ edges = regex_match_edges(node, scope, refine: refinable_operand?(node, scope))
1636
+ return nil if edges.nil?
1637
+
1638
+ match, no_match = edges
1639
+ forgotten = scope.forget_match_globals
1640
+ case match_receiver_edges(node, scope)
1641
+ when :both then [match, no_match]
1642
+ when :match then [match, forgotten]
1643
+ else [forgotten, forgotten]
1644
+ end
1645
+ end
1646
+
1647
+ # The ungated edges of a match:
1648
+ #
1649
+ # - Match edge (`=~` returned an Integer position — the match succeeded): `$~` to
1581
1650
  # `Nominal[MatchData]`; `$&` and `$1..$N` (where N is the number of capture groups in
1582
1651
  # the regex source) to `Nominal[String]`. This is the same optimistic-narrowing shape
1583
1652
  # the existing `analyse_match_write` uses for named captures inside `if /(?<x>...)/
1584
1653
  # =~ str` — optional groups in the regex source (`(\d+)?`) would bind `$N` to `nil`
1585
1654
  # at runtime, but the floor here matches the common idiom (required captures) and
1586
1655
  # lets `unless /(\d+)/ =~ s; raise; end; $1.to_i` resolve cleanly.
1587
- # - Falsey edge (`=~` returned nil — no match): `$~` and every numbered / back-reference
1656
+ # - No-match edge (`=~` returned nil): `$~` and every numbered / back-reference
1588
1657
  # global bound to `Constant<nil>`.
1589
1658
  #
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
1659
  # `refine: false` leaves out the whole-receiver refinement, which names a String.
1594
- def analyse_regex_match_predicate(node, scope, refine: true)
1660
+ def regex_match_edges(node, scope, refine:)
1595
1661
  return nil if node.arguments.nil?
1596
1662
  return nil unless node.arguments.arguments.size == 1
1597
1663
 
@@ -1615,90 +1681,113 @@ module Rigor
1615
1681
  [truthy, falsey]
1616
1682
  end
1617
1683
 
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:
1684
+ # Which edges of `recv =~ pattern` / `recv !~ pattern` the receiver lets {#gated_regex_match_edges} narrow:
1685
+ # `:both`, `:match` (the match edge only) or nil. `Kernel#!~` dispatches `=~` on the receiver:
1643
1686
  #
1644
1687
  # - A Regexp receiver (the pattern on the left) runs `Regexp#=~`, which sets `$~` on both outcomes, a `nil`
1645
1688
  # 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.
1689
+ # - `String#=~` and `Symbol#=~` do the same: `:both` when every member of the receiver's type is a String
1690
+ # (a project subclass of String included) or a Symbol.
1648
1691
  # - `NilClass#=~` answers nil and leaves `$~` alone, so a nil receiver reaches the no-match edge with the
1649
1692
  # 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.
1693
+ # whatever its static facet says. So does a safe-navigation call, which answers nil without matching on a
1694
+ # nil receiver.
1653
1695
  #
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)
1696
+ # The two operators part over a receiver that is none of those ({#match_operator_edges},
1697
+ # {#not_match_operator_edges}).
1698
+ def match_receiver_edges(node, scope)
1699
+ return nil if node.receiver.nil?
1700
+
1701
+ edges =
1702
+ if regex_operand_pattern(node.receiver, scope).is_a?(RegexMatchPattern)
1703
+ :both
1704
+ else
1705
+ kinds = match_member_kinds(scope.type_of(node.receiver), scope)
1706
+ node.name == :=~ ? match_operator_edges(kinds) : not_match_operator_edges(kinds)
1707
+ end
1708
+ return edges unless node.safe_navigation?
1658
1709
 
1659
- kinds = not_match_member_kinds(scope.type_of(node.receiver), scope)
1660
- return :both if kinds.all? { |kind| %i[string symbol].include?(kind) }
1710
+ :match if edges && node.name == :=~
1711
+ end
1712
+
1713
+ STRING_MATCH_KINDS = %i[string string_subclass symbol].freeze
1714
+ private_constant :STRING_MATCH_KINDS
1661
1715
 
1662
- :match if kinds.all? { |kind| %i[string symbol nil dynamic].include?(kind) }
1716
+ # `recv =~ pattern`'s match edge is the truthy one. Only a member known to define its own `=~` in the
1717
+ # project keeps it out (#1494): that method binds `$~` in its own frame, not the caller's. Any other member
1718
+ # either matches in the caller's frame, answers nil (`NilClass`), or has no `=~` at all (`Integer` since
1719
+ # Ruby 3.2 drops `Object#=~`) and so never reaches the edge; a receiver the analyzer cannot classify keeps the
1720
+ # edge, as every `=~` did before #1494.
1721
+ def match_operator_edges(kinds)
1722
+ return nil if kinds.include?(:custom)
1723
+ return :both if kinds.all? { |kind| STRING_MATCH_KINDS.include?(kind) }
1724
+
1725
+ :match
1726
+ end
1727
+
1728
+ # `recv !~ pattern`'s match edge is the falsey one, which a receiver whose `=~` is not the caller's match
1729
+ # also reaches, so it is kept only when every member is a String, a Symbol, nil, or a `Dynamic` that is
1730
+ # untyped or whose facet holds only those (#1379).
1731
+ def not_match_operator_edges(kinds)
1732
+ return :both if kinds.all? { |kind| STRING_MATCH_KINDS.include?(kind) }
1733
+
1734
+ :match if kinds.all? { |kind| (STRING_MATCH_KINDS + %i[nil dynamic]).include?(kind) }
1663
1735
  end
1664
1736
 
1665
1737
  # Whether the local operand {#apply_whole_receiver_refinement} would narrow holds only Strings and nil, which
1666
1738
  # 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.
1739
+ # untyped `Dynamic` that may be one at run time — keeps it out: `return unless raw =~ /\A\d+\z/` on an untyped
1740
+ # `raw` must not make `raw.to_proc` an undefined method (#1379, #1494).
1669
1741
  def refinable_operand?(node, scope)
1670
1742
  operand = [node.receiver, *node.arguments&.arguments].find { |o| o.is_a?(Prism::LocalVariableReadNode) }
1671
1743
  type = operand && scope.local(operand.name)
1672
1744
  return true if type.nil?
1673
1745
 
1674
- not_match_member_kinds(type, scope).all? { |kind| %i[string nil].include?(kind) }
1746
+ match_member_kinds(type, scope).all? { |kind| %i[string nil].include?(kind) }
1675
1747
  end
1676
1748
 
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) }
1749
+ def match_member_kinds(type, scope)
1750
+ (type.is_a?(Type::Union) ? type.members : [type]).map { |member| match_member_kind(member, scope) }
1679
1751
  end
1680
1752
 
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)
1753
+ # `:custom` for a member whose class the project gives a `=~` of its own (in its ancestry, a reopened
1754
+ # `String` included); `:string` or `:symbol` for a member {.narrow_not_class} proves a String or a Symbol (a
1755
+ # value or refinement of either, or a class the environment orders below it); `:string_subclass` for a
1756
+ # project class whose superclass chain reaches `String`, which the environment cannot order; `:nil`;
1757
+ # `:dynamic` for a `Dynamic` whose facet is untyped or holds only Strings, Symbols and nil; or nil for anything
1758
+ # else.
1759
+ def match_member_kind(member, scope)
1760
+ return match_dynamic_kind(member, scope) if member.is_a?(Type::Dynamic)
1687
1761
  return :nil if narrow_nil(member) == member && !member.is_a?(Type::Bot)
1762
+ return :custom if custom_match_operator?(member, scope)
1688
1763
 
1689
1764
  environment = scope.environment
1690
1765
  { "String" => :string, "Symbol" => :symbol }.each do |class_name, kind|
1691
1766
  return kind if narrow_not_class(member, class_name, environment: environment, scope: scope).is_a?(Type::Bot)
1692
1767
  end
1693
- nil
1768
+ :string_subclass if project_string_subclass?(member, scope)
1694
1769
  end
1695
1770
 
1696
- def not_match_dynamic_kind(member, scope)
1771
+ def match_dynamic_kind(member, scope)
1697
1772
  facet = member.static_facet
1698
1773
  return :dynamic if facet.is_a?(Type::Top)
1699
1774
 
1700
- facet_kinds = not_match_member_kinds(facet, scope)
1701
- :dynamic if facet_kinds.all? { |kind| %i[string symbol nil].include?(kind) }
1775
+ facet_kinds = match_member_kinds(facet, scope)
1776
+ :dynamic if facet_kinds.all? { |kind| (STRING_MATCH_KINDS + [:nil]).include?(kind) }
1777
+ end
1778
+
1779
+ def custom_match_operator?(member, scope)
1780
+ targets = ProjectMethodOwnership.targets(member)
1781
+ return false if targets.nil?
1782
+
1783
+ targets.any? { |class_name, kind| ProjectMethodOwnership.defines?(class_name, :=~, kind, scope) }
1784
+ end
1785
+
1786
+ def project_string_subclass?(member, scope)
1787
+ return false unless member.is_a?(Type::Nominal) && scope.known_user_class?(member.class_name)
1788
+
1789
+ scope.external_ancestor_name_candidates(member.class_name, mixins: false)
1790
+ .flatten.any? { |name| name.to_s.delete_prefix("::") == "String" }
1702
1791
  end
1703
1792
 
1704
1793
  # `str.match?(/\A\d+\z/)` — `String#match?` is a pure boolean predicate whose truthy edge
@@ -2626,6 +2715,21 @@ module Rigor
2626
2715
  ]
2627
2716
  end
2628
2717
 
2718
+ # 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}),
2720
+ # or (#1925) a `nil` / `true` / `false` literal. `nil === x` runs `NilClass#===`, which holds only for `nil`
2721
+ # itself whatever the subject's class defines, and `nil` is `NilClass`'s only instance, so `when nil` is
2722
+ # `when NilClass`; `true` and `false` likewise.
2723
+ def when_condition_class(condition, scope)
2724
+ lexical_class_name(condition, scope) || case_equality_target_class(condition) ||
2725
+ SINGLETON_WHEN_LITERAL_CLASSES[unwrap_parens(condition).class]
2726
+ end
2727
+
2728
+ SINGLETON_WHEN_LITERAL_CLASSES = {
2729
+ Prism::NilNode => "NilClass", Prism::TrueNode => "TrueClass", Prism::FalseNode => "FalseClass"
2730
+ }.freeze
2731
+ private_constant :SINGLETON_WHEN_LITERAL_CLASSES
2732
+
2629
2733
  # Maps a case-equality literal receiver to the class whose membership is implied by the
2630
2734
  # truthy edge. `Prism::ParenthesesNode` wrappers are transparently unwrapped (`(1..10)
2631
2735
  # === x` is parsed with the range inside parentheses). Range literals: integer
@@ -2818,15 +2922,15 @@ module Rigor
2818
2922
  # narrowed type for the subject as if `condition === subject`, and accumulates them.
2819
2923
  # The body's narrowed type is the union across all conditions; the falsey type is the
2820
2924
  # 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).
2925
+ # cannot statically classify narrow neither edge: the body reads as
2926
+ # {.case_when_body_type} says, and the falsey edge is the entry type.
2824
2927
  #
2825
2928
  # `[body type, next-clause type]` of a `when` clause's conditions over the subject type `current`.
2826
2929
  def case_when_types(scope, current, conditions)
2827
2930
  truthy_members = []
2828
2931
  falsey_type = current
2829
2932
  fully_narrowable = true
2933
+ unread = false
2830
2934
 
2831
2935
  conditions.each do |condition|
2832
2936
  applied = apply_case_when_condition(scope, current, condition, falsey_type)
@@ -2835,12 +2939,24 @@ module Rigor
2835
2939
  falsey_type = applied[:falsey]
2836
2940
  fully_narrowable &&= applied[:fully_narrowable]
2837
2941
  else
2942
+ unread = true
2838
2943
  fully_narrowable = false
2839
2944
  end
2840
2945
  end
2841
2946
 
2842
- truthy = truthy_members.empty? ? current : Type::Combinator.union(*truthy_members)
2843
- [truthy, fully_narrowable ? falsey_type : current]
2947
+ [case_when_body_type(current, truthy_members, unread), fully_narrowable ? falsey_type : current]
2948
+ end
2949
+
2950
+ # #1925 — the body type of a `when` clause. A clause whose conditions are all unread keeps the entry type. A
2951
+ # clause mixing read conditions with an unread one reads `Dynamic[top]`: the body may run because the unread
2952
+ # condition matched, so the read conditions' union is unsound (`when nil, ""` is not `nil`), and the entry
2953
+ # type is wider than what reaches the body when the unread condition is itself a test
2954
+ # (`when /a/, ->(v) { !v.nil? }` never enters with `nil`), so reading it would report a call no `nil` reaches.
2955
+ def case_when_body_type(current, truthy_members, unread)
2956
+ return current if truthy_members.empty?
2957
+ return Type::Combinator.untyped if unread
2958
+
2959
+ Type::Combinator.union(*truthy_members)
2844
2960
  end
2845
2961
 
2846
2962
  # Per-condition rule. Returns `nil` when the condition shape is not recognised (caller
@@ -2855,7 +2971,7 @@ module Rigor
2855
2971
  return integer_literal_when_result(current, int_literal, falsey_acc)
2856
2972
  end
2857
2973
 
2858
- target = lexical_class_name(condition, scope) || case_equality_target_class(condition)
2974
+ target = when_condition_class(condition, scope)
2859
2975
  return class_when_result(scope, current, target, falsey_acc) if target
2860
2976
 
2861
2977
  nil
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Issue #1889 — `instance_variable_set` called on `self` writes an instance variable that no write node shows.
10
+ # Left unseen, the binding the call site holds outlived it: `@n = 0; instance_variable_set(:@n, 5)` kept `@n`
11
+ # on `0`, and a block that wrote `@n` only this way never entered the captured-binding name set, so
12
+ # `[1, 2].inject(0) { |a, e| instance_variable_set(:@n, @n + 1); a + @n }` folded to a constant.
13
+ #
14
+ # A call counts when its receiver is implicit or `self` and it passes any argument ({.self_write?}). With
15
+ # exactly two plain positional arguments, the first a Symbol or String literal naming an instance variable
16
+ # (`:@n`, `"@n"`; Ruby accepts both), it is a write of that variable ({.literal_name}). Any other argument list
17
+ # — a computed name, a splat (`instance_variable_set(*args)`), a keyword hash, or a literal Ruby rejects with
18
+ # `NameError` (`:n`) — names an ivar the analysis cannot see, so every ivar the scope binds may have been
19
+ # written ({.apply}; for a rejected literal this over-approximates, which only widens).
20
+ # `obj.instance_variable_set` writes another object's ivar and does not count; neither does the reflective
21
+ # `send(:instance_variable_set, …)`.
22
+ module ReflectiveIvarWrite
23
+ METHOD_NAME = :instance_variable_set
24
+ private_constant :METHOD_NAME
25
+
26
+ IVAR_NAME = /\A@[A-Za-z_]\w*\z/
27
+ private_constant :IVAR_NAME
28
+
29
+ REPEATING_KEY = :__rigor_reflective_ivar_write_repeating__
30
+ private_constant :REPEATING_KEY
31
+
32
+ module_function
33
+
34
+ # True when `node` is an `instance_variable_set` on `self` with at least one argument.
35
+ def self_write?(node)
36
+ return false unless node.is_a?(Prism::CallNode) && node.name == METHOD_NAME
37
+ return false unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
38
+
39
+ arguments = node.arguments&.arguments
40
+ !arguments.nil? && !arguments.empty?
41
+ end
42
+
43
+ # `:@n` for `instance_variable_set(:@n, v)` or `instance_variable_set("@n", v)` on `self`, else nil.
44
+ def literal_name(node)
45
+ return nil unless self_write?(node)
46
+
47
+ arguments = node.arguments.arguments
48
+ return nil unless arguments.size == 2 && arguments.none? { |arg| splat_or_keywords?(arg) }
49
+
50
+ name = arguments.first
51
+ text =
52
+ case name
53
+ when Prism::SymbolNode, Prism::StringNode then name.unescaped
54
+ end
55
+ text && IVAR_NAME.match?(text) ? text.to_sym : nil
56
+ end
57
+
58
+ # True for a call {.self_write?} takes whose target {.literal_name} cannot name: it may write any ivar.
59
+ def unknown_target?(node)
60
+ self_write?(node) && literal_name(node).nil?
61
+ end
62
+
63
+ # The value argument of a call {.literal_name} names.
64
+ def value_node(node)
65
+ node.arguments.arguments[1]
66
+ end
67
+
68
+ # Runs the block with the evaluator inside a `while` / `until` / `for` body. The loop's rebind fixpoint widens
69
+ # locals only, so a literal-name write there must not replace the binding with one pass's value
70
+ # ({.apply}).
71
+ def in_repeating_body
72
+ depth = Thread.current[REPEATING_KEY] || 0
73
+ Thread.current[REPEATING_KEY] = depth + 1
74
+ begin
75
+ yield
76
+ ensure
77
+ Thread.current[REPEATING_KEY] = depth
78
+ end
79
+ end
80
+
81
+ def repeating_body?
82
+ (Thread.current[REPEATING_KEY] || 0).positive?
83
+ end
84
+
85
+ # `scope` after `node` runs. A literal name binds that ivar to the value's type (yielded), as `@n = v` does.
86
+ # Inside a loop body ({.in_repeating_body}) the value and `Dynamic[top]` are joined into the current binding
87
+ # instead: the loop's fixpoint widens locals only, so its single pass over an ivar types the value from one
88
+ # iteration's locals (`instance_variable_set(:@n, i + 1)` stored `1`) and would leave that. Any other call joins
89
+ # `Dynamic[top]` into every ivar `scope` binds: any one of them may now hold the value, and joining the
90
+ # value's own type would report a method the old value has and the new one lacks. An ivar on its ADR-58
91
+ # declaration seed keeps its mark, which stops a nil in the seed from fuelling a nil-receiver diagnostic.
92
+ def apply(node, scope)
93
+ return scope unless self_write?(node)
94
+
95
+ name = literal_name(node)
96
+ return bind_literal(scope, name, yield) if name
97
+
98
+ floor = Type::Combinator.untyped
99
+ scope.ivars.reduce(scope) do |acc, (ivar, type)|
100
+ widened = Type::Combinator.union(type, floor)
101
+ rebound = acc.with_ivar(ivar, widened).without_ivar_guard(ivar)
102
+ scope.declaration_sourced?(:ivar, ivar) ? rebound.seed_declaration_sourced_ivar(ivar, widened) : rebound
103
+ end
104
+ end
105
+
106
+ def bind_literal(scope, name, value)
107
+ type = repeating_body? ? Type::Combinator.union(*scope.ivar(name), value, Type::Combinator.untyped) : value
108
+ scope.with_ivar(name, type).without_ivar_guard(name)
109
+ end
110
+
111
+ def splat_or_keywords?(arg)
112
+ arg.is_a?(Prism::SplatNode) || arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::BlockArgumentNode)
113
+ end
114
+ end
115
+ end
116
+ end