rigortype 0.1.17 → 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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  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 +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -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
@@ -98,7 +98,8 @@ module Rigor
98
98
  uniq: :tuple_uniq,
99
99
  index: :tuple_find_index,
100
100
  find_index: :tuple_find_index,
101
- rindex: :tuple_rindex
101
+ rindex: :tuple_rindex,
102
+ flatten: :tuple_flatten
102
103
  }.freeze
103
104
 
104
105
  HASH_SHAPE_HANDLERS = {
@@ -228,15 +229,8 @@ module Rigor
228
229
  end
229
230
 
230
231
  def dispatch_nominal_size(nominal, method_name, args)
231
- if nominal.class_name == "String" && args.size == 1
232
- string_binary = dispatch_string_binary_from_arg(method_name, args.first)
233
- return string_binary if string_binary
234
- end
235
-
236
- if nominal.class_name == "Integer" && args.size == 1
237
- integer_binary = dispatch_integer_binary_from_arg(method_name, args.first)
238
- return integer_binary if integer_binary
239
- end
232
+ projection = nominal_projection(nominal, method_name, args)
233
+ return projection if projection
240
234
 
241
235
  return nil unless args.empty?
242
236
 
@@ -246,6 +240,106 @@ module Rigor
246
240
  Type::Combinator.non_negative_int
247
241
  end
248
242
 
243
+ # Arg-/method-driven precision projections for a `Nominal`
244
+ # receiver, consulted ahead of the no-arg size tier. Each
245
+ # branch gates on the class name first so unrelated nominals
246
+ # skip the work. Returns nil when no projection applies.
247
+ def nominal_projection(nominal, method_name, args)
248
+ case nominal.class_name
249
+ when "String"
250
+ dispatch_string_binary_from_arg(method_name, args.first) if args.size == 1
251
+ when "Integer"
252
+ dispatch_integer_binary_from_arg(method_name, args.first) if args.size == 1
253
+ when "Array"
254
+ case method_name
255
+ when :flatten then array_nominal_flatten(nominal, args)
256
+ when :compact then array_nominal_compact(nominal, args)
257
+ end
258
+ end
259
+ end
260
+
261
+ # `Array[T]#compact` — `compact` removes every `nil` element,
262
+ # so the result element type is `T` with its `nil` constituent
263
+ # stripped (`Array[Node?]#compact` → `Array[Node]`). Mirrors
264
+ # the `Tuple#compact` constant fold for the generic element
265
+ # case. Declines when the receiver carries no type argument
266
+ # (the RBS `Array[untyped]` answer is already maximal) or when
267
+ # `T` has no `nil` constituent to remove (the result equals the
268
+ # receiver, so the RBS tier's answer is already precise).
269
+ def array_nominal_compact(nominal, args)
270
+ return nil unless args.empty?
271
+
272
+ element = nominal.type_args&.first
273
+ return nil if element.nil?
274
+
275
+ stripped = strip_nil_constituent(element)
276
+ return nil if stripped.equal?(element)
277
+
278
+ Type::Combinator.nominal_of("Array", type_args: [stripped])
279
+ end
280
+
281
+ # Removes the `nil` constituent from a (possibly union) type,
282
+ # returning the same object when there is nothing to remove so
283
+ # callers can detect the no-op cheaply. Kept local to the
284
+ # dispatch tier to avoid a dependency on the narrowing module.
285
+ def strip_nil_constituent(type)
286
+ case type
287
+ when Type::Constant
288
+ type.value.nil? ? Type::Combinator.bot : type
289
+ when Type::Nominal
290
+ type.class_name == "NilClass" ? Type::Combinator.bot : type
291
+ when Type::Union
292
+ kept = type.members.map { |m| strip_nil_constituent(m) }
293
+ return type if kept.zip(type.members).all? { |k, m| k.equal?(m) }
294
+
295
+ Type::Combinator.union(*kept)
296
+ else
297
+ type
298
+ end
299
+ end
300
+
301
+ # `Array[T]#flatten` (and `flatten(depth)`). When `T` is a
302
+ # nested `Array[U]` nominal, one flatten level yields the
303
+ # joined inner element type — `Array[Array[U]]#flatten` →
304
+ # `Array[U]`. When `T` is non-nested the result is `Array[T]`
305
+ # unchanged (Ruby returns a copy with the same element type).
306
+ # Multi-level nesting is handled conservatively: each level
307
+ # joins its element types, and a `depth` argument that does
308
+ # not fully resolve the nesting still produces a sound
309
+ # superset. Declines on an `Array` with no type argument
310
+ # (the RBS `Array[untyped]` answer is already as precise as
311
+ # we can be) and on a non-static depth argument.
312
+ def array_nominal_flatten(nominal, args)
313
+ element = nominal.type_args&.first
314
+ return nil if element.nil?
315
+
316
+ depth = tuple_flatten_depth(args)
317
+ return nil if depth == :decline
318
+
319
+ flattened = flatten_nominal_element(element, depth)
320
+ Type::Combinator.nominal_of("Array", type_args: [flattened])
321
+ end
322
+
323
+ # Resolves the element type of a flattened `Array[element]`.
324
+ # Each `Array[U]` nesting level contributes `U`; the per-level
325
+ # element types are unioned. `depth < 0` recurses without
326
+ # bound; `depth == 0` stops (Ruby's `flatten(0)` is a no-op
327
+ # copy and returns the element unchanged).
328
+ def flatten_nominal_element(element, depth)
329
+ return element if depth.zero?
330
+ return element unless array_nominal?(element)
331
+
332
+ inner = element.type_args.first
333
+ return element if inner.nil?
334
+
335
+ flatten_nominal_element(inner, depth - 1)
336
+ end
337
+
338
+ def array_nominal?(type)
339
+ type.is_a?(Type::Nominal) && type.class_name == "Array" && !type.type_args.nil? &&
340
+ !type.type_args.empty?
341
+ end
342
+
249
343
  # Arg-type-driven String binary projections for any String-typed
250
344
  # receiver (including Nominal, Refined, and Difference fallbacks).
251
345
  # Called before the no-arg size guard so binary operators are seen.
@@ -875,6 +969,50 @@ module Rigor
875
969
  constant_index(tuple, args) { |elements, value| elements.index { |e| e.value == value } }
876
970
  end
877
971
 
972
+ # `tuple.flatten` / `tuple.flatten(depth)` — recursively
973
+ # flattens nested Tuple elements into a single Tuple. With
974
+ # no argument the flatten is unbounded (matching Ruby's
975
+ # `Array#flatten`); a `Constant[Integer]` depth bounds it.
976
+ # Non-Tuple elements (scalars, `Array[T]` nominals, …) pass
977
+ # through unchanged at their level. A non-static depth
978
+ # argument (or a non-Integer one) declines so RBS answers.
979
+ def tuple_flatten(tuple, _method_name, args)
980
+ depth = tuple_flatten_depth(args)
981
+ return nil if depth == :decline
982
+
983
+ Type::Combinator.tuple_of(*flatten_elements(tuple.elements, depth))
984
+ end
985
+
986
+ # Returns the requested flatten depth: `-1` for the no-arg
987
+ # (unbounded) form, the Integer for a `Constant[Integer]`
988
+ # argument, or `:decline` for any non-static / wrong-arity
989
+ # argument shape.
990
+ def tuple_flatten_depth(args)
991
+ return -1 if args.empty?
992
+ return :decline unless args.size == 1
993
+
994
+ arg = args.first
995
+ return arg.value if arg.is_a?(Type::Constant) && arg.value.is_a?(Integer)
996
+
997
+ :decline
998
+ end
999
+
1000
+ # Flattens a list of element types to `depth` levels.
1001
+ # `depth < 0` means unbounded. A Tuple element is spliced
1002
+ # in (recursing with `depth - 1`); everything else passes
1003
+ # through at this level.
1004
+ def flatten_elements(elements, depth)
1005
+ return elements if depth.zero?
1006
+
1007
+ elements.flat_map do |element|
1008
+ if element.is_a?(Type::Tuple)
1009
+ flatten_elements(element.elements, depth - 1)
1010
+ else
1011
+ [element]
1012
+ end
1013
+ end
1014
+ end
1015
+
878
1016
  # `rindex(obj)` → the LAST matching index, same decidability gate.
879
1017
  def tuple_rindex(tuple, _method_name, args)
880
1018
  constant_index(tuple, args) { |elements, value| elements.rindex { |e| e.value == value } }