rigortype 0.1.18 → 0.1.19

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -17,7 +17,7 @@ module Rigor
17
17
  # `MyApp.get(...)` call);
18
18
  # - the underlying class `X` equals or inherits from the
19
19
  # entry's `receiver_constraint`;
20
- # - the call's method name is in the entry's `verbs`.
20
+ # - the call's method name is in the entry's `method_names`.
21
21
  #
22
22
  # On a match the helper returns the **instance** type of
23
23
  # the receiver class (`Nominal[X]`) — the narrowed
@@ -55,7 +55,7 @@ module Rigor
55
55
  # replaces the per-call plugins × block_as_methods linear scan.
56
56
  # Entries arrive in (plugin registration, declaration) order, so
57
57
  # the first ancestry match below is the same entry the previous
58
- # walk returned; the verb membership the old `matches?` checked
58
+ # walk returned; the method-name membership the old `matches?` checked
59
59
  # is guaranteed by the table key.
60
60
  entries = registry.contribution_index.block_entries_for(call_node.name)
61
61
  entries.each do |entry|
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # `Array#to_h { |x| [k, v] }` (and the no-block-pair tuple form's
9
+ # block sibling) return-type fold.
10
+ #
11
+ # `Enumerable#to_h` with a block maps every element to a
12
+ # `[key, value]` pair and collects them into a Hash. When the
13
+ # block's inferred return type is a recognizable 2-element
14
+ # `Tuple` (`[K, V]`), this tier projects the pair into a
15
+ # `Hash[K, V]` nominal whose key/value parameters are the
16
+ # widened pair types. Without this fold the call hits the RBS
17
+ # generic and the block's `[K, V]` return is dropped, typing as
18
+ # `Hash[Dynamic[top], Dynamic[top]]`.
19
+ #
20
+ # Value-pinned constants in the pair (`Constant[2]`) are widened
21
+ # to their nominal (`Integer`) for the Hash parameters: the built
22
+ # hash holds many keys, so pinning the parameter to a single
23
+ # element's literal would be unsound for the aggregate. The same
24
+ # widening the loop-body fixpoint applies (`Combinator#
25
+ # widen_value_pinned`) is reused.
26
+ #
27
+ # Declines (returns `nil`, leaving today's RBS answer) when:
28
+ #
29
+ # - there is no block at the call site,
30
+ # - the block return type is not a 2-element `Tuple`, or
31
+ # - the receiver is not an Array-shaped carrier (`Tuple` or
32
+ # `Array` nominal). Hash receivers keep their existing
33
+ # `ShapeDispatch#hash_to_h` identity fold.
34
+ module ArrayToHFolding
35
+ module_function
36
+
37
+ def try_dispatch(context)
38
+ return nil unless context.method_name == :to_h
39
+
40
+ block_type = context.block_type
41
+ return nil unless block_type.is_a?(Type::Tuple)
42
+ return nil unless block_type.elements.size == 2
43
+ return nil unless array_shaped?(context.receiver)
44
+
45
+ key = Type::Combinator.widen_value_pinned(block_type.elements[0])
46
+ value = Type::Combinator.widen_value_pinned(block_type.elements[1])
47
+ Type::Combinator.nominal_of("Hash", type_args: [key, value])
48
+ end
49
+
50
+ def array_shaped?(receiver)
51
+ case receiver
52
+ when Type::Tuple then true
53
+ when Type::Nominal then receiver.class_name == "Array"
54
+ else false
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -226,13 +226,54 @@ module Rigor
226
226
  case type
227
227
  when Type::Constant then [type.value]
228
228
  when Type::Union
229
- return nil unless type.members.all?(Type::Constant)
230
-
231
- type.members.map(&:value)
229
+ return type.members.map(&:value) if type.members.all?(Type::Constant)
230
+
231
+ # A union that mixes `Constant<Integer>` and `IntegerRange`
232
+ # members (e.g. an accumulator's running fixpoint assumption
233
+ # `1 | int<1, 6>`) folds as the bounding interval. The
234
+ # range-arithmetic path (`try_fold_binary_range`) then keeps
235
+ # the result an `IntegerRange` instead of bailing to Dynamic.
236
+ union_integer_bounds(type)
232
237
  when Type::IntegerRange then type
233
238
  end
234
239
  end
235
240
 
241
+ # Returns the bounding `IntegerRange` over a union whose members
242
+ # are each an Integer `Constant` or an `IntegerRange`; `nil`
243
+ # otherwise (a Float constant or any non-numeric member declines,
244
+ # so precision is never silently lost).
245
+ def union_integer_bounds(union)
246
+ lowers = []
247
+ uppers = []
248
+ union.members.each do |member|
249
+ case member
250
+ when Type::Constant
251
+ return nil unless member.value.is_a?(Integer)
252
+
253
+ lowers << member.value
254
+ uppers << member.value
255
+ when Type::IntegerRange
256
+ lowers << member.lower
257
+ uppers << member.upper
258
+ else
259
+ return nil
260
+ end
261
+ end
262
+ # `IntegerRange#lower`/`#upper` surface an unbounded edge as
263
+ # `±Float::INFINITY`; `integer_range` wants the `±∞` *sentinel*,
264
+ # so map the extremum back.
265
+ Type::Combinator.integer_range(infinity_to_sentinel(lowers.min),
266
+ infinity_to_sentinel(uppers.max))
267
+ end
268
+
269
+ def infinity_to_sentinel(bound)
270
+ case bound
271
+ when -Float::INFINITY then Type::IntegerRange::NEG_INFINITY
272
+ when Float::INFINITY then Type::IntegerRange::POS_INFINITY
273
+ else bound
274
+ end
275
+ end
276
+
236
277
  def try_fold_unary(set, method_name)
237
278
  case set
238
279
  when Array then try_fold_unary_set(set, method_name)
@@ -1265,17 +1306,16 @@ module Rigor
1265
1306
  end
1266
1307
  end
1267
1308
 
1268
- # `String#reverse` / `#swapcase` etc. produce a
1269
- # string the same size as the receiver; only the
1270
- # already-handled binary `:+` / `:*` paths can
1271
- # explode the output. No unary string method
1272
- # currently in the catalogue grows beyond the input
1273
- # size, so this hook is a no-op today — kept as a
1274
- # placeholder so future additions (e.g. `:succ` on
1275
- # very long strings) can be guarded without
1276
- # restructuring.
1277
- def string_unary_blow_up?(_receiver_value, _method_name)
1278
- false
1309
+ # `String#reverse` / `#swapcase` / `#succ` etc. produce a string
1310
+ # at least as large as the receiver. The binary `:+` / `:*` paths
1311
+ # have their own `string_blow_up?` output guard; this is the unary
1312
+ # analogue decline to fold a unary String op whose receiver is
1313
+ # already at or beyond `STRING_FOLD_BYTE_LIMIT`, since the folded
1314
+ # output would be just as large and constant-materialising it buys
1315
+ # no precision worth the bytes. Non-String receivers never blow up
1316
+ # through a unary op, so they pass.
1317
+ def string_unary_blow_up?(receiver_value, _method_name)
1318
+ receiver_value.is_a?(String) && receiver_value.bytesize >= STRING_FOLD_BYTE_LIMIT
1279
1319
  end
1280
1320
 
1281
1321
  # Scalar / String / Symbol values fold; everything
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+ require_relative "call_context"
5
+ require_relative "iterator_dispatch"
6
+
7
+ module Rigor
8
+ module Inference
9
+ module MethodDispatcher
10
+ # Symbol-form `reduce` / `inject` return-type tier.
11
+ #
12
+ # `IteratorDispatch.inject_block_params` deliberately declines the
13
+ # Symbol-call shapes (`(1..n).reduce(1, :*)`, `[1,2,3].reduce(:+)`)
14
+ # because they carry no block to bind parameters for — the decline
15
+ # of *block-param typing* is correct. What that decline leaves on
16
+ # the floor is the *return type*: with no block and no precise tier,
17
+ # the call falls to `Enumerable#reduce`'s RBS overload
18
+ # `(untyped, Symbol) -> untyped`, so the whole fold widens to
19
+ # `Dynamic[top]`.
20
+ #
21
+ # This tier recovers a precise return type for the Symbol-operand
22
+ # forms by dispatching the named operator on the accumulated type:
23
+ #
24
+ # - `(seed, :op)` (2-arg) — operand starts at the seed type `S`;
25
+ # the result type is `dispatch(:op, widen(S) ∪ widen(E), widen(E))`
26
+ # joined with the seed (the seed is returned unchanged when the
27
+ # collection is empty). `E` is the receiver's element type.
28
+ # - `(:op)` (1-arg) — no seed: the first element seeds the memo, so
29
+ # the operand type is `E` and the result is
30
+ # `dispatch(:op, widen(E), widen(E))`. RBS models this overload as
31
+ # `() { (E, E) -> E } -> E` (no nil), so the tier returns the
32
+ # operator result without manufacturing a nil — staying consistent
33
+ # with the declared type and Rigor's false-positive discipline
34
+ # (the empty-collection `nil` runtime case is not modelled by RBS
35
+ # and adding it here would only pressure callers into defensive
36
+ # nil-handling for code that works).
37
+ #
38
+ # The tier is precision-additive: it declines (returns nil, today's
39
+ # `Dynamic[top]` behaviour) for every shape it cannot prove —
40
+ # unknown element type, Dynamic / Top receiver, a non-`Constant`
41
+ # Symbol operand, or an operator the engine cannot dispatch on the
42
+ # widened operand types.
43
+ #
44
+ # `widen_value_pinned` (ADR-55/56) collapses `Constant`/`IntegerRange`
45
+ # operands to their nominal base before dispatch so the result is the
46
+ # operator's nominal return (`Integer`) rather than a constant-folded
47
+ # `Constant[120]` — full constant folding of the reduction is out of
48
+ # scope, the precision target is the carrier (`Integer`), not the value.
49
+ module ReduceFolding
50
+ module_function
51
+
52
+ REDUCE_METHODS = %i[reduce inject].freeze
53
+ private_constant :REDUCE_METHODS
54
+
55
+ # Allow-listed pure, side-effect-free fold operators. Each is
56
+ # safe on the foldable constant operand classes (Integer / Float
57
+ # / Rational); division / modulo by zero is caught by the rescue
58
+ # harness in `execute_constant_reduce` and declines to the
59
+ # nominal fold. `:gcd` / `:lcm` are valid binary Integer methods
60
+ # (`acc.public_send(:gcd, elem)`); `:min` / `:max` are *not* (no
61
+ # `Integer#min`/`#max` — they would raise and decline), so they
62
+ # are deliberately omitted.
63
+ CONSTANT_FOLD_OPERATORS = %i[+ * - / % & | ^ gcd lcm].freeze
64
+ private_constant :CONSTANT_FOLD_OPERATORS
65
+
66
+ # Hard caps (ADR-41 WD4 style). The element count is checked
67
+ # BEFORE the receiver is enumerated, so a `(1..1_000_000)` range
68
+ # never materialises. The bit-length cap rejects factorial-style
69
+ # blow-up (`(1..64).reduce(:*)` is a ~296-bit Integer) so the
70
+ # folded value can never become an analyzer-heavy bignum.
71
+ CONSTANT_FOLD_ELEMENT_CAP = 64
72
+ CONSTANT_FOLD_BIT_CAP = 256
73
+ private_constant :CONSTANT_FOLD_ELEMENT_CAP, :CONSTANT_FOLD_BIT_CAP
74
+
75
+ # @return [Rigor::Type, nil]
76
+ def try_dispatch(context)
77
+ return nil unless REDUCE_METHODS.include?(context.method_name)
78
+ return nil if context.block_type
79
+
80
+ args = context.args
81
+ operator, seed = operator_and_seed(args)
82
+ return nil if operator.nil?
83
+
84
+ # Precision pre-check: when the receiver is a fully-constant
85
+ # collection (a `Constant[Range]` with foldable endpoints or a
86
+ # `Tuple` of `Constant` elements) and the operator is an
87
+ # allow-listed pure op, treat the reduction as a pure function
88
+ # and execute it on the real values, capped. Any decline (size
89
+ # cap, non-constant member, magnitude, exception) falls through
90
+ # to the carrier-precise nominal fold below — byte-identical to
91
+ # pre-fold behaviour.
92
+ folded = try_constant_reduce(context.receiver, operator, seed)
93
+ return folded if folded
94
+
95
+ element = IteratorDispatch.element_type_of(context.receiver)
96
+ return nil if element.nil?
97
+
98
+ fold_result(operator, seed, element, context.environment)
99
+ end
100
+
101
+ # Executes the reduction on real constant values, or returns nil
102
+ # to decline. The caller falls through to the nominal fold on a
103
+ # nil return, so every guard here is precision-additive only.
104
+ #
105
+ # @param seed [Rigor::Type, nil] the optional seed type; only a
106
+ # foldable `Constant` seed is honoured, any other seed declines.
107
+ # @return [Rigor::Type::Constant, nil]
108
+ def try_constant_reduce(receiver, operator, seed)
109
+ return nil unless CONSTANT_FOLD_OPERATORS.include?(operator)
110
+
111
+ members = constant_members(receiver)
112
+ return nil if members.nil?
113
+
114
+ seed_value, seed_present = constant_seed(seed)
115
+ return nil if !seed.nil? && !seed_present
116
+
117
+ execute_constant_reduce(members, operator, seed_value, seed_present)
118
+ end
119
+
120
+ # Extracts the receiver's elements as a Ruby Array of foldable
121
+ # constant values, or nil to decline. Checks the size cap BEFORE
122
+ # enumerating so an unbounded / huge `Constant[Range]` (e.g.
123
+ # `(1..1_000_000)`) never calls `to_a`.
124
+ #
125
+ # @return [Array, nil]
126
+ def constant_members(receiver)
127
+ case receiver
128
+ when Type::Constant
129
+ constant_range_members(receiver.value)
130
+ when Type::Tuple
131
+ tuple_members(receiver.elements)
132
+ end
133
+ end
134
+
135
+ def constant_range_members(value)
136
+ return nil unless value.is_a?(Range)
137
+
138
+ first = value.begin
139
+ last = value.end
140
+ return nil unless foldable?(first) && foldable?(last)
141
+ # Reject endless / beginless ranges (`(1..)`, `(..5)`).
142
+ return nil if first.nil? || last.nil?
143
+
144
+ # Size BEFORE enumeration — `Range#size` is O(1) for numeric
145
+ # ranges and never materialises the elements.
146
+ size = value.size
147
+ return nil unless size.is_a?(Integer)
148
+ return nil if size > CONSTANT_FOLD_ELEMENT_CAP
149
+
150
+ value.to_a
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ def tuple_members(elements)
156
+ return nil if elements.size > CONSTANT_FOLD_ELEMENT_CAP
157
+ return nil unless elements.all? { |e| e.is_a?(Type::Constant) && foldable?(e.value) }
158
+
159
+ elements.map(&:value)
160
+ end
161
+
162
+ # @return [Array(Object, Boolean)] `[seed_value, present?]`. A nil
163
+ # seed type yields `[nil, false]` (no-seed form). A foldable
164
+ # `Constant` seed yields `[value, true]`. Any other seed yields
165
+ # `[nil, false]` so the caller can decline.
166
+ def constant_seed(seed)
167
+ return [nil, false] if seed.nil?
168
+ return [nil, false] unless seed.is_a?(Type::Constant) && foldable?(seed.value)
169
+
170
+ [seed.value, true]
171
+ end
172
+
173
+ # Runs the actual reduction, guarded by the rescue harness and
174
+ # the magnitude cap. Empty-collection semantics match Ruby:
175
+ # `[].reduce(s, :op) == s` (seed form) and `[].reduce(:op) == nil`
176
+ # (no-seed form → declines so the nominal fold's no-nil contract
177
+ # stands; see `fold_result`).
178
+ #
179
+ # @return [Rigor::Type::Constant, nil]
180
+ def execute_constant_reduce(members, operator, seed_value, seed_present)
181
+ result =
182
+ if seed_present
183
+ members.reduce(seed_value) { |acc, e| acc.public_send(operator, e) }
184
+ else
185
+ # No-seed empty collection reduces to nil; decline so the
186
+ # nominal no-nil contract is preserved rather than folding
187
+ # to `Constant[nil]`.
188
+ return nil if members.empty?
189
+
190
+ members.reduce { |acc, e| acc.public_send(operator, e) }
191
+ end
192
+
193
+ return nil unless foldable?(result)
194
+ return nil if magnitude_too_large?(result)
195
+
196
+ Type::Combinator.constant_of(result)
197
+ rescue StandardError
198
+ # Division by zero, type mismatch, or any operator error: decline.
199
+ nil
200
+ end
201
+
202
+ FOLDABLE_FOLD_CLASSES = [Integer, Float, Rational].freeze
203
+ private_constant :FOLDABLE_FOLD_CLASSES
204
+
205
+ def foldable?(value)
206
+ FOLDABLE_FOLD_CLASSES.any? { |klass| value.is_a?(klass) }
207
+ end
208
+
209
+ # Rejects bignum blow-up. A folded Integer wider than the bit cap
210
+ # (factorial, repeated multiplication) declines to the nominal
211
+ # `Integer` carrier rather than parking a heavy literal in the
212
+ # type graph. Float / Rational carry no comparable blow-up risk.
213
+ def magnitude_too_large?(result)
214
+ result.is_a?(Integer) && result.bit_length > CONSTANT_FOLD_BIT_CAP
215
+ end
216
+
217
+ # Splits the call's positional arguments into the operator Symbol
218
+ # and the optional seed. Returns `[nil, nil]` for any non-Symbol-
219
+ # operand shape so `try_dispatch` declines.
220
+ #
221
+ # - `[:op]` -> operator `:op`, no seed
222
+ # - `[seed, :op]` -> operator `:op`, seed `seed`
223
+ def operator_and_seed(args)
224
+ case args.size
225
+ when 1
226
+ sym = symbol_value(args[0])
227
+ sym ? [sym, nil] : [nil, nil]
228
+ when 2
229
+ sym = symbol_value(args[1])
230
+ sym ? [sym, args[0]] : [nil, nil]
231
+ else
232
+ [nil, nil]
233
+ end
234
+ end
235
+
236
+ def symbol_value(type)
237
+ return nil unless type.is_a?(Type::Constant)
238
+
239
+ type.value.is_a?(Symbol) ? type.value : nil
240
+ end
241
+
242
+ # Dispatches the operator on the widened operand types. With a
243
+ # seed the memo type spans `seed | element` (the first iteration's
244
+ # memo is the seed, every later iteration's memo is a previous
245
+ # operator result); without a seed the memo and operand are both
246
+ # the element type.
247
+ def fold_result(operator, seed, element, environment)
248
+ widened_element = Type::Combinator.widen_value_pinned(element)
249
+ memo = if seed.nil?
250
+ widened_element
251
+ else
252
+ Type::Combinator.union(
253
+ Type::Combinator.widen_value_pinned(seed), widened_element
254
+ )
255
+ end
256
+
257
+ result = dispatch_operator(memo, operator, widened_element, environment)
258
+ return nil if result.nil?
259
+
260
+ # The seed itself is the result when the collection is empty
261
+ # (`[].reduce(s, :op) == s`), so a 2-arg fold's static type is
262
+ # the operator result joined with the seed. The seed is widened
263
+ # (`Constant[0]` -> `Integer`) for the join so the carrier stays
264
+ # the precision target rather than leaking a value-pinned member
265
+ # (`0 | Integer`) — full constant folding of the fold is out of
266
+ # scope.
267
+ return result if seed.nil?
268
+
269
+ Type::Combinator.union(Type::Combinator.widen_value_pinned(seed), result)
270
+ end
271
+
272
+ def dispatch_operator(memo, operator, operand, environment)
273
+ MethodDispatcher.dispatch(
274
+ receiver_type: memo, method_name: operator,
275
+ arg_types: [operand], environment: environment
276
+ )
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
@@ -38,10 +38,81 @@ module Rigor
38
38
  return nil unless SingletonFolding.receiver?(receiver, "Regexp")
39
39
  return fold_escape(args) if REGEXP_ESCAPE_METHODS.include?(method_name)
40
40
  return fold_new(args) if method_name == :new
41
+ return fold_last_match(context) if method_name == :last_match
41
42
 
42
43
  nil
43
44
  end
44
45
 
46
+ # `Regexp.last_match` reads the same match-data slot the perlish
47
+ # `$~` global tracks. On a proven-match edge — the truthy branch
48
+ # of `if str =~ /re/`, the surviving path of `unless /re/ =~ s;
49
+ # raise; end`, a `case`/`when` regex arm — the flow engine has
50
+ # already narrowed `$~` to a non-nil `MatchData` (see
51
+ # `Narrowing#regex_match_predicate_scopes`). Upstream RBS types
52
+ # `Regexp.last_match` as `() -> MatchData?` / `(int) -> String?`,
53
+ # so without this consult the call drops back to the nilable
54
+ # return even where the surrounding flow has *proven* the match
55
+ # succeeded — re-introducing the `possible nil receiver` the `$~`
56
+ # narrowing exists to remove the moment the code reaches for the
57
+ # match through the method rather than the global.
58
+ #
59
+ # Mirrors the global's narrowing exactly, so it rides the same
60
+ # `Scope#forget_match_globals` invalidation (an intervening call
61
+ # that can re-run a match clears `$~`, and this consult clears
62
+ # with it):
63
+ #
64
+ # * 0-arg -> `MatchData` (the narrowed `$~`)
65
+ # * `(N)` -> `String` when capture group N is unconditional on
66
+ # every successful match (the same set the `$N`
67
+ # globals narrow), else `String?` (an optional /
68
+ # alternation-reachable group is nil at runtime even
69
+ # on a successful match).
70
+ # * `(:name)` / `(0)` and other forms defer to RBS.
71
+ #
72
+ # Declines (returns nil -> RBS) whenever `$~` is NOT proven
73
+ # non-nil: no match edge established it, or a falsey edge bound it
74
+ # to `Constant[nil]`. Narrowing only on the proven edge keeps the
75
+ # bare `m = Regexp.last_match; m[...]` (no preceding match) firing
76
+ # exactly as today — this never invents a match.
77
+ def fold_last_match(context)
78
+ scope = context.scope
79
+ return nil if scope.nil?
80
+
81
+ match_data = scope.global(:$~)
82
+ return nil unless proven_match?(match_data)
83
+
84
+ args = context.args
85
+ return Type::Combinator.nominal_of("MatchData") if args.empty?
86
+ return nil unless args.size == 1
87
+
88
+ fold_last_match_group(args.first, scope)
89
+ end
90
+
91
+ # Narrowed `$~` is a non-nil `Nominal[MatchData]`. A bare
92
+ # `MatchData?` / `nil` / Dynamic binding is NOT a proven match.
93
+ def proven_match?(match_data)
94
+ match_data.is_a?(Type::Nominal) && match_data.class_name == "MatchData"
95
+ end
96
+
97
+ # `Regexp.last_match(N)` for a value-pinned positive Integer N.
98
+ # Track the matching `$N` global the predicate edge already
99
+ # narrowed: present (`String`) means the group is unconditional,
100
+ # absent means it is optional / alternation-reachable (nil at
101
+ # runtime on a success), so we surface `String?`. A non-constant
102
+ # or non-positive index defers to RBS.
103
+ def fold_last_match_group(arg, scope)
104
+ return nil unless arg.is_a?(Type::Constant)
105
+
106
+ index = arg.value
107
+ return nil unless index.is_a?(Integer) && index.positive?
108
+
109
+ string_t = Type::Combinator.nominal_of("String")
110
+ group_global = scope.global(:"$#{index}")
111
+ return string_t if group_global.is_a?(Type::Nominal) && group_global.class_name == "String"
112
+
113
+ Type::Combinator.union(string_t, Type::Combinator.constant_of(nil))
114
+ end
115
+
45
116
  # `Regexp.escape(str)` / `.quote(str)` — one String arg.
46
117
  def fold_escape(args)
47
118
  return nil unless args.size == 1