rigortype 0.3.1 → 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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -399,23 +399,38 @@ module Rigor
399
399
  ) { invoke_synthesizer_safely(callable, path) || "" }
400
400
  end
401
401
 
402
- # ADR-32 WD6 — route a synthesizer return value through the per-run failure reporter. The
402
+ # ADR-32 WD6 / WD12 — route a synthesizer return value through the per-run reporter. The
403
403
  # synthesizer's contract (declared in
404
- # `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`) admits three return shapes:
405
- # - `String` (non-empty) → successful RBS source
406
- # - `nil` / `""` → no contribution
407
- # - `[:error, message]` → parse failed
404
+ # `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`) admits four return shapes:
405
+ # - `String` (non-empty) → successful RBS source
406
+ # - `nil` / `""` → no contribution
407
+ # - `[:error, message]` → parse failed (WD6)
408
+ # - `[:ok, source, [message…]]` → synthesis SUCCEEDED, but an annotation was parsed and not
409
+ # honoured (WD12)
408
410
  # The error tuple is converted into a reporter entry + treated as "no contribution" so the analysis
409
- # pipeline continues. Reporter is `nil` for callers that don't care (legacy Environment.new, tests).
411
+ # pipeline continues. The WD12 tuple keeps its `source` the file's other annotations are good, and
412
+ # dropping them to report one omission would be a strictly worse trade. Reporter is `nil` for callers
413
+ # that don't care (legacy Environment.new, tests).
414
+ #
415
+ # A cached entry written before WD12 is a bare `String`, which falls through the `is_a?(Array)` guard
416
+ # unchanged, so no cache-descriptor bump is needed for the new shape.
410
417
  def interpret_synthesizer_outcome(outcome, plugin, path, reporter)
411
- return outcome unless outcome.is_a?(Array) && outcome[0] == :error
418
+ return outcome unless outcome.is_a?(Array)
419
+
420
+ case outcome[0]
421
+ when :error
422
+ record_synthesis_entry(reporter, plugin, path, outcome[1], :failed)
423
+ nil
424
+ when :ok
425
+ Array(outcome[2]).each { |m| record_synthesis_entry(reporter, plugin, path, m, :not_honoured) }
426
+ outcome[1]
427
+ else
428
+ outcome
429
+ end
430
+ end
412
431
 
413
- reporter&.record(
414
- plugin_id: plugin.manifest.id,
415
- path: path,
416
- message: outcome[1].to_s
417
- )
418
- nil
432
+ def record_synthesis_entry(reporter, plugin, path, message, kind)
433
+ reporter&.record(plugin_id: plugin.manifest.id, path: path, message: message.to_s, kind: kind)
419
434
  end
420
435
 
421
436
  SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
@@ -609,6 +609,18 @@ module Rigor
609
609
  elide_or_union(node.predicate, else_type, then_type)
610
610
  end
611
611
 
612
+ # Issue #286 — the effective optimistic-nil-free cause of an expression, resolving a bare local read (or
613
+ # a local write in value position) through its binding. Mirrors `StatementEvaluator#optimistic_origin_for`.
614
+ def optimistic_origin_for(node)
615
+ recorded = scope.optimistic_origins[node]
616
+ return recorded if recorded
617
+
618
+ case node
619
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
620
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
621
+ end
622
+ end
623
+
612
624
  def if_else_type(subsequent)
613
625
  return Type::Combinator.constant_of(nil) if subsequent.nil?
614
626
 
@@ -632,6 +644,9 @@ module Rigor
632
644
  # `Constant[false]` fold one branch; `Union[true, false]`, `Dynamic[T]`, and `Top` keep both branches live.
633
645
  def constant_predicate_polarity(predicate)
634
646
  return nil if predicate.nil?
647
+ # ADR-101 — decline on an optimistically nil-free carrier; see
648
+ # `StatementEvaluator#optimistic_carrier?` for why the gate is here and not in `Narrowing`.
649
+ return nil unless optimistic_origin_for(predicate).nil?
635
650
 
636
651
  Narrowing.predicate_certainty(type_of(predicate))
637
652
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "tmpdir"
4
4
 
5
+ require_relative "../runtime/jit"
6
+
5
7
  module Rigor
6
8
  module Inference
7
9
  # Generic fork-over-slices map for the embarrassingly-parallel per-file passes of the
@@ -56,6 +58,13 @@ module Rigor
56
58
  # (skipping `at_exit` / stdio flush — the payload is durable on disk). Any failure exits non-zero so
57
59
  # the parent re-runs the slice in-process.
58
60
  def run_worker(slice, block, out_path)
61
+ # Re-arm deferred YJIT in the child, exactly as the check fork pool does
62
+ # ({Analysis::Runner::PoolCoordinator#run_fork_worker}). The parent's deadline thread does not survive
63
+ # `fork`, so without this a worker runs its whole slice un-JITted while the parent JITs the tail it no
64
+ # longer runs — the pool was a *pessimization* on long runs (`coverage --protection --mutation
65
+ # lib/rigor/analysis`: 37s sequential against 67s at eight workers). The child picks up what is left of
66
+ # the parent's window rather than a fresh one; {Runtime::Jit.rearm_after_fork} carries the mechanism.
67
+ Runtime::Jit.rearm_after_fork
59
68
  File.binwrite(out_path, Marshal.dump(block.call(slice)))
60
69
  exit!(0)
61
70
  rescue StandardError
@@ -117,7 +117,16 @@ module Rigor
117
117
  :succ, :pred, :next, :abs, :magnitude,
118
118
  :bit_length, :to_s, :to_i, :to_int, :to_f,
119
119
  :floor, :ceil, :round, :truncate, :chr,
120
- :inspect, :-@, :+@, :~, :to_r, :to_c
120
+ :inspect, :-@, :+@, :~, :to_r, :to_c,
121
+ # `rationalize` (no-arg form) is the Integer sibling of the already-folded
122
+ # `Float#rationalize` / `Rational#rationalize` — pure, returns `Rational(self, 1)`. The
123
+ # optional-`eps` binary form is NOT mirrored here: unlike `Rational#rationalize` (which reaches
124
+ # `NUMERIC_BINARY`'s catalog fallback via `numeric.yml`'s `nurat_rationalize` leaf entry),
125
+ # `numeric.yml` carries no `rationalize` entry for Integer/Float, so the 1-arg call declines to
126
+ # the RBS tier exactly as it does today — this only closes the 0-arg gap.
127
+ # `abs2` (`self * self`, always a non-negative Integer) is the Integer sibling of the
128
+ # already-folded `Rational#abs2` / `Complex#abs2`.
129
+ :rationalize, :abs2
121
130
  ].freeze
122
131
  FLOAT_UNARY = Set[
123
132
  :zero?, :positive?, :negative?, :nonzero?,
@@ -135,7 +144,12 @@ module Rigor
135
144
  # `self >= 0`, `Math::PI` for `self < 0`. Pure sign test, deterministic; a NaN receiver yields NaN
136
145
  # which `foldable_constant_value?` declines.
137
146
  :arg, :angle, :phase,
138
- :inspect, :-@, :+@
147
+ :inspect, :-@, :+@,
148
+ # `abs2` (`self * self`) is the Float sibling of the already-folded `Rational#abs2` /
149
+ # `Complex#abs2` / (now) `Integer#abs2`. A non-finite receiver (`Infinity`, `NaN`) still folds
150
+ # via `foldable_constant_value?`'s existing NaN guard — `Infinity.abs2` is `Infinity`
151
+ # (foldable), `NaN.abs2` is `NaN` (declined, same as every other NaN-producing Float unary op).
152
+ :abs2
139
153
  ].freeze
140
154
  STRING_UNARY = Set[
141
155
  :upcase, :downcase, :capitalize, :swapcase,
@@ -195,6 +209,11 @@ module Rigor
195
209
  # `flow.always-truthy-condition`.
196
210
  REFLECTIVE_SEND_METHODS = %i[public_send send __send__].to_set.freeze
197
211
 
212
+ # The self-returners, the `Type::Constant` counterpart of `ShapeDispatch`'s `:shape_self` handlers
213
+ # (ADR-76 WD2 / ADR-78 WD3). Each returns the receiver at run time, so the value-pinned carrier
214
+ # survives the call.
215
+ CONSTANT_SELF_RETURNERS = %i[freeze itself dup clone].to_set.freeze
216
+
198
217
  # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
199
218
  def try_dispatch(context)
200
219
  receiver = context.receiver
@@ -205,6 +224,9 @@ module Rigor
205
224
  first_arg = args.first
206
225
  return nil unless first_arg.is_a?(Type::Constant) && first_arg.value.is_a?(Symbol)
207
226
  end
227
+ self_return = try_fold_self_return(receiver, method_name, args)
228
+ return self_return if self_return
229
+
208
230
  # v0.0.7 — `String#%` against a `Tuple` / `HashShape` argument runs Ruby's format-string engine
209
231
  # when both sides are statically constant. The standard `numeric_set_of` path bails on Tuple /
210
232
  # HashShape arguments because they are not scalar-Constant carriers, so the special-case sits
@@ -221,6 +243,32 @@ module Rigor
221
243
  dispatch_by_arity(receiver_set, method_name, arg_sets)
222
244
  end
223
245
 
246
+ # `freeze` / `itself` / `dup` / `clone` on a value-pinned receiver return the receiver carrier.
247
+ #
248
+ # This sits AHEAD of the catalogue path and deliberately bypasses its purity gate, because the gate
249
+ # asks a question that does not apply. `String#freeze` is classified `purity: mutates_self`
250
+ # (`data/builtins/ruby_core/string.yml`) — correctly, since the C body sets `FL_FREEZE` — and the
251
+ # gate then holds it back from the pure-invoke path. But nothing here is invoked: the receiver type
252
+ # is returned unchanged, and freezing mutates an object's flags, not its value. `Type::Constant`
253
+ # pins a value, not an identity, so the mutation it performs is invisible to the carrier.
254
+ #
255
+ # The gap this closes is `FOO = "bar".freeze`, the standard way to write a String constant in
256
+ # Ruby, which lost its value while `%w[a b].freeze` (a Tuple, via `:shape_self`) and `"bar".-@`
257
+ # (classified `purity: leaf`) both kept theirs. Integer / Float / Symbol had the same hole for a
258
+ # different reason — no `Object`/`Kernel` catalogue owns their self-returners at all.
259
+ #
260
+ # `dup` / `clone` return a NEW object at run time, so the carrier is right about the value and
261
+ # silent about the identity — the same trade `shape_self` already makes, and the same one the
262
+ # existing `"x".dup` fold (which reaches the invoke path, `dup` being `leaf`) already makes.
263
+ # A `clone(freeze: false)` carries an argument and declines here.
264
+ def try_fold_self_return(receiver, method_name, args)
265
+ return nil unless args.empty?
266
+ return nil unless CONSTANT_SELF_RETURNERS.include?(method_name)
267
+ return nil unless receiver.is_a?(Type::Constant)
268
+
269
+ receiver
270
+ end
271
+
224
272
  # `Constant<String> % …` — runs the actual `String#%` operation when both sides are statically
225
273
  # known. The argument may be:
226
274
  # - A `Type::Constant` whose value is a scalar (Integer / Float / String / Symbol). Already handled
@@ -116,7 +116,7 @@ module Rigor
116
116
  map = member_map_for_new(members, context)
117
117
  return degraded_instance(class_name) if map.nil?
118
118
 
119
- Type::Combinator.data_instance_of(members: map, class_name: class_name)
119
+ Type::Combinator.data_instance_of(members: widen_unowned_emptiness(map), class_name: class_name)
120
120
  end
121
121
 
122
122
  # Builds the member -> type map from the call's arguments, honouring the keyword vs positional
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../../type"
6
6
  require_relative "../../source/node_children"
7
+ require_relative "../mutation_widening"
7
8
 
8
9
  module Rigor
9
10
  module Inference
@@ -18,6 +19,39 @@ module Rigor
18
19
  # Both folders `extend` this module so the projections resolve as their own module functions
19
20
  # (matching their `module_function` style).
20
21
  module MemberShapeProjection
22
+ # A value object's member holds a **reference** to whatever the constructor was handed, and an
23
+ # EMPTY container literal's emptiness is a fact about that argument at the instant of
24
+ # construction — never a fact about the member. The caller keeps its own alias, and "construct
25
+ # empty, then fill" (`r = R.new([], []); xs.each { |x| r.items << x }`) is the dominant Ruby
26
+ # shape, so of every literal a member map can pin, emptiness is the one falsified most often.
27
+ #
28
+ # It is also the only one whose reads fold to `nil` — `.first` / `.last` / `[]` on an empty
29
+ # container — and `nil` is the receiver type that fires `call.undefined-method` on correct code
30
+ # (issue #293). A non-empty literal's element evidence, by contrast, survives an append and its
31
+ # reads never fold to `nil`, so it is kept intact.
32
+ #
33
+ # So an empty `Tuple` / closed empty `HashShape` is recorded widened to its bare nominal —
34
+ # the same carrier, through the same helpers, that {Inference::MutationWidening} installs when
35
+ # it retracts a mutated local's literal shape.
36
+ def widen_unowned_emptiness(member_map)
37
+ return member_map if member_map.nil?
38
+
39
+ member_map.transform_values { |type| widened_empty_container(type) }
40
+ end
41
+
42
+ def widened_empty_container(type)
43
+ case type
44
+ when Type::Tuple
45
+ type.elements.empty? ? MutationWidening.widen_tuple(type) : type
46
+ when Type::HashShape
47
+ # An OPEN shape claims nothing about the keys it does not list — a read of an unlisted key
48
+ # is already `untyped`, not `nil` — so only the closed literal `{}` carries the emptiness.
49
+ type.closed? && type.pairs.empty? ? MutationWidening.widen_hash_shape(type) : type
50
+ else
51
+ type
52
+ end
53
+ end
54
+
21
55
  # A Data/Struct subclass body can redefine a member's synthesised reader (`def x`); when it does,
22
56
  # `inst.x` runs that `def`, not the member, so folding the read would be unsound. A real `def` node
23
57
  # under the class name is the discriminator (the synthesised reader has none), so an entry in the
@@ -109,7 +143,7 @@ module Rigor
109
143
  return nil unless shape.optional_keys.empty?
110
144
  return nil unless shape.pairs.keys.all? { |key| instance.members.key?(key) }
111
145
 
112
- merged = instance.members.merge(shape.pairs)
146
+ merged = widen_unowned_emptiness(instance.members.merge(shape.pairs))
113
147
  yield(merged, instance.class_name)
114
148
  end
115
149
  end
@@ -5,6 +5,7 @@ require_relative "../../type"
5
5
  require_relative "../../rbs_extended"
6
6
  require_relative "../rbs_type_translator"
7
7
  require_relative "../void_origin"
8
+ require_relative "../optimistic_origin"
8
9
  require_relative "overload_selector"
9
10
 
10
11
  module Rigor
@@ -362,6 +363,11 @@ module Rigor
362
363
  # to the *direct*-RBS dispatch (the receiver's own resolvable class): the user-class / Object
363
364
  # ancestor fallback nils both out (WD4 defers that, murkier, surface).
364
365
  record_void_recovery(method_type, scope, call_node, [class_name, method_name, kind])
366
+ # Issue #286 — the same call site is the one place that still knows the selected overload spelled
367
+ # its miss as `%a{implicitly-returns-nil}` rather than as `?`; the translator below reads the
368
+ # return type only, by the deliberate choice the spec records. Mark the result so a certainty
369
+ # judgment downstream can tell "nil-free because of its class" from "nil-free because we bet".
370
+ record_optimistic_nil_free(method_definition, method_type, scope, call_node)
365
371
 
366
372
  full_type_vars = compose_block_type_vars(method_type, type_vars, block_type)
367
373
 
@@ -386,6 +392,16 @@ module Rigor
386
392
  )
387
393
  end
388
394
 
395
+ # Issue #286 — record that this value's nil-freeness is a bet rather than a class property. Gated on
396
+ # `scope` && `call_node` exactly as {#record_void_recovery} is, which scopes it to the direct-RBS
397
+ # dispatch path.
398
+ def record_optimistic_nil_free(method_definition, method_type, scope, call_node)
399
+ return unless scope && call_node
400
+ return unless OptimisticOrigin.optimistic_overload?(method_definition, method_type)
401
+
402
+ scope.record_optimistic_origin(call_node, OptimisticOrigin::IMPLICITLY_RETURNS_NIL)
403
+ end
404
+
389
405
  def void_return?(method_type)
390
406
  fun = method_type.type
391
407
  fun.respond_to?(:return_type) && fun.return_type.is_a?(RBS::Types::Bases::Void)
@@ -14,17 +14,38 @@ module Rigor
14
14
  # * `new(str)` / `new(str, opts)` — constructs a Regexp at fold time when the pattern argument is a
15
15
  # `Constant[String]`. The optional second argument may be a `Constant[Integer]` (flag bits), a
16
16
  # `Constant[true/false]` (IGNORECASE shorthand), or absent. Returns `Constant[Regexp]`.
17
+ # * `compile(str)` / `compile(str, opts)` — `Regexp.compile` is a documented alias of `Regexp.new`
18
+ # (same C entry point, `rb_reg_s_new`); shares `fold_new` verbatim.
19
+ # * `union(*patterns)` / `union(array)` — builds the combined pattern at inference time when every
20
+ # element is a `Constant[String]` or `Constant[Regexp]`. Delegates to the real `Regexp.union` so the
21
+ # zero-arg (`/(?!)/`), single-array-argument, and pre-built-Regexp-argument forms all match Ruby's
22
+ # own semantics without separate case analysis. Returns `Constant[Regexp]`.
23
+ # * `linear_time?(pattern)` — one `Constant[String]` or `Constant[Regexp]` argument. Returns
24
+ # `Constant[bool]`.
17
25
  #
18
26
  # === Non-constant / unsupported cases
19
27
  #
20
28
  # Returns `nil` (deferring to the next dispatcher tier) when:
21
29
  # - the receiver is not `Singleton[Regexp]`,
22
- # - the required pattern argument is not a `Constant[String]`,
30
+ # - the required pattern argument is not a `Constant[String]` (or, for `union`/`linear_time?`, not a
31
+ # `Constant[String]`/`Constant[Regexp]`),
32
+ # - `union` is given more elements than `UNION_LIMIT` or any element fails the constant check,
33
+ # - `linear_time?` is given anything other than exactly one constant argument (a `timeout:` keyword
34
+ # argument is rejected this way — it would land as a second positional slot),
23
35
  # - the method is not in the supported set.
24
36
  module RegexpFolding
25
37
  REGEXP_ESCAPE_METHODS = Set[:escape, :quote].freeze
26
38
  private_constant :REGEXP_ESCAPE_METHODS
27
39
 
40
+ # `.new` and `.compile` are the same C function under two names — both fold identically.
41
+ REGEXP_NEW_METHODS = Set[:new, :compile].freeze
42
+ private_constant :REGEXP_NEW_METHODS
43
+
44
+ # `Regexp.union` element cap — same rationale as `SHELLWORDS_SPLIT_LIMIT` / `STRING_ARRAY_LIFT_LIMIT`:
45
+ # keep the fold's work bounded even though the source is a static literal.
46
+ UNION_LIMIT = 64
47
+ private_constant :UNION_LIMIT
48
+
28
49
  module_function
29
50
 
30
51
  # @return [Rigor::Type, nil] folded result, or nil to defer.
@@ -34,8 +55,10 @@ module Rigor
34
55
  args = context.args
35
56
  return nil unless SingletonFolding.receiver?(receiver, "Regexp")
36
57
  return fold_escape(args) if REGEXP_ESCAPE_METHODS.include?(method_name)
37
- return fold_new(args) if method_name == :new
58
+ return fold_new(args) if REGEXP_NEW_METHODS.include?(method_name)
38
59
  return fold_last_match(context) if method_name == :last_match
60
+ return fold_union(args) if method_name == :union
61
+ return fold_linear_time(args) if method_name == :linear_time?
39
62
 
40
63
  nil
41
64
  end
@@ -111,9 +134,9 @@ module Rigor
111
134
  end
112
135
 
113
136
  # `Regexp.new(pattern)` / `Regexp.new(pattern, opts)` — constructs the pattern at inference time.
114
- # Delegates to Ruby's real `Regexp.new` so all option forms (Integer flags, `true`/`false`, option
115
- # strings) are handled without case-analysis; non-constant or invalid arguments decline through to
116
- # the RBS tier.
137
+ # Also serves `Regexp.compile`, its documented alias (`REGEXP_NEW_METHODS`). Delegates to Ruby's
138
+ # real `Regexp.new` so all option forms (Integer flags, `true`/`false`, option strings) are handled
139
+ # without case-analysis; non-constant or invalid arguments decline through to the RBS tier.
117
140
  def fold_new(args)
118
141
  return nil if args.empty? || args.size > 2
119
142
 
@@ -132,6 +155,42 @@ module Rigor
132
155
  def constant_value_or_nil(type)
133
156
  type.is_a?(Type::Constant) ? type.value : nil
134
157
  end
158
+
159
+ # `Regexp.union(*patterns)` / `Regexp.union(array)` — folds when every pattern element is a
160
+ # `Constant[String]` or `Constant[Regexp]`. A single `Tuple` argument is unpacked exactly like
161
+ # Ruby's own single-array-argument form (`Regexp.union(["a", "b"])`); anything else is treated as
162
+ # the splatted-arguments form, including zero arguments (`Regexp.union()` → `/(?!)/`). Delegates to
163
+ # the real `Regexp.union` so encoding handling, escaping of String elements, and pass-through of
164
+ # already-`Regexp` elements all match Ruby exactly; declines (rescues to `nil`) on an encoding
165
+ # conflict between elements rather than propagating the exception.
166
+ def fold_union(args)
167
+ pattern_args = args.size == 1 && args.first.is_a?(Type::Tuple) ? args.first.elements : args
168
+ return nil if pattern_args.size > UNION_LIMIT
169
+ return nil unless pattern_args.all? { |arg| union_element?(arg) }
170
+
171
+ Type::Combinator.constant_of(Regexp.union(*pattern_args.map(&:value)))
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
176
+ def union_element?(arg)
177
+ arg.is_a?(Type::Constant) && (arg.value.is_a?(String) || arg.value.is_a?(Regexp))
178
+ end
179
+
180
+ # `Regexp.linear_time?(pattern)` — one `Constant[String]` or `Constant[Regexp]` argument. A second
181
+ # argument (the `timeout:` keyword, which lands as an extra positional slot) declines rather than
182
+ # being silently ignored. An invalid String pattern raises `RegexpError` when Ruby compiles it
183
+ # internally; rescued to `nil` like every other fold in this file.
184
+ def fold_linear_time(args)
185
+ return nil unless args.size == 1
186
+
187
+ arg = args.first
188
+ return nil unless union_element?(arg)
189
+
190
+ Type::Combinator.constant_of(Regexp.linear_time?(arg.value))
191
+ rescue StandardError
192
+ nil
193
+ end
135
194
  end
136
195
  end
137
196
  end
@@ -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