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
@@ -394,32 +394,56 @@ module Rigor
394
394
  cache_store.fetch_or_compute(
395
395
  producer_id: SYNTHESIZER_CACHE_PRODUCER_ID,
396
396
  params: {},
397
- descriptor: descriptor
397
+ descriptor: descriptor,
398
+ generation_cap: synthesizer_generation_cap
398
399
  ) { invoke_synthesizer_safely(callable, path) || "" }
399
400
  end
400
401
 
401
- # 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
402
403
  # synthesizer's contract (declared in
403
- # `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`) admits three return shapes:
404
- # - `String` (non-empty) → successful RBS source
405
- # - `nil` / `""` → no contribution
406
- # - `[: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)
407
410
  # The error tuple is converted into a reporter entry + treated as "no contribution" so the analysis
408
- # 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.
409
417
  def interpret_synthesizer_outcome(outcome, plugin, path, reporter)
410
- 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
411
431
 
412
- reporter&.record(
413
- plugin_id: plugin.manifest.id,
414
- path: path,
415
- message: outcome[1].to_s
416
- )
417
- 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)
418
434
  end
419
435
 
420
436
  SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
421
437
  private_constant :SYNTHESIZER_CACHE_PRODUCER_ID
422
438
 
439
+ # One entry per (plugin, source file), all of them live for as long as the file is in the project — a
440
+ # generation count says nothing about staleness here, so this producer declares itself out of
441
+ # `Cache::Store#evict!`'s compaction pass and is bounded only by the size-based LRU pass. A method
442
+ # rather than a constant: `Cache::Store` is not loaded yet when this class body runs.
443
+ def synthesizer_generation_cap
444
+ Cache::Store::UNBOUNDED_GENERATIONS
445
+ end
446
+
423
447
  def build_synthesizer_cache_descriptor(plugin, path)
424
448
  Cache::Descriptor.new(
425
449
  files: [Cache::Descriptor::FileEntry.new(
@@ -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
@@ -55,7 +55,12 @@ module Rigor
55
55
  "::io" => ["IO"],
56
56
  "::encoding" => %w[Encoding String],
57
57
  "::path" => ["String"],
58
- "::boolean" => %w[TrueClass FalseClass]
58
+ "::boolean" => %w[TrueClass FalseClass],
59
+ # `range[T] = Range[T] | _Range[T]`. Generic, unlike the others, but the strict arm is still a
60
+ # single nominal and the args are irrelevant to this pass. rbs 4.1 rewrote `Array#[]`'s slicing
61
+ # overload from `(::Range[::Integer?])` to `(range[int])`; without the entry both it and the
62
+ # `(int) -> E` overload look alias-typed, so `a[1..2]` resolved to the element type.
63
+ "::range" => ["Range"]
59
64
  }.freeze
60
65
  private_constant :ALIAS_STRICT_NOMINALS
61
66
 
@@ -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