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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Thread-local event recorder behind `rigor trace`: while a block runs
6
+ # under {record}, the inference engine emits a flat, ordered event
7
+ # stream describing HOW it typed the program — expression enter/result
8
+ # pairs, scope binds, union formation, and method-dispatch outcomes.
9
+ # The CLI replays that stream as a terminal animation (or dumps it as
10
+ # JSON); the engine itself never reads the events back, so recording
11
+ # is purely observational and MUST NOT change any inferred type.
12
+ #
13
+ # Modelled on {Analysis::DependencyRecorder}: thread-local state, a
14
+ # module-level activation count so the disabled fast path ({active?})
15
+ # is a plain integer read, and a frozen snapshot for consumers. The
16
+ # instrumented hot paths (`ExpressionTyper#type_of`,
17
+ # `Scope#with_local`, `Type::Combinator.union`,
18
+ # `MethodDispatcher.dispatch`) each guard their emit behind {active?},
19
+ # so a normal (non-tracing) run pays one integer comparison.
20
+ module FlowTracer
21
+ KEY = :__rigor_flow_tracer__
22
+ private_constant :KEY
23
+
24
+ # One animation-relevant moment.
25
+ #
26
+ # kind :enter | :result | :bind | :union | :dispatch
27
+ # depth expression-recursion depth at emit time (0 = statement level)
28
+ # location frozen Hash with :start_line/:start_column/:end_line/
29
+ # :end_column/:start_offset/:end_offset, or nil. Events
30
+ # without a node of their own (:bind, :union) inherit the
31
+ # innermost in-flight expression node's location so the
32
+ # replayer can still highlight the source being evaluated.
33
+ # stack frozen Array of short node-class names, outermost first
34
+ # data frozen kind-specific Hash (types pre-rendered as Strings
35
+ # via `describe(:short)` so events serialise to JSON as-is)
36
+ Event = Data.define(:kind, :depth, :location, :stack, :data)
37
+
38
+ # Mutable per-thread accumulator; only ever touched by the thread
39
+ # that activated it, so no locking is needed on the emit path.
40
+ class Recorder
41
+ attr_reader :events
42
+
43
+ def initialize
44
+ @events = []
45
+ @stack = []
46
+ end
47
+
48
+ # Brackets one `ExpressionTyper#type_of` recursion: emits :enter,
49
+ # runs the real inference, emits :result with the inferred type,
50
+ # and returns the type unchanged.
51
+ def node(node)
52
+ location = location_of(node)
53
+ name = short_name(node.class)
54
+ emit(:enter, location: location, data: { node: name })
55
+ @stack.push(node)
56
+ result = nil
57
+ begin
58
+ result = yield
59
+ ensure
60
+ @stack.pop
61
+ end
62
+ emit(:result, location: location, data: { node: name, type: FlowTracer.describe(result) })
63
+ result
64
+ end
65
+
66
+ def emit(kind, location: nil, data: {})
67
+ @events << Event.new(
68
+ kind: kind,
69
+ depth: @stack.size,
70
+ location: (location || current_location)&.freeze,
71
+ stack: @stack.map { |n| short_name(n.class) }.freeze,
72
+ data: data.freeze
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ def current_location
79
+ location_of(@stack.last)
80
+ end
81
+
82
+ def location_of(node)
83
+ return nil unless node.respond_to?(:location) && node.location
84
+
85
+ loc = node.location
86
+ {
87
+ start_line: loc.start_line, start_column: loc.start_column,
88
+ end_line: loc.end_line, end_column: loc.end_column,
89
+ start_offset: loc.start_offset, end_offset: loc.end_offset
90
+ }
91
+ end
92
+
93
+ def short_name(klass)
94
+ klass.name.to_s.split("::").last
95
+ end
96
+ end
97
+
98
+ @active_count = 0
99
+ @mutex = Mutex.new
100
+
101
+ module_function
102
+
103
+ # Activates recording on the current thread for the duration of the
104
+ # block and returns the frozen event list. Nests safely; restores
105
+ # the previous recorder on exit.
106
+ def record
107
+ previous = Thread.current[KEY]
108
+ recorder = Recorder.new
109
+ Thread.current[KEY] = recorder
110
+ @mutex.synchronize { @active_count += 1 }
111
+ yield
112
+ recorder.events.freeze
113
+ ensure
114
+ Thread.current[KEY] = previous
115
+ @mutex.synchronize { @active_count -= 1 }
116
+ end
117
+
118
+ # Plain integer read (GVL-atomic) — the disabled fast path.
119
+ def active?
120
+ @active_count.positive?
121
+ end
122
+
123
+ # Brackets one expression-typing recursion. Falls through to the
124
+ # bare block when the current thread is not recording (another
125
+ # thread may have flipped {active?}).
126
+ def trace_node(node, &)
127
+ recorder = Thread.current[KEY]
128
+ return yield unless recorder
129
+
130
+ recorder.node(node, &)
131
+ end
132
+
133
+ # `Scope#with_local` — the moment a local enters the scope.
134
+ def bind(name, type)
135
+ Thread.current[KEY]&.emit(:bind, data: { name: name.to_s, type: describe(type) })
136
+ end
137
+
138
+ # `Type::Combinator.union` — the moment branch types merge
139
+ # (including degenerate collapses like `1 | 1 → 1`).
140
+ def union(members, result)
141
+ Thread.current[KEY]&.emit(
142
+ :union,
143
+ data: { members: members.map { |m| describe(m) }.freeze, type: describe(result) }
144
+ )
145
+ end
146
+
147
+ # `MethodDispatcher.dispatch` — resolution or the fail-soft `nil`
148
+ # ("no rule matched"; the caller will widen to `Dynamic[Top]`).
149
+ def dispatch(receiver:, method_name:, args:, result:, location: nil)
150
+ recorder = Thread.current[KEY]
151
+ return unless recorder
152
+
153
+ recorder.emit(
154
+ :dispatch,
155
+ location: location && location_hash(location),
156
+ data: {
157
+ receiver: describe(receiver), method: method_name.to_s,
158
+ args: args.map { |a| describe(a) }.freeze,
159
+ type: result && describe(result), resolved: !result.nil?
160
+ }
161
+ )
162
+ end
163
+
164
+ def describe(type)
165
+ return "nil" if type.nil?
166
+ return type.describe(:short) if type.respond_to?(:describe)
167
+
168
+ type.inspect
169
+ end
170
+
171
+ def location_hash(loc)
172
+ {
173
+ start_line: loc.start_line, start_column: loc.start_column,
174
+ end_line: loc.end_line, end_column: loc.end_column,
175
+ start_offset: loc.start_offset, end_offset: loc.end_offset
176
+ }
177
+ end
178
+ end
179
+ end
180
+ end
@@ -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
@@ -51,11 +51,16 @@ module Rigor
51
51
  receiver_class_name = singleton_receiver_class_name(receiver_type)
52
52
  return nil if receiver_class_name.nil?
53
53
 
54
- verb = call_node.name
55
- registry.plugins.each do |plugin|
56
- plugin.manifest.block_as_methods.each do |entry| # rigor:disable undefined-method
57
- return instance_type_for(receiver_class_name, environment) if matches?(entry, verb, receiver_class_name,
58
- environment)
54
+ # ADR-52 WD1 — the verb-keyed table compiled at registry build
55
+ # replaces the per-call plugins × block_as_methods linear scan.
56
+ # Entries arrive in (plugin registration, declaration) order, so
57
+ # the first ancestry match below is the same entry the previous
58
+ # walk returned; the method-name membership the old `matches?` checked
59
+ # is guaranteed by the table key.
60
+ entries = registry.contribution_index.block_entries_for(call_node.name)
61
+ entries.each do |entry|
62
+ if receiver_class_inherits_from?(receiver_class_name, entry.receiver_constraint, environment)
63
+ return instance_type_for(receiver_class_name, environment)
59
64
  end
60
65
  end
61
66
  nil
@@ -73,12 +78,6 @@ module Rigor
73
78
  receiver_type.class_name
74
79
  end
75
80
 
76
- def matches?(entry, verb, receiver_class_name, environment)
77
- return false unless entry.verbs.include?(verb)
78
-
79
- receiver_class_inherits_from?(receiver_class_name, entry.receiver_constraint, environment)
80
- end
81
-
82
81
  def receiver_class_inherits_from?(class_name, constraint, environment)
83
82
  return true if class_name == constraint
84
83
 
@@ -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
@@ -32,7 +32,16 @@ module Rigor
32
32
  # matches, accept the first arity-and-gradual-accept match
33
33
  # (the v0.1.1 behaviour). Alias / Interface / Intersection
34
34
  # params still reach this pass, so call sites whose only
35
- # candidate IS an alias-typed overload keep working.
35
+ # candidate IS an alias-typed overload keep working. One
36
+ # exclusion: an `untyped` argument does NOT gradually match
37
+ # a value-pinning param (`nil` / literal types — carriers
38
+ # that admit only specific values). Those overloads carry
39
+ # value-precise returns (`Kernel#Array: (nil) -> []`,
40
+ # `Regexp#=~: (nil) -> nil`) that would otherwise win purely
41
+ # by list position and inject false constants into the flow;
42
+ # they remain selectable when the argument PROVES the value
43
+ # (strict pass) or when no other overload matches (step 4's
44
+ # fallback picks the first overload regardless).
36
45
  # 4. If no overload matches at all, fall back to
37
46
  # `method_types.first` so existing call sites keep their
38
47
  # phase 1 / 2b behavior. This preserves the fail-soft
@@ -393,9 +402,32 @@ module Rigor
393
402
  instance_type: instance_type,
394
403
  type_vars: type_vars
395
404
  )
405
+ # An `untyped` arg gradually accepts against every param,
406
+ # so a value-pinning param would be "matched" with zero
407
+ # evidence and its value-precise return (`(nil) -> []`)
408
+ # would beat broader overloads purely by list position.
409
+ # Decline the pair; only the strict pass (where the arg
410
+ # proves the value) or the final first-overload fallback
411
+ # may select such an overload. (Pass 1 already skips
412
+ # untyped args entirely, so this only engages pass 2.)
413
+ return false if untyped_arg?(arg) && value_pinning?(param_type)
414
+
396
415
  result = param_type.accepts(arg, mode: :gradual)
397
416
  result.yes? || result.maybe?
398
417
  end
418
+
419
+ # A type that admits only specific VALUES rather than a
420
+ # class of values: a `Constant` carrier (RBS `nil` and
421
+ # literal types both translate to one) or a union made up
422
+ # entirely of them (`true | false`, `1 | 2`, `nil?`-style
423
+ # optionals of literals).
424
+ def value_pinning?(type)
425
+ case type
426
+ when Type::Constant then true
427
+ when Type::Union then type.members.all? { |member| value_pinning?(member) }
428
+ else false
429
+ end
430
+ end
399
431
  end
400
432
  end
401
433
  end