rigortype 0.1.11 → 0.1.12

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  3. data/lib/rigor/analysis/runner.rb +6 -1
  4. data/lib/rigor/analysis/worker_session.rb +6 -1
  5. data/lib/rigor/cli/plugins_command.rb +308 -0
  6. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  7. data/lib/rigor/cli.rb +28 -0
  8. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  9. data/lib/rigor/inference/expression_typer.rb +69 -30
  10. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  11. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  12. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  13. data/lib/rigor/inference/mutation_widening.rb +285 -0
  14. data/lib/rigor/inference/narrowing.rb +72 -4
  15. data/lib/rigor/inference/scope_indexer.rb +409 -12
  16. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  17. data/lib/rigor/scope.rb +181 -4
  18. data/lib/rigor/version.rb +1 -1
  19. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  20. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  21. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  23. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  24. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  25. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  27. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  28. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  29. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  31. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  32. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  33. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  34. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  35. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  36. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  37. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  42. data/sig/rigor/scope.rbs +22 -0
  43. metadata +9 -1
@@ -6,6 +6,7 @@ require_relative "../type"
6
6
  require_relative "../ast"
7
7
  require_relative "block_parameter_binder"
8
8
  require_relative "fallback"
9
+ require_relative "indexed_narrowing"
9
10
  require_relative "macro_block_self_type"
10
11
  require_relative "method_dispatcher"
11
12
  require_relative "narrowing"
@@ -1133,6 +1134,69 @@ module Rigor
1133
1134
  type_of(body.last)
1134
1135
  end
1135
1136
 
1137
+ # Indexed-collection narrowing — `receiver[key]` after a
1138
+ # prior `receiver[key] ||= default` reads the post-`||=`
1139
+ # type when the receiver and key are stable enough to
1140
+ # address. Sits ahead of `MethodDispatcher.dispatch` so
1141
+ # the standard `Hash#[]` / `Array#[]` answer (which would
1142
+ # fold to `Constant[nil]` for an empty `HashShape{}` or
1143
+ # `Tuple[]`) does not override the narrowing. See
1144
+ # {Inference::IndexedNarrowing}.
1145
+ def indexed_narrowing_for(node)
1146
+ IndexedNarrowing.lookup_for_call(node, scope) || method_chain_narrowing_for(node)
1147
+ end
1148
+
1149
+ # Stable single-hop chain narrowing — `receiver.method`
1150
+ # after an `is_a?` / `kind_of?` / `instance_of?` predicate
1151
+ # established the narrowing on the dominated edge. The
1152
+ # call MUST be no-arg + no-block + rooted at a local-var /
1153
+ # ivar read; everything else falls through to the
1154
+ # standard dispatcher. ROADMAP § Future cycles —
1155
+ # "Method-call receiver narrowing across stable
1156
+ # receivers" — Law-of-Demeter-justified single-hop scope.
1157
+ def method_chain_narrowing_for(node)
1158
+ return nil unless node.is_a?(Prism::CallNode)
1159
+ return nil unless node.block.nil?
1160
+ return nil unless node.arguments.nil? || node.arguments.arguments.empty?
1161
+
1162
+ case node.receiver
1163
+ when Prism::LocalVariableReadNode
1164
+ scope.method_chain_narrowing(:local, node.receiver.name, node.name)
1165
+ when Prism::InstanceVariableReadNode
1166
+ scope.method_chain_narrowing(:ivar, node.receiver.name, node.name)
1167
+ end
1168
+ end
1169
+
1170
+ # v0.0.3 A — implicit-self calls prefer a same-named
1171
+ # top-level `def` over RBS dispatch. Without this,
1172
+ # a helper like `def select(...)` defined inside an
1173
+ # `RSpec.describe ... do ... end` block mis-routes
1174
+ # through `Enumerable#select` / `Object#select` and
1175
+ # the caller observes `Array[Elem]` instead of the
1176
+ # helper's actual return type. The check fires only
1177
+ # for `node.receiver.nil?` (true implicit self), so
1178
+ # explicit-receiver dispatch is unaffected.
1179
+ def try_local_def_dispatch(node, receiver, arg_types)
1180
+ local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1181
+ return nil unless local_def
1182
+
1183
+ local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1184
+ return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1185
+
1186
+ # The local def matches by name but the inference was
1187
+ # disqualified — either the parameter shape is too complex
1188
+ # for the first-iteration binder (kwargs / optionals /
1189
+ # rest), or ADR-24 slice 1's conservative gate declined
1190
+ # the resolved return type inside a class body (see
1191
+ # `adoptable_self_call_result?`). `Dynamic[Top]` is the
1192
+ # safest answer: RBS dispatch would be wrong (the method
1193
+ # is user-defined and shadows whatever ancestor method the
1194
+ # dispatch would find), and `Dynamic[Top]` propagates
1195
+ # correctly through downstream call chains without
1196
+ # surfacing misleading false-positive diagnostics.
1197
+ dynamic_top
1198
+ end
1199
+
1136
1200
  # Slice 2 routes call expressions through `MethodDispatcher`. The
1137
1201
  # receiver and every argument are typed first, then the dispatcher is
1138
1202
  # asked for a result type. A nil result triggers the fail-soft fallback
@@ -1140,40 +1204,15 @@ module Rigor
1140
1204
  # their own fallbacks for unrecognised receivers/args, so the tracer
1141
1205
  # captures both the immediate dispatch miss and the deeper cause).
1142
1206
  def call_type_for(node)
1207
+ narrowed = indexed_narrowing_for(node)
1208
+ return narrowed if narrowed
1209
+
1143
1210
  receiver = call_receiver_type_for(node)
1144
1211
  arg_types = call_arg_types(node)
1145
1212
  block_type = block_return_type_for(node, receiver, arg_types)
1146
1213
 
1147
- # v0.0.3 A implicit-self calls prefer a same-named
1148
- # top-level `def` over RBS dispatch. Without this,
1149
- # a helper like `def select(...)` defined inside an
1150
- # `RSpec.describe ... do ... end` block mis-routes
1151
- # through `Enumerable#select` / `Object#select` and
1152
- # the caller observes `Array[Elem]` instead of the
1153
- # helper's actual return type. The check fires only
1154
- # for `node.receiver.nil?` (true implicit self), so
1155
- # explicit-receiver dispatch is unaffected.
1156
- local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1157
- if local_def
1158
- local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1159
- return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1160
-
1161
- # The local def matches by name but the inference
1162
- # was disqualified — either the parameter shape is
1163
- # too complex for the first-iteration binder
1164
- # (kwargs / optionals / rest), or ADR-24 slice 1's
1165
- # conservative gate declined the resolved return
1166
- # type inside a class body (see
1167
- # `adoptable_self_call_result?`).
1168
- # Returning `Dynamic[Top]` is the safest answer:
1169
- # we know RBS dispatch would be wrong (the
1170
- # method is user-defined and shadows whatever
1171
- # ancestor method the dispatch would find), and
1172
- # `Dynamic[Top]` propagates correctly through
1173
- # downstream call chains without surfacing
1174
- # misleading false-positive diagnostics.
1175
- return dynamic_top
1176
- end
1214
+ local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1215
+ return local_def_result if local_def_result
1177
1216
 
1178
1217
  # v0.0.6 phase 2 — per-element block fold for Tuple
1179
1218
  # receivers. When `[a, b, c].map { |x| f(x) }` and the
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "mutation_widening"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # Closes the "`params[:f] ||= []; params[:f] << x`" precision
11
+ # gap surfaced by the Redmine 6.1.2 `Query#as_params` survey
12
+ # (ROADMAP § Future cycles / Type-language / engine —
13
+ # "Indexed-collection narrowing through `Hash[k] ||= default`").
14
+ #
15
+ # After `receiver[key] ||= default` the next read at
16
+ # `receiver[key]` is known non-nil, but Rigor types each
17
+ # `Hash#[]` independently and the subsequent `<<` / `[]=` /
18
+ # other mutator dispatches against the un-narrowed result —
19
+ # which on a `HashShape{}` carrier folds to `Constant[nil]`.
20
+ #
21
+ # This module is the address-recogniser + invalidator shared
22
+ # by {Inference::StatementEvaluator}'s `eval_index_or_write`
23
+ # handler (which RECORDS the narrowing) and `eval_call`
24
+ # (which INVALIDATES on intervening writes / mutators) and
25
+ # by {Inference::ExpressionTyper}'s `call_type_for` (which
26
+ # CONSUMES the narrowing when typing a follow-up `[]` read).
27
+ #
28
+ # **Stable receivers.** A receiver is "stable" iff it is a
29
+ # `LocalVariableReadNode` or `InstanceVariableReadNode`.
30
+ # Method-call chains (`foo.bar[:k]`) and other shapes are
31
+ # rejected because a follow-up read against an identical-
32
+ # looking AST chain has no guarantee of resolving to the
33
+ # same runtime value — narrowing it would invent a fact.
34
+ #
35
+ # **Stable keys.** A key is "stable" iff it is a literal
36
+ # `SymbolNode` / `StringNode` / `IntegerNode`. Local-variable
37
+ # keys (`params[field]`) are excluded for the same
38
+ # invent-a-fact reason: the local could be rebound between
39
+ # the `||=` and the read.
40
+ #
41
+ # **Invalidation.** Three conditions drop a recorded
42
+ # narrowing:
43
+ # - The receiver variable is rebound (handled inside
44
+ # `Scope#with_local` / `Scope#with_ivar`).
45
+ # - An intervening `receiver[key] = value` writes the same
46
+ # slot — `:[]=` could rebind the slot to nil; conservative
47
+ # drop.
48
+ # - An intervening mutator from {MutationWidening::HASH_MUTATORS}
49
+ # or {MutationWidening::ARRAY_MUTATORS} runs against the
50
+ # receiver (e.g. `params.delete(:f)`, `params.clear`).
51
+ #
52
+ # All three are implemented in `StatementEvaluator#eval_call`'s
53
+ # post-dispatch path through {.invalidate_after_call}.
54
+ module IndexedNarrowing
55
+ # Literal Prism nodes whose Ruby value the analyzer trusts
56
+ # as a stable address. Symbol / String are the dominant
57
+ # Hash key shapes; Integer covers numerically-keyed Hashes
58
+ # and Array indices.
59
+ STABLE_KEY_NODES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
60
+
61
+ module_function
62
+
63
+ # Returns `[receiver_kind, receiver_name]` when `node` is a
64
+ # `LocalVariableReadNode` or `InstanceVariableReadNode`,
65
+ # otherwise nil.
66
+ def stable_receiver(node)
67
+ case node
68
+ when Prism::LocalVariableReadNode then [:local, node.name]
69
+ when Prism::InstanceVariableReadNode then [:ivar, node.name]
70
+ end
71
+ end
72
+
73
+ # Returns the literal Ruby value when `node` is a stable
74
+ # key shape, otherwise nil. Symbols → `Symbol`,
75
+ # Strings → `String` (unescaped), Integers → `Integer`.
76
+ def stable_key(node)
77
+ case node
78
+ when Prism::SymbolNode then node.unescaped.to_sym
79
+ when Prism::StringNode then node.unescaped
80
+ when Prism::IntegerNode then node.value
81
+ end
82
+ end
83
+
84
+ # Returns `[receiver_kind, receiver_name, key]` when the
85
+ # CallNode is a `receiver[key]` read or write whose
86
+ # receiver and key are both stable, otherwise nil. Used
87
+ # by both the recorder (for `IndexOrWriteNode`'s
88
+ # receiver/arguments triplet) and the invalidator (for
89
+ # `CallNode :[]=` / mutator calls). Treats only the FIRST
90
+ # argument as the key; `:[]=`'s second argument is the
91
+ # rvalue and is not part of the address.
92
+ def stable_address(receiver_node, key_node)
93
+ receiver = stable_receiver(receiver_node)
94
+ return nil if receiver.nil?
95
+
96
+ key = stable_key(key_node)
97
+ return nil if key.nil?
98
+
99
+ [receiver.first, receiver.last, key]
100
+ end
101
+
102
+ # Looks up a recorded narrowing for `receiver[key]` against
103
+ # `scope`, returning the narrowed type or nil when no
104
+ # entry applies. Used by ExpressionTyper's `[]` dispatch
105
+ # to refine the result of a stable indexed read.
106
+ def lookup_for_call(node, scope)
107
+ return nil unless node.is_a?(Prism::CallNode)
108
+ return nil unless node.name == :[]
109
+ return nil if node.arguments.nil?
110
+ return nil unless node.arguments.arguments.size == 1
111
+
112
+ address = stable_address(node.receiver, node.arguments.arguments.first)
113
+ return nil if address.nil?
114
+
115
+ scope.indexed_narrowing(*address)
116
+ end
117
+
118
+ # Removes recorded narrowings invalidated by `call_node`.
119
+ # Two patterns:
120
+ #
121
+ # - `receiver[key] = value` (a `:[]=` against a stable
122
+ # address): drop the specific `(receiver, key)` entry.
123
+ # - Any mutator from `HASH_MUTATORS` / `ARRAY_MUTATORS`
124
+ # against a stable receiver: drop EVERY entry rooted
125
+ # at that receiver, because the mutator could rebind
126
+ # any slot.
127
+ #
128
+ # Returns the updated scope. Always-safe (only forgets;
129
+ # never invents).
130
+ def invalidate_after_call(call_node:, current_scope:)
131
+ return current_scope unless call_node.is_a?(Prism::CallNode)
132
+
133
+ if call_node.name == :[]=
134
+ invalidate_indexed_write(call_node, current_scope)
135
+ elsif mutator?(call_node.name)
136
+ invalidate_mutator(call_node, current_scope)
137
+ else
138
+ current_scope
139
+ end
140
+ end
141
+
142
+ def mutator?(method_name)
143
+ MutationWidening::HASH_MUTATORS.include?(method_name) ||
144
+ MutationWidening::ARRAY_MUTATORS.include?(method_name)
145
+ end
146
+
147
+ def invalidate_indexed_write(call_node, current_scope)
148
+ args = call_node.arguments&.arguments
149
+ return current_scope if args.nil? || args.empty?
150
+
151
+ address = stable_address(call_node.receiver, args.first)
152
+ return current_scope if address.nil?
153
+
154
+ current_scope.without_indexed_narrowing(*address)
155
+ end
156
+
157
+ def invalidate_mutator(call_node, current_scope)
158
+ receiver = stable_receiver(call_node.receiver)
159
+ return current_scope if receiver.nil?
160
+
161
+ current_scope.without_indexed_narrowings_for(*receiver)
162
+ end
163
+
164
+ # Companion invalidator for single-hop method-chain
165
+ # narrowings (ROADMAP § Future cycles — "Method-call
166
+ # receiver narrowing across stable receivers", B2 from
167
+ # the slice's design notes). Drops every
168
+ # `(receiver, *)` chain narrowing rooted at the call's
169
+ # OUTER stable receiver — matching the ROADMAP's "any
170
+ # intervening method call against the same receiver"
171
+ # criterion. A call against `x.last` (the OUTER receiver
172
+ # is a `CallNode`, not a stable root) does NOT drop
173
+ # narrowings keyed on `x`, so the worked-site
174
+ # `x.last << y` pattern correctly preserves the chain
175
+ # narrowing for any further `x.last` read in the same
176
+ # body. Always-safe (only forgets; never invents).
177
+ def invalidate_chain_after_call(call_node:, current_scope:)
178
+ return current_scope unless call_node.is_a?(Prism::CallNode)
179
+
180
+ receiver = stable_receiver(call_node.receiver)
181
+ return current_scope if receiver.nil?
182
+
183
+ current_scope.without_method_chain_narrowings_for(*receiver)
184
+ end
185
+ end
186
+ end
187
+ end
@@ -42,9 +42,33 @@ module Rigor
42
42
  when :inject, :reduce then inject_block_params(receiver, args)
43
43
  when :group_by, :partition then single_element_block_params(receiver)
44
44
  when :each_slice, :each_cons then slice_block_params(receiver)
45
+ when :new then class_new_block_params(receiver, args)
45
46
  end
46
47
  end
47
48
 
49
+ # `Class.new { |c| … }` and `Class.new(Parent) { |c| … }`
50
+ # — the block parameter is the freshly-created anonymous
51
+ # class, statically representable as the parent's singleton
52
+ # type (the new class inherits every singleton method the
53
+ # parent exposes, which is what callers use this form to
54
+ # configure: `c.table_name = …`, `c.attribute :foo`, etc.).
55
+ # No parent → `singleton(Object)`. RBS would otherwise widen
56
+ # the block param to bare `Nominal[Class]`, dropping access
57
+ # to the parent's class-side surface.
58
+ def class_new_block_params(receiver, args)
59
+ return nil unless class_metaclass_receiver?(receiver)
60
+
61
+ parent = args.first
62
+ return [Type::Combinator.singleton_of("Object")] if parent.nil?
63
+ return [parent] if parent.is_a?(Type::Singleton)
64
+
65
+ nil
66
+ end
67
+
68
+ def class_metaclass_receiver?(type)
69
+ type.is_a?(Type::Singleton) && type.class_name == "Class"
70
+ end
71
+
48
72
  def times_block_params(receiver)
49
73
  return nil unless integer_rooted?(receiver)
50
74
 
@@ -805,9 +805,32 @@ module Rigor
805
805
  date_lift = date_new_lift(receiver_type.class_name, arg_types)
806
806
  return date_lift if date_lift
807
807
 
808
+ class_new_lift = class_new_lift(receiver_type.class_name, arg_types)
809
+ return class_new_lift if class_new_lift
810
+
808
811
  Type::Combinator.nominal_of(receiver_type.class_name)
809
812
  end
810
813
 
814
+ # `Class.new` and `Class.new(Parent)` create a brand-new
815
+ # anonymous class. Statically that class is representable as
816
+ # the parent's singleton type — its singleton-method surface
817
+ # is the parent's (plus whatever the block defines, which we
818
+ # do not statically track here), so `Singleton[Parent]` lets
819
+ # downstream `klass.some_class_method` resolve. No parent →
820
+ # `singleton(Object)`. Anything else (dynamic parent, more
821
+ # than one positional, …) falls back to `Nominal[Class]` via
822
+ # the surrounding `meta_new` tail.
823
+ def class_new_lift(class_name, arg_types)
824
+ return nil unless class_name == "Class"
825
+ return Type::Combinator.singleton_of("Object") if arg_types.empty?
826
+ return nil unless arg_types.size == 1
827
+
828
+ parent = arg_types.first
829
+ return parent if parent.is_a?(Type::Singleton)
830
+
831
+ nil
832
+ end
833
+
811
834
  # ADR-15 Phase 4b.x — `Ractor.make_shareable` on both the
812
835
  # outer Hash and each lambda value. A plain `.freeze` leaves
813
836
  # the Procs unshareable; reading `CONSTANT_CONSTRUCTORS[class]`
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ # Widens a local- or instance-variable binding after a call
8
+ # whose receiver is that variable AND whose method is a known
9
+ # in-place mutator.
10
+ #
11
+ # Closes the **G1 / G2** flow-folding gaps documented at
12
+ # `docs/notes/20260521-mastodon-cluster4-flow-folding-triage.md`
13
+ # and queued in [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
14
+ # § "Flow-folding". The user-visible symptom they shared was a
15
+ # spurious `flow.always-truthy-condition` on a `arr.size == N`
16
+ # / `arr.empty?` / `@arr.empty?` check that follows a loop
17
+ # body or sibling method that mutates `arr` / `@arr` in place.
18
+ #
19
+ # **The mechanism.** When source like
20
+ #
21
+ # arms = [first] # arms : Tuple[T] (size=1)
22
+ # while peek_pipe?
23
+ # arms << next_arm # mutator call on a local
24
+ # end
25
+ # return arms.first if arms.size == 1
26
+ #
27
+ # runs through inference today, the literal `[first]` writes
28
+ # `arms` as `Tuple[T]`. The shape carrier's `size` folds to
29
+ # `Constant[1]`. The body's `arms << next_arm` returns a type
30
+ # for the call expression but does NOT rebind `arms`, so after
31
+ # the loop `arms` still carries the `Tuple[T]` binding —
32
+ # `arms.size == 1` constant-folds to `true` and the user sees
33
+ # a false `flow.always-truthy-condition`.
34
+ #
35
+ # The narrowest correct fix is to **widen the receiver binding
36
+ # at the mutator call site**: replace `arms`'s binding with
37
+ # `Nominal[Array, [union(elements)]]` so the carrier no longer
38
+ # carries the literal arity. Inside a loop body, the post-call
39
+ # body scope then joins with the pre-loop scope through
40
+ # `join_with_nil_injection` → `Scope#join` (which unions per
41
+ # name); the resulting union loses size precision, so the
42
+ # `arms.size` fold returns `Integer` (not `Constant[1]`) and
43
+ # the diagnostic correctly stays silent.
44
+ #
45
+ # The widening is **always type-safe**: it never introduces a
46
+ # new fact, only forgets a literal-shape fact that is no longer
47
+ # justified once mutation occurred. It costs only the precise
48
+ # arity / pair-set the shape carrier was tracking; the underlying
49
+ # nominal stays exact (`Array` / `Hash`) and element types
50
+ # stay as a union of what was there.
51
+ #
52
+ # **Scope.** This slice addresses:
53
+ #
54
+ # - `arr.<mutator>(...)` where `arr` is a local variable.
55
+ # - `@arr.<mutator>(...)` where `@arr` is an instance variable.
56
+ #
57
+ # Out of scope (left for a separate cycle):
58
+ #
59
+ # - **`retry` flow edge** (e.g. `tries += 1; retry`). The
60
+ # `tries` rebind across `retry` is a flow-edge issue, not a
61
+ # call-site mutation issue.
62
+ # - **Intervening method call invalidates the ivar binding**
63
+ # (e.g. `if @performed; perform!; if @performed`). The
64
+ # intra-procedural call effect on ivars is a separate
65
+ # mutation-effect feature.
66
+ # - **Read-before-write nil** (e.g. `unless @warning_issued;
67
+ # ...; @warning_issued = true`). Requires tracking the
68
+ # first-write position; flow-sensitive but orthogonal.
69
+ # - **Local-variable mutation inside a block body** (e.g.
70
+ # `arr = []; xs.each { |x| arr << x }`). Block bodies
71
+ # create a child scope; the existing closure-escape model
72
+ # only widens outer locals when the block ESCAPES the
73
+ # call. An in-place mutator inside a non-escaping block on
74
+ # an outer LOCAL does not yet flow back. **Ivar mutations
75
+ # inside a block ARE handled** (ivars live in the
76
+ # method-body scope, not the block-local scope) — the
77
+ # widening fires from inside the block and the new ivar
78
+ # binding is visible to the outer scope.
79
+ #
80
+ # Those four are documented as "G2 remaining" in
81
+ # `docs/CURRENT_WORK.md` and are intentionally deferred.
82
+ module MutationWidening
83
+ # Array mutators that change either the size or the element
84
+ # set of a literal-shape carrier (Tuple). Receiver-mutating
85
+ # methods only — non-mutating siblings (`map` vs `map!`,
86
+ # `select` vs `select!`) stay precise.
87
+ #
88
+ # `<<` and `[]=` are the dominant survey cases; the bang
89
+ # variants and the size-mutators cover the rest of the
90
+ # Mastodon cluster-4 G1 catalogue.
91
+ ARRAY_MUTATORS = %i[
92
+ << push append prepend unshift concat insert
93
+ pop shift
94
+ delete delete_at delete_if reject!
95
+ clear compact!
96
+ replace fill []=
97
+ map! collect! select! filter! keep_if uniq!
98
+ flatten! sort! sort_by! reverse! rotate! shuffle! slice!
99
+ ].to_set.freeze
100
+
101
+ # Hash mutators that invalidate a `HashShape` carrier. Same
102
+ # principle as `ARRAY_MUTATORS`: only the receiver-mutating
103
+ # methods are listed.
104
+ HASH_MUTATORS = %i[
105
+ []= store
106
+ delete delete_if reject! select! filter! keep_if
107
+ clear compact! merge! update transform_keys! transform_values!
108
+ replace
109
+ ].to_set.freeze
110
+
111
+ module_function
112
+
113
+ # Returns a scope with the call's receiver widened, when the
114
+ # receiver is a local-/instance-variable read whose current
115
+ # binding is a literal-shape carrier (`Tuple` / `HashShape`)
116
+ # AND the call name is a known in-place mutator for that
117
+ # shape. Returns `current_scope` unchanged otherwise.
118
+ #
119
+ # @param call_node [Prism::CallNode]
120
+ # @param current_scope [Rigor::Scope]
121
+ # @return [Rigor::Scope]
122
+ def widen_after_call(call_node:, current_scope:)
123
+ receiver = call_node.receiver
124
+ return current_scope if receiver.nil?
125
+
126
+ case receiver
127
+ when Prism::LocalVariableReadNode
128
+ widen_local(call_node.name, receiver.name, current_scope)
129
+ when Prism::InstanceVariableReadNode
130
+ widen_ivar(call_node.name, receiver.name, current_scope)
131
+ else
132
+ current_scope
133
+ end
134
+ end
135
+
136
+ # Propagate block-body mutations of outer-scope variables
137
+ # back into `outer_scope`. Block bodies live in a child
138
+ # scope; mutations the block body performs on captured
139
+ # outer LOCALS are otherwise invisible to the post-call
140
+ # outer scope (ivars are handled correctly already because
141
+ # they live in the method-body scope, not the block-local
142
+ # scope).
143
+ #
144
+ # Walks the block AST for `<receiver>.<method>(...)` calls
145
+ # whose receiver is either a `LocalVariableReadNode` with
146
+ # `depth > 0` (a captured outer local — Prism's `depth`
147
+ # counts scope hops outward; `depth == 0` means a
148
+ # block-local) or an `InstanceVariableReadNode` (always
149
+ # method-scope), and applies `widen_after_call` for each
150
+ # one against the outer scope. The widening is always safe
151
+ # — it can only LOSE precision — so blindly propagating is
152
+ # sound regardless of whether the block actually runs.
153
+ #
154
+ # Recurses into nested expression nodes so chained / nested
155
+ # forms (`arr << f(x); arr << g(y)`, `arr.push(x) if cond`)
156
+ # are all caught. Does NOT recurse into nested
157
+ # `Prism::BlockNode`s — each block is processed by its own
158
+ # `eval_call`.
159
+ def widen_after_block(call_node:, outer_scope:)
160
+ block = call_node.block
161
+ return outer_scope unless block.is_a?(Prism::BlockNode)
162
+
163
+ body = block.body
164
+ return outer_scope if body.nil?
165
+
166
+ walk_for_outer_mutations(body, outer_scope)
167
+ end
168
+
169
+ def walk_for_outer_mutations(node, scope)
170
+ return scope if node.nil?
171
+
172
+ scope = widen_for_outer_receiver(node, scope) if node.is_a?(Prism::CallNode)
173
+
174
+ # Descend into every child, including nested blocks. The
175
+ # `LocalVariableReadNode#depth` check inside
176
+ # `widen_for_outer_receiver` keeps nested-block-locals
177
+ # from being widened in the outer scope — only references
178
+ # with `depth >= 1` (true captures of the outer scope's
179
+ # locals) trigger widening, so descending into nested
180
+ # blocks is safe and necessary for the hkt_registry-shape
181
+ # case (an outer collection mutated inside an iterator
182
+ # block whose body is itself inside another block).
183
+ node.compact_child_nodes.each do |child|
184
+ scope = walk_for_outer_mutations(child, scope)
185
+ end
186
+ scope
187
+ end
188
+
189
+ def widen_for_outer_receiver(call_node, scope)
190
+ receiver = call_node.receiver
191
+ return scope if receiver.nil?
192
+
193
+ case receiver
194
+ when Prism::LocalVariableReadNode
195
+ return scope if receiver.depth.zero?
196
+
197
+ widen_local(call_node.name, receiver.name, scope)
198
+ when Prism::InstanceVariableReadNode
199
+ widen_ivar(call_node.name, receiver.name, scope)
200
+ else
201
+ scope
202
+ end
203
+ end
204
+
205
+ def widen_local(method_name, var_name, current_scope)
206
+ current = current_scope.local(var_name)
207
+ widened = widen_for_mutator(current, method_name)
208
+ return current_scope if widened.nil?
209
+
210
+ current_scope.with_local(var_name, widened)
211
+ end
212
+
213
+ def widen_ivar(method_name, var_name, current_scope)
214
+ current = current_scope.ivar(var_name)
215
+ widened = widen_for_mutator(current, method_name)
216
+ return current_scope if widened.nil?
217
+
218
+ current_scope.with_ivar(var_name, widened)
219
+ end
220
+
221
+ # Returns the widened type for a binding whose receiver is
222
+ # about to be mutated by `method_name`, or `nil` when no
223
+ # widening applies (binding is not a literal-shape carrier,
224
+ # OR the method is not a mutator for that shape, OR the
225
+ # binding is already a nominal — no precision to lose).
226
+ def widen_for_mutator(type, method_name)
227
+ return nil if type.nil?
228
+
229
+ case type
230
+ when Type::Tuple
231
+ return nil unless ARRAY_MUTATORS.include?(method_name)
232
+
233
+ widen_tuple(type)
234
+ when Type::HashShape
235
+ return nil unless HASH_MUTATORS.include?(method_name)
236
+
237
+ widen_hash_shape(type)
238
+ end
239
+ end
240
+
241
+ # `Tuple[A, B, C]` → `Nominal[Array, [union(A, B, C)]]`.
242
+ # An empty tuple has no element evidence, so the widened
243
+ # form carries `untyped` element bound — matches the
244
+ # `tuple_to_array` widening already used by `BlockFolding`.
245
+ def widen_tuple(tuple)
246
+ element_type =
247
+ if tuple.elements.empty?
248
+ Type::Combinator.untyped
249
+ elsif tuple.elements.size == 1
250
+ tuple.elements.first
251
+ else
252
+ Type::Combinator.union(*tuple.elements)
253
+ end
254
+ Type::Combinator.nominal_of("Array", type_args: [element_type])
255
+ end
256
+
257
+ # `HashShape` (closed or open) → `Nominal[Hash, [Kunion,
258
+ # Vunion]]`. Empty / extra-keys-only shapes degrade to a
259
+ # fully-untyped Hash.
260
+ def widen_hash_shape(shape)
261
+ if shape.pairs.empty?
262
+ return Type::Combinator.nominal_of("Hash",
263
+ type_args: [Type::Combinator.untyped,
264
+ Type::Combinator.untyped])
265
+ end
266
+
267
+ key_type = key_union_for(shape.pairs.keys)
268
+ value_type = Type::Combinator.union(*shape.pairs.values)
269
+ Type::Combinator.nominal_of("Hash", type_args: [key_type, value_type])
270
+ end
271
+
272
+ # Maps the literal Ruby key set (`Symbol` / `String`) to a
273
+ # union of the corresponding type carriers. We deliberately
274
+ # do NOT fold to a `Constant<:k1> | Constant<:k2>` union —
275
+ # that would be a precision improvement that complicates the
276
+ # widening contract; the goal here is to LOSE precision, not
277
+ # to record a new fact set.
278
+ def key_union_for(keys)
279
+ kinds = keys.map { |k| k.is_a?(Symbol) ? "Symbol" : "String" }.uniq
280
+ carriers = kinds.map { |name| Type::Combinator.nominal_of(name) }
281
+ carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
282
+ end
283
+ end
284
+ end
285
+ end