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
@@ -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 } }
@@ -14,7 +14,9 @@ require_relative "method_dispatcher/shape_dispatch"
14
14
  require_relative "method_dispatcher/data_folding"
15
15
  require_relative "method_dispatcher/rbs_dispatch"
16
16
  require_relative "method_dispatcher/iterator_dispatch"
17
+ require_relative "method_dispatcher/reduce_folding"
17
18
  require_relative "method_dispatcher/block_folding"
19
+ require_relative "method_dispatcher/array_to_h_folding"
18
20
  require_relative "method_dispatcher/file_folding"
19
21
  require_relative "method_dispatcher/shellwords_folding"
20
22
  require_relative "method_dispatcher/math_folding"
@@ -274,7 +276,14 @@ module Rigor
274
276
  class_name, kind = discovered_method_lookup(receiver_type)
275
277
  return nil if class_name.nil?
276
278
  return nil unless scope.discovered_method?(class_name, method_name, kind)
279
+ # Decline when a re-typable body is recorded for the method, so the
280
+ # downstream `ExpressionTyper` inference tier can fold a precise
281
+ # return instead of collapsing to `Dynamic[top]` here — instance
282
+ # bodies via `user_def_for`, singleton bodies (`def self.x` /
283
+ # `module_function`) via `singleton_def_for` (module-singleton
284
+ # call resolution, ADR-57 follow-up).
277
285
  return nil if kind == :instance && scope.user_def_for(class_name, method_name)
286
+ return nil if kind == :singleton && scope.singleton_def_for(class_name, method_name)
278
287
 
279
288
  Type::Combinator.untyped
280
289
  end
@@ -792,7 +801,7 @@ module Rigor
792
801
  private_constant :STDLIB_SINGLETON_FOLDERS
793
802
 
794
803
  PRECISE_TIERS_TAIL = Ractor.make_shareable([
795
- KernelDispatch, MethodFolding, BlockFolding
804
+ KernelDispatch, MethodFolding, ReduceFolding, ArrayToHFolding, BlockFolding
796
805
  ].freeze)
797
806
  private_constant :PRECISE_TIERS_TAIL
798
807
 
@@ -952,18 +961,53 @@ module Rigor
952
961
  set_lift = set_new_lift(receiver_type.class_name, arg_types)
953
962
  return set_lift if set_lift
954
963
 
964
+ hash_lift = hash_new_lift(receiver_type.class_name, arg_types)
965
+ return hash_lift if hash_lift
966
+
955
967
  regexp_lift = regexp_new_lift(receiver_type.class_name, arg_types)
956
968
  return regexp_lift if regexp_lift
957
969
 
958
970
  date_lift = date_new_lift(receiver_type.class_name, arg_types)
959
971
  return date_lift if date_lift
960
972
 
973
+ struct_new_lift = struct_new_lift(receiver_type.class_name, arg_types)
974
+ return struct_new_lift if struct_new_lift
975
+
961
976
  class_new_lift = class_new_lift(receiver_type.class_name, arg_types)
962
977
  return class_new_lift if class_new_lift
963
978
 
964
979
  Type::Combinator.nominal_of(receiver_type.class_name)
965
980
  end
966
981
 
982
+ # `Struct.new(:a, :b)` synthesises an anonymous Struct *subclass*
983
+ # (a class object), not a Struct *instance* — so the chained
984
+ # idiom `Struct.new(:a, :b).new(1, 2)` must resolve `.new` again
985
+ # on a class-like carrier. The constant-bound form
986
+ # (`S = Struct.new(:a); S.new(1)`) already records `Singleton[S]`
987
+ # via `ScopeIndexer#record_meta_new_constant?`; this lift gives
988
+ # the *chained* (anonymous) position the same class-like carrier
989
+ # so the trailing `.new` dispatches instead of firing a spurious
990
+ # `undefined method 'new' for Struct`.
991
+ #
992
+ # The disambiguation mirrors `ScopeIndexer#struct_new_call?`: a
993
+ # call whose positionals are all `Constant<Symbol>` literals is a
994
+ # member-list class definition → `Singleton[Struct]`. The
995
+ # following `AnonStruct.new(1, 2)` carries non-symbol args, so it
996
+ # falls through this gate to `Nominal[Struct]` (a fresh instance)
997
+ # via the `meta_new` tail. ADR-48 deferred full Struct *value*
998
+ # folding (member-reader precision) on mutability grounds; this is
999
+ # the narrower `.new`-dispatch-only fix and contributes no member
1000
+ # layout, so `instance.a` stays at its RBS/Dynamic type.
1001
+ def struct_new_lift(class_name, arg_types)
1002
+ return nil unless class_name == "Struct"
1003
+
1004
+ positional = arg_types.grep_v(Type::HashShape)
1005
+ return nil if positional.empty?
1006
+ return nil unless positional.all? { |t| t.is_a?(Type::Constant) && t.value.is_a?(Symbol) }
1007
+
1008
+ Type::Combinator.singleton_of("Struct")
1009
+ end
1010
+
967
1011
  # `Class.new` and `Class.new(Parent)` create a brand-new
968
1012
  # anonymous class. Statically that class is representable as
969
1013
  # the parent's singleton type — its singleton-method surface
@@ -1050,6 +1094,33 @@ module Rigor
1050
1094
  type
1051
1095
  end
1052
1096
 
1097
+ # `Hash.new(default)` — lifts the default value's type into the
1098
+ # Hash's value parameter so a subsequent `h[k]` read surfaces the
1099
+ # default type rather than `Dynamic[top]`. The common counter
1100
+ # idiom `h = Hash.new(0); h[k] += 1` then types the read as
1101
+ # `Integer`. The key parameter is left `untyped` (the default
1102
+ # carrier imposes no key constraint), so reads of any key resolve
1103
+ # through the value parameter. A value-pinned `Constant` default
1104
+ # (`0`) is widened to its nominal (`Integer`): the hash's values
1105
+ # mutate over its lifetime, so pinning the parameter to the
1106
+ # literal would be unsound for the aggregate.
1107
+ #
1108
+ # Only the single-argument default form folds. The zero-arg
1109
+ # (`Hash.new`) and the block form (`Hash.new { |h, k| … }`) keep
1110
+ # the bare `Nominal[Hash]` answer — the block's value type is not
1111
+ # available at this `:new` dispatch site, and conservatively
1112
+ # leaving the read as today's behaviour is precision-additive.
1113
+ def hash_new_lift(class_name, arg_types)
1114
+ return nil unless class_name == "Hash"
1115
+ return nil unless arg_types.size == 1
1116
+
1117
+ default = arg_types.first
1118
+ return nil if default.nil?
1119
+
1120
+ value = Type::Combinator.widen_value_pinned(default)
1121
+ Type::Combinator.nominal_of("Hash", type_args: [Type::Combinator.untyped, value])
1122
+ end
1123
+
1053
1124
  # `Range.new(b, e)` / `Range.new(b, e, excl)` — folds to
1054
1125
  # `Constant[Range]` when both endpoints are `Constant[Integer]`
1055
1126
  # or both are `Constant[String]`, and the optional third argument
@@ -32,6 +32,35 @@ module Rigor
32
32
  # `#singleton_method`.
33
33
  #
34
34
  # See docs/internal-spec/inference-engine.md for the binding contract.
35
+ # Leaf-name extraction for a destructured positional parameter
36
+ # (`Prism::MultiTargetNode`). Stateless; lifted out of
37
+ # {MethodParameterBinder} so the binder's class length stays in
38
+ # budget.
39
+ module Destructure
40
+ module_function
41
+
42
+ # Collect every leaf local name a `MultiTargetNode` binds,
43
+ # recursing through nested destructures (`((a, b), c)`) and the
44
+ # splat slot (`(a, *rest)`). Targets without a `#name` (an
45
+ # index/call write target, vanishingly rare in a parameter
46
+ # position) are skipped — there is no local to bind.
47
+ def target_names(multi_target)
48
+ entries = multi_target.lefts + [multi_target.rest, *multi_target.rights].compact
49
+ entries.flat_map { |entry| names_for_entry(entry) }
50
+ end
51
+
52
+ def names_for_entry(entry)
53
+ # A splat sub-target (`*rest` inside the destructure) wraps its
54
+ # real target in a `SplatNode#expression`; unwrap it.
55
+ entry = entry.expression if entry.is_a?(Prism::SplatNode) && entry.expression
56
+ return [] if entry.nil?
57
+ return target_names(entry) if entry.is_a?(Prism::MultiTargetNode)
58
+ return [entry.name] if entry.respond_to?(:name) && entry.name
59
+
60
+ []
61
+ end
62
+ end
63
+
35
64
  class MethodParameterBinder
36
65
  # @param environment [Rigor::Environment]
37
66
  # @param class_path [String, nil] the qualified name of the class
@@ -112,14 +141,39 @@ module Rigor
112
141
 
113
142
  def positional_slots(params_node)
114
143
  slots = []
115
- params_node.requireds.each_with_index { |p, i| slots << ParamSlot.new(:required_positional, p.name, i) }
144
+ params_node.requireds.each_with_index do |p, i|
145
+ append_positional_slot(slots, :required_positional, p, i)
146
+ end
116
147
  params_node.optionals.each_with_index { |p, i| slots << ParamSlot.new(:optional_positional, p.name, i) }
117
148
  rest = params_node.rest
118
149
  slots << ParamSlot.new(:rest_positional, rest.name, nil) if rest.respond_to?(:name) && rest&.name
119
- params_node.posts.each_with_index { |p, i| slots << ParamSlot.new(:trailing_positional, p.name, i) }
150
+ params_node.posts.each_with_index do |p, i|
151
+ append_positional_slot(slots, :trailing_positional, p, i)
152
+ end
120
153
  slots
121
154
  end
122
155
 
156
+ # A destructured positional parameter — `def f((a, b))` — is a
157
+ # `Prism::MultiTargetNode` in the `requireds`/`posts` list, not a
158
+ # `RequiredParameterNode`, so it has no `#name`. Bind each leaf
159
+ # sub-target local to `Dynamic[Top]` (a `:destructured_positional`
160
+ # slot with no RBS index) instead of crashing on a blind `.name`.
161
+ # Binding the names at all is what matters: it keeps the
162
+ # destructured locals present in the entry scope so the body's
163
+ # reads of them don't fall through to undefined-local noise. The
164
+ # element types are not cheaply available from the parameter list
165
+ # alone (no RBS function param maps onto a destructured slot), so
166
+ # `Dynamic[Top]` is the sound default.
167
+ def append_positional_slot(slots, kind, param, index)
168
+ if param.is_a?(Prism::MultiTargetNode)
169
+ Destructure.target_names(param).each do |name|
170
+ slots << ParamSlot.new(:destructured_positional, name, nil)
171
+ end
172
+ else
173
+ slots << ParamSlot.new(kind, param.name, index)
174
+ end
175
+ end
176
+
123
177
  def keyword_slots(params_node)
124
178
  params_node.keywords.filter_map do |kw|
125
179
  case kw
@@ -100,19 +100,62 @@ module Rigor
100
100
 
101
101
  def decompose_tuple(tuple, front_count, back_count, rest_present:)
102
102
  elements = tuple.elements
103
- fronts = Array.new(front_count) { |i| elements[i] || Type::Combinator.constant_of(nil) }
103
+ fronts = Array.new(front_count) { |i| slot_type(elements, i) }
104
104
  if rest_present
105
105
  middle_end = [elements.size - back_count, front_count].max
106
106
  middle = elements[front_count...middle_end] || []
107
107
  rest_type = Type::Combinator.tuple_of(*middle)
108
- backs = Array.new(back_count) { |i| elements[middle_end + i] || Type::Combinator.constant_of(nil) }
108
+ backs = Array.new(back_count) { |i| slot_type(elements, middle_end + i) }
109
109
  else
110
110
  rest_type = nil
111
- backs = Array.new(back_count) { |i| elements[front_count + i] || Type::Combinator.constant_of(nil) }
111
+ backs = Array.new(back_count) { |i| slot_type(elements, front_count + i) }
112
112
  end
113
113
  [fronts, rest_type, backs]
114
114
  end
115
115
 
116
+ # The per-slot type for index `i` of a tuple decomposition, FP-safely
117
+ # softened: a missing slot is `nil` (the runtime value of an
118
+ # over-destructured positional), and a PRESENT but nil-bearing slot
119
+ # (`X | nil`) is softened to its non-`nil` part — for a heterogeneous
120
+ # `Tuple` whose optional slot was made optional by flow.
121
+ #
122
+ # Rationale (ADR-57 slice 3 work-item 2): a destructure of a tuple
123
+ # element that flow typed as optional is almost always guarded by a
124
+ # CORRELATED invariant the flow engine cannot prove. The canonical case
125
+ # is haml's `parse_tag`, which returns `[..., last_line || @line.index
126
+ # + 1]` — a 9-tuple whose `last_line` slot widens to `Dynamic[top]?`
127
+ # through a loop-nested destructure; at the call site `..., last_line =
128
+ # parse_tag(text); raise(..., last_line - 1) if parse && value.empty?`
129
+ # the `last_line` is nil ONLY when an earlier element is too, and the
130
+ # guard short-circuits — but that correlation lives across slots, so
131
+ # per-slot flow sees `last_line` as nil-able and `last_line - 1` fires a
132
+ # spurious `possible nil receiver`. Manufacturing a `T?` for every
133
+ # destructured slot frightens working code; FP discipline (the program
134
+ # works) outranks the worst-case per-slot reading, so we drop the `nil`
135
+ # from a destructured slot and keep the non-`nil` constituent (a bare
136
+ # `nil` slot stays `nil` — there is nothing to soften). A pure non-
137
+ # optional element keeps its precise type unchanged.
138
+ def slot_type(elements, index)
139
+ element = elements[index]
140
+ return Type::Combinator.constant_of(nil) if element.nil?
141
+
142
+ soften_optional_slot(element)
143
+ end
144
+
145
+ def soften_optional_slot(element)
146
+ return element unless element.is_a?(Type::Union)
147
+ return element unless element.members.any? { |m| nil_literal?(m) }
148
+
149
+ non_nil = element.members.reject { |m| nil_literal?(m) }
150
+ return element if non_nil.empty? # a bare `nil` slot: nothing to soften
151
+
152
+ Type::Combinator.union(*non_nil)
153
+ end
154
+
155
+ def nil_literal?(member)
156
+ member.is_a?(Type::Constant) && member.value.nil?
157
+ end
158
+
116
159
  def decompose_default(front_count, back_count, rest_present:)
117
160
  [
118
161
  Array.new(front_count) { Type::Combinator.untyped },
@@ -280,6 +280,148 @@ module Rigor
280
280
  carriers = kinds.map { |name| Type::Combinator.nominal_of(name) }
281
281
  carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
282
282
  end
283
+
284
+ # ----------------------------------------------------------------
285
+ # ADR-56 slice C — receiver-content element-type JOIN.
286
+ #
287
+ # `widen_after_block` above forgets a literal-shape carrier's arity
288
+ # when a captured local is content-mutated inside a block, but it
289
+ # keeps only the SEED's element types — an unsound under-
290
+ # approximation for a non-empty seed (`out = [0]; arr.each { |x|
291
+ # out << x }` types `Array[0]` while the runtime array is
292
+ # `[0, 1, 2, 3]`). Slice C joins the appended/stored element (and
293
+ # key/value) types INTO the continuation collection's parameter, so
294
+ # the result is `Array[0 | Integer]` rather than `Array[0]`.
295
+ #
296
+ # Array content-mutators that append/store ELEMENTS. The appended
297
+ # element type is the call's argument type(s); `[]=`'s value is its
298
+ # LAST argument (the keys precede it). Subset of `ARRAY_MUTATORS`:
299
+ # only the element-INTRODUCING methods (removers / reorderers add no
300
+ # new element evidence and are already covered by the arity-forget).
301
+ ARRAY_CONTENT_ADDERS = %i[
302
+ << push append prepend unshift concat insert []= fill replace
303
+ ].to_set.freeze
304
+
305
+ # Hash content-mutators that store a key→value pair. For `[]=` /
306
+ # `store` the key is the first argument and the value the last.
307
+ HASH_CONTENT_ADDERS = %i[[]= store].to_set.freeze
308
+
309
+ # String content-mutators that append to the buffer. String carries
310
+ # no element parameter, so these contribute nothing to a join — they
311
+ # are listed so the orchestrator recognises them as content mutators
312
+ # (the binding already widens to `String` via normal typing); the
313
+ # join helpers below short-circuit on a non-collection pre-state.
314
+ STRING_CONTENT_ADDERS = %i[<< concat prepend insert replace].to_set.freeze
315
+
316
+ # Every method name that mutates a captured local's CONTENT — the
317
+ # union the orchestrator scans the block body for.
318
+ CONTENT_ADDERS = (ARRAY_CONTENT_ADDERS | HASH_CONTENT_ADDERS | STRING_CONTENT_ADDERS).freeze
319
+
320
+ # The element types a single content-mutator call introduces into an
321
+ # Array, given the per-argument types (already typed in the block
322
+ # body scope). `concat`/`replace` take collection arguments, so their
323
+ # element evidence is the arguments' OWN element types unioned; the
324
+ # rest append the argument values directly. Returns `[]` when no
325
+ # element evidence (e.g. a `<<` with no resolvable arg).
326
+ def array_added_elements(method_name, arg_types)
327
+ return [] if arg_types.empty?
328
+
329
+ case method_name
330
+ when :concat, :replace
331
+ arg_types.flat_map { |t| collection_element_types(t) }
332
+ when :insert
333
+ # `insert(index, *objs)` — first arg is the position.
334
+ arg_types.drop(1)
335
+ when :[]=
336
+ # `arr[i] = v` / `arr[i, n] = v` — value is the last argument.
337
+ [arg_types.last]
338
+ when :fill
339
+ # `fill(value)` — only the no-block single-value form adds a
340
+ # concrete element; block / range forms are conservatively
341
+ # ignored (the arity-forget already widened the binding).
342
+ arg_types.size == 1 ? arg_types : []
343
+ else # << push append prepend unshift
344
+ arg_types
345
+ end
346
+ end
347
+
348
+ # Builds the continuation Array type from the pre-state binding and
349
+ # the appended element types. The floor is `Array[Dynamic[top]]`
350
+ # (the sound empty-seed behaviour) when there is no element evidence
351
+ # at all.
352
+ def join_array_content(pre_state, added_elements)
353
+ seed_elements = collection_element_types(pre_state)
354
+ added = added_elements.compact
355
+ # The empty-seed floor element is `Dynamic[top]` (no element
356
+ # evidence). When real appended evidence exists that floor carries
357
+ # nothing, so drop it — an empty accumulator built by `out << x*2`
358
+ # reads `Array[Integer]`, not `Array[Integer | Dynamic[top]]`.
359
+ seed_elements = drop_dynamic(seed_elements) unless added.empty?
360
+ elements = seed_elements + added
361
+ return Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped]) if elements.empty?
362
+
363
+ Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.union(*elements)])
364
+ end
365
+
366
+ # Builds the continuation Hash type from the pre-state binding and a
367
+ # list of `[key_type, value_type]` pairs stored by `[]=` / `store`.
368
+ def join_hash_content(pre_state, added_pairs)
369
+ seed_keys, seed_values = hash_shape_key_values(pre_state)
370
+ added_keys = added_pairs.map(&:first).compact
371
+ added_values = added_pairs.map(&:last).compact
372
+ seed_keys = drop_dynamic(seed_keys) unless added_keys.empty?
373
+ seed_values = drop_dynamic(seed_values) unless added_values.empty?
374
+ keys = seed_keys + added_keys
375
+ values = seed_values + added_values
376
+ key_t = keys.empty? ? Type::Combinator.untyped : Type::Combinator.union(*keys)
377
+ value_t = values.empty? ? Type::Combinator.untyped : Type::Combinator.union(*values)
378
+ Type::Combinator.nominal_of("Hash", type_args: [key_t, value_t])
379
+ end
380
+
381
+ # Drops `Dynamic` (incl. `untyped`) constituents from a type list.
382
+ def drop_dynamic(types)
383
+ types.grep_v(Type::Dynamic)
384
+ end
385
+
386
+ # Element types carried by a collection binding, regardless of which
387
+ # carrier holds them: a `Tuple` lists them, a `Nominal[Array, [E]]`
388
+ # has one element param, a bare `Array` / anything else yields none.
389
+ def collection_element_types(type)
390
+ case type
391
+ when Type::Tuple
392
+ type.elements
393
+ when Type::Nominal
394
+ type.class_name == "Array" ? type.type_args : []
395
+ when Type::Union
396
+ # A loop's single-pass join can union the widened collection with
397
+ # its un-widened literal seed (`Array[0] | [0]`); pull element
398
+ # evidence from every Array-ish member.
399
+ type.members.flat_map { |m| collection_element_types(m) }
400
+ else
401
+ []
402
+ end
403
+ end
404
+
405
+ # `[keys, values]` evidence from a Hash-ish pre-state binding —
406
+ # a `HashShape` (literal pairs) or a `Nominal[Hash, [K, V]]`.
407
+ def hash_shape_key_values(type)
408
+ case type
409
+ when Type::HashShape
410
+ return [[], []] if type.pairs.empty?
411
+
412
+ [[key_union_for(type.pairs.keys)], type.pairs.values]
413
+ when Type::Nominal
414
+ type.class_name == "Hash" && type.type_args.size == 2 ? [[type.type_args[0]], [type.type_args[1]]] : [[], []]
415
+ when Type::Union
416
+ type.members.each_with_object([[], []]) do |m, (ks, vs)|
417
+ mk, mv = hash_shape_key_values(m)
418
+ ks.concat(mk)
419
+ vs.concat(mv)
420
+ end
421
+ else
422
+ [[], []]
423
+ end
424
+ end
283
425
  end
284
426
  end
285
427
  end