rigortype 0.3.0 → 0.3.2

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -18,7 +18,8 @@ module Rigor
18
18
  #
19
19
  # Catalogue (Slice 5 phase 2):
20
20
  #
21
- # - Tuple#`first`, Tuple#`last`, Tuple#`size`/`length`/`count`: no-arg, no-block.
21
+ # - Tuple#`first`, Tuple#`last` (no-arg, or a single `Constant[Integer]` count lifting to a
22
+ # sub-`Tuple`, the `take`/`drop` shape), Tuple#`size`/`length`/`count`: no-block.
22
23
  # - Tuple#`[]`, Tuple#`fetch` with a single `Constant[Integer]` argument inside the tuple's bounds
23
24
  # (negative indices are normalised by length). Tuple#`[]` also handles static Range and
24
25
  # start-length slices, returning a sliced Tuple or `Constant[nil]` for statically nil slices.
@@ -82,6 +83,16 @@ module Rigor
82
83
  drop: :tuple_drop,
83
84
  rotate: :tuple_rotate,
84
85
  uniq: :tuple_uniq,
86
+ at: :tuple_at,
87
+ :& => :tuple_intersection,
88
+ intersection: :tuple_intersection,
89
+ :| => :tuple_union,
90
+ union: :tuple_union,
91
+ :- => :tuple_difference,
92
+ difference: :tuple_difference,
93
+ intersect?: :tuple_intersect?,
94
+ one?: :tuple_one?,
95
+ deconstruct: :shape_self,
85
96
  index: :tuple_find_index,
86
97
  find_index: :tuple_find_index,
87
98
  rindex: :tuple_rindex,
@@ -672,23 +683,39 @@ module Rigor
672
683
  Type::Combinator.integer_range(min, max)
673
684
  end
674
685
 
675
- # `first` (no arg) → the first element (or `Constant[nil]` when empty). The `first(n)` arg-form
676
- # is deliberately left to RBS overload selection (see the overload-selection specs) folding it
677
- # here would change that documented `Array[Elem]` contract.
686
+ # `first` (no arg) → the first element (or `Constant[nil]` when empty). `first(n)` → a `Tuple` of
687
+ # the leading `n` elements, the same `take`/`drop` shape via `non_negative_count_arg` (`n >= size`
688
+ # returns the full receiver, a non-static or negative count declines to RBS).
678
689
  def tuple_first(tuple, _method_name, args)
679
- return nil unless args.empty?
690
+ return tuple_first_n(tuple, args) unless args.empty?
680
691
  return Type::Combinator.constant_of(nil) if tuple.elements.empty?
681
692
 
682
693
  tuple.elements.first
683
694
  end
684
695
 
696
+ def tuple_first_n(tuple, args)
697
+ n = non_negative_count_arg(args)
698
+ return nil if n.nil?
699
+
700
+ Type::Combinator.tuple_of(*tuple.elements.first(n))
701
+ end
702
+
703
+ # `last` (no arg) → the last element (or `Constant[nil]` when empty). `last(n)` → a `Tuple` of the
704
+ # trailing `n` elements, mirroring `tuple_first_n`.
685
705
  def tuple_last(tuple, _method_name, args)
686
- return nil unless args.empty?
706
+ return tuple_last_n(tuple, args) unless args.empty?
687
707
  return Type::Combinator.constant_of(nil) if tuple.elements.empty?
688
708
 
689
709
  tuple.elements.last
690
710
  end
691
711
 
712
+ def tuple_last_n(tuple, args)
713
+ n = non_negative_count_arg(args)
714
+ return nil if n.nil?
715
+
716
+ Type::Combinator.tuple_of(*tuple.elements.last(n))
717
+ end
718
+
692
719
  def tuple_size(tuple, _method_name, args)
693
720
  return nil unless args.empty?
694
721
 
@@ -957,6 +984,88 @@ module Rigor
957
984
 
958
985
  # `tuple + other` — concatenates two Tuples. Both sides must be `Type::Tuple`. Returns a new
959
986
  # Tuple whose elements are those of the receiver followed by those of the argument.
987
+ # `at(n)` — the strict single-Integer accessor. Deliberately NOT an alias of {#tuple_index}: `[]`
988
+ # and `slice` accept a Range or a `(start, length)` pair, while `Array#at` raises `ArgumentError`
989
+ # on anything but one Integer, and a fold must never invent a value for a call that raises.
990
+ #
991
+ # An out-of-range index declines rather than folding to `Constant[nil]`, matching {#tuple_index}.
992
+ # Ruby really does return nil there, so the fold would be correct — but it would also turn a
993
+ # receiver the RBS tier types as `Elem?` into a proven nil, newly surfacing diagnostics on code
994
+ # that may be guarded in ways a shape cannot see. Precision that fires a new diagnostic is a
995
+ # separate decision from precision that removes a `Dynamic` (#121).
996
+ def tuple_at(tuple, _method_name, args)
997
+ return nil unless args.size == 1
998
+ return nil unless args.first.is_a?(Type::Constant) && args.first.value.is_a?(Integer)
999
+
1000
+ tuple.elements[args.first.value]
1001
+ end
1002
+
1003
+ # `a & b` / `a.intersection(b, ...)`, `a | b` / `a.union(...)`, `a - b` / `a.difference(...)`.
1004
+ # Every set operation is evaluated by running Ruby's own operator over the unwrapped values, which
1005
+ # is the only way to reproduce its `eql?` / `hash` semantics exactly — `[1] & [1.0]` is empty even
1006
+ # though `1 == 1.0`, so an equality-based reimplementation would fold the wrong answer.
1007
+ def tuple_intersection(tuple, _method_name, args)
1008
+ set_operation(tuple, args) { |values, others| others.reduce(values, :&) }
1009
+ end
1010
+
1011
+ def tuple_union(tuple, _method_name, args)
1012
+ set_operation(tuple, args) { |values, others| others.reduce(values, :|) }
1013
+ end
1014
+
1015
+ def tuple_difference(tuple, _method_name, args)
1016
+ set_operation(tuple, args) { |values, others| others.reduce(values, :-) }
1017
+ end
1018
+
1019
+ # `a.intersect?(b)` — the predicate form, `Constant[bool]`. `nil` is the decline signal every
1020
+ # handler shares, not a boolean answer, so the predicate-return cop is disabled as it is for the
1021
+ # other `?`-named handlers above.
1022
+ # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
1023
+ def tuple_intersect?(tuple, _method_name, args)
1024
+ result = set_operation(tuple, args) { |values, others| others.reduce(values, :&) }
1025
+ return nil if result.nil?
1026
+
1027
+ Type::Combinator.constant_of(!result.elements.empty?)
1028
+ end
1029
+
1030
+ # Shared body for the set operations: unwrap the receiver and every Tuple argument, hand the raw
1031
+ # values to `block`, and lift the result back. Declines unless every element on both sides is a
1032
+ # `Constant` (an unknown element makes membership undecidable) and every argument is a `Tuple` —
1033
+ # `Array#&` also accepts anything that responds to `to_ary`, which a shape cannot prove.
1034
+ def set_operation(tuple, args)
1035
+ return nil if args.empty? || args.size > MAX_SET_OPERATION_ARITY
1036
+
1037
+ values = constant_values(tuple.elements)
1038
+ return nil if values.nil?
1039
+
1040
+ others = args.map do |arg|
1041
+ return nil unless arg.is_a?(Type::Tuple)
1042
+
1043
+ constant_values(arg.elements) || (return nil)
1044
+ end
1045
+ result = yield(values, others)
1046
+ return nil if result.size > MAX_SET_OPERATION_SIZE
1047
+
1048
+ Type::Combinator.tuple_of(*result.map { |value| Type::Combinator.constant_of(value) })
1049
+ end
1050
+
1051
+ # An argument list longer than this, or a result wider than this, declines to the RBS tier rather
1052
+ # than materialising an unbounded Tuple — the same discipline as {MAX_ZIP_ARITY} / the join cap.
1053
+ MAX_SET_OPERATION_ARITY = 8
1054
+ MAX_SET_OPERATION_SIZE = 64
1055
+ private_constant :MAX_SET_OPERATION_ARITY, :MAX_SET_OPERATION_SIZE
1056
+
1057
+ # `one?` with no block and no pattern — `Constant[bool]` of "exactly one truthy element". Only
1058
+ # `Constant` elements have decidable truthiness, and the block / pattern forms defer.
1059
+ def tuple_one?(tuple, _method_name, args)
1060
+ return nil unless args.empty?
1061
+
1062
+ values = constant_values(tuple.elements)
1063
+ return nil if values.nil?
1064
+
1065
+ Type::Combinator.constant_of(values.one? { |value| value })
1066
+ end
1067
+ # rubocop:enable Style/ReturnNilInPredicateMethodDefinition
1068
+
960
1069
  def tuple_concat(tuple, _method_name, args)
961
1070
  return nil unless args.size == 1
962
1071
 
@@ -1558,13 +1667,16 @@ module Rigor
1558
1667
  Type::Combinator.hash_shape_of(shape.pairs.merge(other.pairs))
1559
1668
  end
1560
1669
 
1561
- # `shape[k]` and `shape.fetch(k)` for a value-pinned scalar key. Missing-key resolution depends
1562
- # on the method:
1670
+ # `shape[k]` and `shape.fetch(k)` for a value-pinned scalar key. On a CLOSED shape a key outside
1671
+ # `pairs` is provably missing, and the resolution depends on the method:
1563
1672
  #
1564
1673
  # - `[]` returns `nil` at runtime; we surface `Constant[nil]` so the carrier is visible to
1565
1674
  # downstream narrowing.
1566
1675
  # - `fetch` (no default, no block) raises `KeyError`; we let the projection answer apply because
1567
1676
  # the runtime would not produce a value.
1677
+ #
1678
+ # On an `:open` shape neither applies — the key is unknown rather than missing, so both methods
1679
+ # answer the `untyped` step from {#hash_dig_step}.
1568
1680
  def hash_lookup(shape, method_name, args)
1569
1681
  return nil unless args.size == 1
1570
1682
 
@@ -1594,6 +1706,12 @@ module Rigor
1594
1706
  # Returns the per-step value type for a HashShape lookup (or `Constant[nil]` for a known-missing
1595
1707
  # key). Returns `nil` when the argument is not a value-pinned scalar key so the caller can fall
1596
1708
  # through to the projection answer.
1709
+ #
1710
+ # A key outside `pairs` only resolves to `Constant[nil]` on a CLOSED shape, where `pairs` is the
1711
+ # whole key set and the runtime read provably returns nil. On an `:open` shape the key set is by
1712
+ # definition not exhausted, so an undeclared key is *unknown*, not absent — it may hold a value
1713
+ # of any type. Typing it `Constant[nil]` there would put `call.undefined-method` on the next line
1714
+ # of correct code (`payload[:undeclared].upcase`), so the open case answers `untyped`.
1597
1715
  def hash_dig_step(shape, arg)
1598
1716
  return nil unless arg.is_a?(Type::Constant)
1599
1717
 
@@ -1607,6 +1725,8 @@ module Rigor
1607
1725
  return Type::Combinator.union(value, Type::Combinator.constant_of(nil))
1608
1726
  end
1609
1727
 
1728
+ return Type::Combinator.untyped if shape.open?
1729
+
1610
1730
  Type::Combinator.constant_of(nil)
1611
1731
  end
1612
1732
 
@@ -1616,8 +1736,13 @@ module Rigor
1616
1736
  shape.optional_key?(arg.value)
1617
1737
  end
1618
1738
 
1739
+ # Whether the key is provably ABSENT — which only a closed shape can establish. An open shape
1740
+ # permits keys outside `pairs`, so a key it does not declare is unknown rather than missing, and
1741
+ # `fetch` must not be deferred on the theory that Ruby would raise `KeyError`: the key may well
1742
+ # be there. {#hash_dig_step} has already answered `untyped` for that case.
1619
1743
  def missing_key_step?(shape, arg)
1620
1744
  return false unless arg.is_a?(Type::Constant)
1745
+ return false if shape.open?
1621
1746
 
1622
1747
  !shape.pairs.key?(arg.value)
1623
1748
  end
@@ -1685,7 +1810,9 @@ module Rigor
1685
1810
 
1686
1811
  # Continues a `dig` chain after the first step. Tuple and HashShape members re-dispatch into the
1687
1812
  # catalogue; `Constant[nil]` short-circuits the chain (Hash#dig and Array#dig do the same at
1688
- # runtime); anything else falls through so the projection answer applies.
1813
+ # runtime); an `untyped` step (an undeclared key on an open shape) keeps the whole chain
1814
+ # `untyped`, since digging further into an unknown value cannot recover a bound; anything else
1815
+ # falls through so the projection answer applies.
1689
1816
  def chain_dig(receiver, args)
1690
1817
  return receiver if args.empty?
1691
1818
 
@@ -1693,6 +1820,7 @@ module Rigor
1693
1820
  when Type::Tuple then tuple_dig(receiver, :dig, args)
1694
1821
  when Type::HashShape then hash_dig(receiver, :dig, args)
1695
1822
  when Type::Constant then receiver.value.nil? ? Type::Combinator.constant_of(nil) : nil
1823
+ when Type::Dynamic then Type::Combinator.untyped
1696
1824
  end
1697
1825
  end
1698
1826
  end
@@ -141,7 +141,7 @@ module Rigor
141
141
  map = member_map_for_new(members, keyword_init, context)
142
142
  return degraded_instance if map.nil?
143
143
 
144
- Type::Combinator.struct_instance_of(members: map, class_name: class_name)
144
+ Type::Combinator.struct_instance_of(members: widen_unowned_emptiness(map), class_name: class_name)
145
145
  end
146
146
 
147
147
  # Builds the member -> type map honouring the class's `keyword_init` flag: a `keyword_init: true`
@@ -309,7 +309,8 @@ module Rigor
309
309
  return scope if member.nil?
310
310
 
311
311
  rebound = Type::Combinator.struct_instance_of(
312
- members: current.members.merge(member => assigned_type), class_name: current.class_name
312
+ members: widen_unowned_emptiness(current.members.merge(member => assigned_type)),
313
+ class_name: current.class_name
313
314
  )
314
315
  scope.with_local(receiver.name, rebound)
315
316
  end
@@ -20,12 +20,31 @@ module Rigor
20
20
  # decode. Returns `Constant[String]`.
21
21
  # * `encode_uri_component(str)` / `decode_uri_component(str)` — Same encoding but may preserve
22
22
  # additional reserved chars (Ruby 3.2+). Returns `Constant[String]`.
23
+ # * `encode_www_form(enum)` — the whole-form encoder, over a `Tuple` of `[key, value]` `Tuple`s (an
24
+ # array literal) or a `HashShape` (a hash literal) whose keys and values are all `Constant`.
25
+ # Returns `Constant[String]`.
26
+ # * `decode_www_form(str)` — the inverse, over a `Constant[String]`. Returns a `Tuple` of two-element
27
+ # `Tuple[Constant[String], Constant[String]]` pairs rather than the RBS tier's
28
+ # `Array[[String, String]]`, so a destructuring read of a known form gets the concrete strings.
29
+ # * `extract(str)` — the URIs a constant string contains, as a `Tuple` of `Constant[String]` rather
30
+ # than `Array[String]`. Single-argument form only (see {.fold_extract}).
31
+ #
32
+ # === Deliberately NOT folded
33
+ #
34
+ # `URI.parse` and `URI.join` are the two remaining gaps in this module's coverage note, and both are
35
+ # declined on the repo's own existing rule rather than on judgement: they return `URI::Generic` and
36
+ # friends, which are absent from `ConstantFolding::FOLDABLE_CONSTANT_CLASSES`, so there is no
37
+ # `Constant[…]` for this tier to produce. Narrowing `URI.parse`'s ten-arm return union to the one
38
+ # scheme class a constant string selects WOULD be a real precision win, but it makes a gradually-valid
39
+ # dispatch precise and can therefore surface a diagnostic that does not fire today — which puts it in
40
+ # the bucket-3 / P0 category, not in this FP-safe fold category ([#121](
41
+ # https://github.com/rigortype/rigor/issues/121)).
23
42
  #
24
43
  # === Non-constant / unsupported cases
25
44
  #
26
45
  # Returns `nil` (deferring to the next dispatcher tier) when:
27
46
  # - the receiver is not `Singleton[URI]`,
28
- # - the first argument is not a `Constant[String]`,
47
+ # - an argument is not the constant shape the method's fold requires,
29
48
  # - the method is not in the supported set.
30
49
  module URIFolding
31
50
  URI_COMPONENT_METHODS = Set[
@@ -34,6 +53,12 @@ module Rigor
34
53
  ].freeze
35
54
  private_constant :URI_COMPONENT_METHODS
36
55
 
56
+ # A form long enough to suggest generated or accumulated data rather than a literal a reader wrote;
57
+ # past it the folded `Constant[String]` stops being useful in a diagnostic and only costs memory.
58
+ # Mirrors the byte ceiling `ShapeDispatch`'s own `tuple_join` fold applies for the same reason.
59
+ FORM_PAIR_LIMIT = 64
60
+ private_constant :FORM_PAIR_LIMIT
61
+
37
62
  module_function
38
63
 
39
64
  # @return [Rigor::Type, nil] folded result, or nil to defer.
@@ -42,11 +67,109 @@ module Rigor
42
67
  method_name = context.method_name
43
68
  args = context.args
44
69
  return nil unless SingletonFolding.receiver?(receiver, "URI")
70
+ return fold_encode_www_form(args) if method_name == :encode_www_form
71
+ return fold_decode_www_form(args) if method_name == :decode_www_form
72
+ return fold_extract(args) if method_name == :extract
45
73
  return nil unless URI_COMPONENT_METHODS.include?(method_name)
46
74
 
47
75
  fold_uri_call(method_name, args)
48
76
  end
49
77
 
78
+ # `URI.encode_www_form(enum)` — deterministic over its whole input, so it folds only when EVERY key
79
+ # and value is already a `Constant`. One non-constant pair declines the whole call: a partially
80
+ # known form has no `Constant[String]` to stand for it.
81
+ def fold_encode_www_form(args)
82
+ return nil unless args.size == 1
83
+
84
+ pairs = form_pairs(args.first)
85
+ return nil if pairs.nil? || pairs.size > FORM_PAIR_LIMIT
86
+
87
+ Type::Combinator.constant_of(URI.encode_www_form(pairs))
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ # The `[key, value]` pairs of a literal form argument, or nil when the shape is not fully known:
93
+ # a `Tuple` of two-element `Tuple`s (the array-of-pairs spelling) or a `HashShape` (the hash
94
+ # spelling). `HashShape` pairs are read only when the shape is CLOSED and carries no optional key —
95
+ # an open or partially-optional shape describes a hash whose real contents this fold cannot see.
96
+ def form_pairs(arg)
97
+ case arg
98
+ when Type::Tuple then tuple_form_pairs(arg)
99
+ when Type::HashShape then hash_form_pairs(arg)
100
+ end
101
+ end
102
+
103
+ def tuple_form_pairs(tuple)
104
+ tuple.elements.map do |element|
105
+ return nil unless element.is_a?(Type::Tuple) && element.elements.size == 2
106
+ return nil unless element.elements.all?(Type::Constant)
107
+
108
+ element.elements.map(&:value)
109
+ end
110
+ end
111
+
112
+ def hash_form_pairs(shape)
113
+ return nil unless shape.closed? && shape.optional_keys.empty?
114
+
115
+ shape.pairs.map do |key, value|
116
+ return nil unless value.is_a?(Type::Constant)
117
+
118
+ [key, value.value]
119
+ end
120
+ end
121
+
122
+ # `URI.decode_www_form(str)` — the pair list of a constant form, as a `Tuple` of two-element
123
+ # `Tuple`s so a destructuring read (`k, v = URI.decode_www_form(s).first`) reaches the concrete
124
+ # strings instead of the RBS tier's `Array[[String, String]]`.
125
+ def fold_decode_www_form(args)
126
+ return nil unless args.size == 1
127
+
128
+ str = SingletonFolding.constant_string(args.first)
129
+ return nil if str.nil?
130
+
131
+ pairs = URI.decode_www_form(str)
132
+ return nil if pairs.size > FORM_PAIR_LIMIT
133
+
134
+ Type::Combinator.tuple_of(*pairs.map { |key, value| constant_pair(key, value) })
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def constant_pair(key, value)
140
+ Type::Combinator.tuple_of(Type::Combinator.constant_of(key), Type::Combinator.constant_of(value))
141
+ end
142
+
143
+ # `URI.extract(str)` — the URIs a constant string contains, as a `Tuple` of `Constant[String]`
144
+ # instead of the RBS tier's `Array[String]`. Only the single-argument form folds: the optional
145
+ # second argument is a schema FILTER, and honouring it means reproducing `URI.extract`'s own
146
+ # argument handling rather than the one call this fold makes, so it declines instead.
147
+ #
148
+ # Ruby emits an obsolescence warning for this method under `$VERBOSE`; the fold calls it with
149
+ # warnings silenced, because a warning about the ANALYZED program's API choice must not appear on
150
+ # the analyzer's own stderr, where a reader would attribute it to Rigor.
151
+ def fold_extract(args)
152
+ return nil unless args.size == 1
153
+
154
+ str = SingletonFolding.constant_string(args.first)
155
+ return nil if str.nil?
156
+
157
+ found = silence_warnings { URI.extract(str) }
158
+ return nil if found.nil? || found.size > FORM_PAIR_LIMIT
159
+
160
+ Type::Combinator.tuple_of(*found.map { |uri| Type::Combinator.constant_of(uri) })
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def silence_warnings
166
+ previous = $VERBOSE
167
+ $VERBOSE = nil
168
+ yield
169
+ ensure
170
+ $VERBOSE = previous
171
+ end
172
+
50
173
  def fold_uri_call(method_name, args)
51
174
  return nil unless args.size == 1
52
175
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../type"
4
4
  require_relative "../source/node_children"
5
+ require_relative "receiver_alias"
5
6
 
6
7
  module Rigor
7
8
  module Inference
@@ -45,6 +46,10 @@ module Rigor
45
46
  #
46
47
  # - `arr.<mutator>(...)` where `arr` is a local variable.
47
48
  # - `@arr.<mutator>(...)` where `@arr` is an instance variable.
49
+ # - a receiver that *selects* among such variables rather than naming one —
50
+ # `(kind == :required ? required : optional)[key] = info`, an `if`/`else`, `||` / `&&`. Every
51
+ # variable the expression can evaluate to is a possible mutation target, so every one widens
52
+ # (issue #277). See {ReceiverAlias}.
48
53
  #
49
54
  # Out of scope (left for a separate cycle):
50
55
  #
@@ -100,10 +105,10 @@ module Rigor
100
105
  PURE_SELF_RETURNERS.include?(method_name)
101
106
  end
102
107
 
103
- # Returns a scope with the call's receiver widened, when the receiver is a
104
- # local-/instance-variable read whose current binding is a literal-shape carrier
108
+ # Returns a scope with the call's receiver widened, for every variable the receiver expression
109
+ # can evaluate to ({ReceiverAlias.candidates}) whose current binding is a literal-shape carrier
105
110
  # (`Tuple` / `HashShape`) or an empty-witness refinement (`non-empty-array` /
106
- # `non-empty-hash`) AND the call name is a known in-place mutator for that shape.
111
+ # `non-empty-hash`) AND whose call name is a known in-place mutator for that shape.
107
112
  # Returns `current_scope` unchanged otherwise.
108
113
  #
109
114
  # @param call_node [Prism::CallNode]
@@ -115,13 +120,8 @@ module Rigor
115
120
  receiver = call_node.receiver
116
121
  return current_scope if receiver.nil?
117
122
 
118
- case receiver
119
- when Prism::LocalVariableReadNode
120
- widen_local(call_node.name, receiver.name, current_scope)
121
- when Prism::InstanceVariableReadNode
122
- widen_ivar(call_node.name, receiver.name, current_scope)
123
- else
124
- current_scope
123
+ ReceiverAlias.candidates(receiver).reduce(current_scope) do |acc, read|
124
+ widen_alias_read(call_node.name, read, acc)
125
125
  end
126
126
  end
127
127
 
@@ -130,13 +130,13 @@ module Rigor
130
130
  # LOCALS are otherwise invisible to the post-call outer scope (ivars are handled correctly
131
131
  # already because they live in the method-body scope, not the block-local scope).
132
132
  #
133
- # Walks the block AST for `<receiver>.<method>(...)` calls whose receiver is either a
134
- # `LocalVariableReadNode` with `depth > 0` (a captured outer local Prism's `depth`
135
- # counts scope hops outward; `depth == 0` means a block-local) or an
136
- # `InstanceVariableReadNode` (always method-scope), and applies `widen_after_call` for
137
- # each one against the outer scope. The widening is always safe — it can only LOSE
138
- # precision — so blindly propagating is sound regardless of whether the block actually
139
- # runs.
133
+ # Walks the block AST for `<receiver>.<method>(...)` calls, resolves the receiver expression
134
+ # to the variables it can evaluate to ({ReceiverAlias.candidates}), and widens each one
135
+ # against the outer scope. A `LocalVariableReadNode` with `depth == 0` is skipped Prism's
136
+ # `depth` counts scope hops outward, so `0` means a block-local, not a capture; an
137
+ # `InstanceVariableReadNode` is always method-scope and always applies. The widening is
138
+ # always safe — it can only LOSE precision — so blindly propagating is sound regardless of
139
+ # whether the block actually runs.
140
140
  #
141
141
  # Recurses into nested expression nodes so chained / nested forms (`arr << f(x); arr <<
142
142
  # g(y)`, `arr.push(x) if cond`) are all caught. Does NOT recurse into nested
@@ -174,15 +174,20 @@ module Rigor
174
174
  receiver = call_node.receiver
175
175
  return scope if receiver.nil?
176
176
 
177
- case receiver
178
- when Prism::LocalVariableReadNode
179
- return scope if receiver.depth.zero?
177
+ ReceiverAlias.candidates(receiver).reduce(scope) do |acc, read|
178
+ # A block-local read (`depth == 0`) is not a capture of the outer scope, so widening its
179
+ # name against the OUTER scope would hit an unrelated same-named binding.
180
+ next acc if read.is_a?(Prism::LocalVariableReadNode) && read.depth.zero?
180
181
 
181
- widen_local(call_node.name, receiver.name, scope)
182
- when Prism::InstanceVariableReadNode
183
- widen_ivar(call_node.name, receiver.name, scope)
184
- else
185
- scope
182
+ widen_alias_read(call_node.name, read, acc)
183
+ end
184
+ end
185
+
186
+ def widen_alias_read(method_name, read, scope)
187
+ case read
188
+ when Prism::LocalVariableReadNode then widen_local(method_name, read.name, scope)
189
+ when Prism::InstanceVariableReadNode then widen_ivar(method_name, read.name, scope)
190
+ else scope
186
191
  end
187
192
  end
188
193
 
@@ -1169,7 +1169,9 @@ module Rigor
1169
1169
  # so the proven-absent key reads `nil`. Returns the input unchanged when nothing
1170
1170
  # applies (caller detects "no narrowing"). Only an *optional* present key is removed: a
1171
1171
  # required key makes `key?` always true (the false edge is dead, leave the shape opaque)
1172
- # and a key absent from `pairs` already reads `nil`.
1172
+ # and a key absent from `pairs` already reads `nil` on a closed shape. On an open shape an
1173
+ # undeclared key keeps reading `untyped` on this edge — sound but imprecise, since the shape
1174
+ # carries no per-key exclusion to record what the guard just proved absent.
1173
1175
  def narrow_hash_key_absent(type, key)
1174
1176
  case type
1175
1177
  when Type::HashShape
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Marks a value whose *nil-freeness* rests on Rigor's deliberate choice to ignore core RBS's
6
+ # `%a{implicitly-returns-nil}` annotation rather than on the value's class. See
7
+ # docs/internal-spec/inference-engine.md § "That deferred-to answer is nil-free for a lookup that can
8
+ # miss": `Hash#[]` reads as `V` and `Array#first` as `E` because pessimising them to `V?` costs 25
9
+ # measured false positives on Rigor's own `lib`. The consequence the spec draws is that such a value is
10
+ # **optimistic, not proof** — `MAP[key]` reading as `"x" | "y"` asserts nothing about whether the key was
11
+ # present.
12
+ #
13
+ # A side channel in the ADR-75 / ADR-82 sense: it never participates in subtyping, consistency,
14
+ # normalization or erasure, and no diagnostic fires from it. It differs from {DynamicOrigin} in what it
15
+ # attaches to — the value here is *not* `Dynamic`; it is an ordinary `Union` / `Constant` / `Nominal`
16
+ # that happens to have been produced optimistically.
17
+ #
18
+ # Issue #286: the `if` / `unless` branch elision is a third consumer of `Narrowing.predicate_certainty`,
19
+ # and unlike `flow.always-truthy-condition` and the `&&` / `||` gate it is not constrained by the spec
20
+ # passage above. This channel is what lets a certainty judgment tell the two apart.
21
+ module OptimisticOrigin
22
+ # The core-RBS annotation `RbsDispatch` reads the return type past.
23
+ ANNOTATION = "implicitly-returns-nil"
24
+
25
+ # The single cause carried today. Kept as a symbol (rather than a bare `true`) so a later slice can
26
+ # distinguish further optimistic families without changing the table's shape.
27
+ IMPLICITLY_RETURNS_NIL = :implicitly_returns_nil
28
+
29
+ module_function
30
+
31
+ # Whether the overload the selector actually picked carries the ignored annotation. The judgment is
32
+ # per-overload, which is what makes it precise: `Array#first` is optimistic while `Array#first(3)` is
33
+ # not, and `String#[]` / `Enumerable#find` are honest because they already spell the miss as `?`.
34
+ #
35
+ # @param method_definition [RBS::Definition::Method]
36
+ # @param method_type [RBS::MethodType] the overload {OverloadSelector.select} returned
37
+ # @return [Boolean]
38
+ def optimistic_overload?(method_definition, method_type)
39
+ type_def = matching_type_def(method_definition, method_type)
40
+ return false unless type_def.respond_to?(:overload_annotations)
41
+
42
+ type_def.overload_annotations.any? { |annotation| annotation.string == ANNOTATION }
43
+ end
44
+
45
+ # `RBS::Definition::Method#defs` runs parallel to `#method_types`, and the selector returns one of the
46
+ # latter's elements verbatim (`ReceiverAffinity.reorder` permutes the array without copying its
47
+ # members), so identity resolves the overload exactly. Equality is a fallback for any future path that
48
+ # rebuilds the method type.
49
+ def matching_type_def(method_definition, method_type)
50
+ return nil unless method_definition.respond_to?(:defs)
51
+
52
+ defs = method_definition.defs
53
+ defs.find { |type_def| type_def.type.equal?(method_type) } ||
54
+ defs.find { |type_def| type_def.type == method_type }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "scope_indexer"
4
4
  require_relative "origin_lookup"
5
5
  require_relative "../source/node_walker"
6
+ require_relative "../analysis/check_rules/inferred_param_guard"
6
7
 
7
8
  module Rigor
8
9
  module Inference
@@ -20,7 +21,11 @@ module Rigor
20
21
  # A single unprotected call site.
21
22
  Site = Data.define(:line, :receiver, :method_name, :dynamic_origin)
22
23
 
23
- FileResult = Data.define(:protected_count, :unprotected_count, :sites) do
24
+ # `lower_bound_typed` is a split WITHIN `protected_count`, never a move out of it (issue #263):
25
+ # a site whose receiver is concrete but rooted at a WD6b-marked inferred parameter (
26
+ # {Analysis::CheckRules::InferredParamGuard.rooted?}) is a lower-bound type — `protected_count`
27
+ # and `ratio` stay exactly as before, so `--threshold` never moves on unchanged code.
28
+ FileResult = Data.define(:protected_count, :unprotected_count, :lower_bound_typed, :sites) do
24
29
  def total = protected_count + unprotected_count
25
30
 
26
31
  # Protected ratio; a file with no dispatch sites is vacuously fully protected (nothing to get
@@ -37,6 +42,7 @@ module Rigor
37
42
  def scan(root)
38
43
  index = ScopeIndexer.index(root, default_scope: @scope)
39
44
  protected_count = 0
45
+ lower_bound_typed = 0
40
46
  sites = []
41
47
 
42
48
  Source::NodeWalker.each(root) do |node|
@@ -46,6 +52,7 @@ module Rigor
46
52
  receiver_type = scope.type_of(node.receiver)
47
53
  if concrete_receiver?(receiver_type)
48
54
  protected_count += 1
55
+ lower_bound_typed += 1 if lower_bound_typed?(node.receiver, scope)
49
56
  else
50
57
  origin = OriginLookup.origin_for(scope, node.receiver)
51
58
  sites << Site.new(
@@ -57,7 +64,8 @@ module Rigor
57
64
  end
58
65
  end
59
66
 
60
- FileResult.new(protected_count: protected_count, unprotected_count: sites.size, sites: sites)
67
+ FileResult.new(protected_count: protected_count, unprotected_count: sites.size,
68
+ lower_bound_typed: lower_bound_typed, sites: sites)
61
69
  end
62
70
 
63
71
  private
@@ -77,6 +85,14 @@ module Rigor
77
85
  end
78
86
  end
79
87
 
88
+ # ADR-67 WD6b (issue #263) — true when a concrete receiver is rooted at an inferred-but-undeclared
89
+ # parameter: the type is a call-site lower bound, so the negative in-body rules decline on it (no
90
+ # diagnostic can actually fire here yet). Delegates to the single-homed shared predicate every
91
+ # negative rule already consults — nothing is re-derived.
92
+ def lower_bound_typed?(receiver, scope)
93
+ Analysis::CheckRules::InferredParamGuard.rooted?(receiver, scope)
94
+ end
95
+
80
96
  def safe_describe(type)
81
97
  type.respond_to?(:describe) ? type.describe(:short) : type.to_s
82
98
  rescue StandardError