rigortype 0.1.11 → 0.1.13

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules.rb +96 -3
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/plugins_command.rb +308 -0
  7. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  8. data/lib/rigor/cli/skill_command.rb +170 -0
  9. data/lib/rigor/cli.rb +37 -1
  10. data/lib/rigor/configuration/severity_profile.rb +3 -0
  11. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  12. data/lib/rigor/inference/expression_typer.rb +69 -30
  13. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  14. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  15. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  16. data/lib/rigor/inference/mutation_widening.rb +285 -0
  17. data/lib/rigor/inference/narrowing.rb +72 -4
  18. data/lib/rigor/inference/scope_indexer.rb +409 -12
  19. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  20. data/lib/rigor/scope.rb +195 -4
  21. data/lib/rigor/version.rb +1 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  23. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  24. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  25. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  27. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  28. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  29. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  34. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  35. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  36. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  37. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  42. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  43. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  44. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  45. data/sig/rigor/scope.rbs +23 -0
  46. data/skills/rigor-baseline-reduce/SKILL.md +100 -0
  47. data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
  48. data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
  49. data/skills/rigor-plugin-author/SKILL.md +95 -0
  50. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
  51. data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
  52. data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
  53. data/skills/rigor-project-init/SKILL.md +129 -0
  54. data/skills/rigor-project-init/references/01-detect.md +101 -0
  55. data/skills/rigor-project-init/references/02-configure.md +185 -0
  56. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
  57. data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
  58. metadata +22 -1
@@ -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
@@ -1371,16 +1371,12 @@ module Rigor
1371
1371
  # can resolve to a qualified class name. Anything else falls
1372
1372
  # through to "no narrowing".
1373
1373
  def analyse_class_predicate(node, scope, exact:)
1374
- return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
1375
1374
  return nil if node.arguments.nil?
1376
1375
  return nil unless node.arguments.arguments.size == 1
1377
1376
 
1378
1377
  bare_name = static_class_name(node.arguments.arguments.first)
1379
1378
  return nil if bare_name.nil?
1380
1379
 
1381
- current = scope.local(node.receiver.name)
1382
- return nil if current.nil?
1383
-
1384
1380
  # Resolve `bare_name` through the lexical-scope chain
1385
1381
  # so a name shadowed by the current class / enclosing
1386
1382
  # module wins over the top-level constant. Mirrors
@@ -1392,9 +1388,81 @@ module Rigor
1392
1388
  # surface as a spurious `undefined-method` on
1393
1389
  # subsequent `other.class_name` calls).
1394
1390
  class_name = resolve_class_name_lexically(bare_name, scope)
1391
+
1392
+ case node.receiver
1393
+ when Prism::LocalVariableReadNode
1394
+ analyse_class_predicate_on_local(node, scope, class_name, exact)
1395
+ when Prism::CallNode
1396
+ analyse_class_predicate_on_chain(node, scope, class_name, exact)
1397
+ end
1398
+ end
1399
+
1400
+ def analyse_class_predicate_on_local(node, scope, class_name, exact)
1401
+ current = scope.local(node.receiver.name)
1402
+ return nil if current.nil?
1403
+
1395
1404
  class_predicate_scopes(scope, node.receiver.name, current, class_name, exact: exact)
1396
1405
  end
1397
1406
 
1407
+ # Stable single-hop method-chain narrowing (ROADMAP §
1408
+ # Future cycles — "Method-call receiver narrowing across
1409
+ # stable receivers"). When the predicate's receiver is
1410
+ # `<local/ivar>.<method>` with no args and no block,
1411
+ # record the truthy / falsey narrowing in
1412
+ # `Scope#method_chain_narrowings` keyed on the chain
1413
+ # address. The dominated body's identical chain reads
1414
+ # then observe the narrowed type through
1415
+ # `ExpressionTyper#call_type_for`'s lookup.
1416
+ #
1417
+ # Heuristic-by-design (ROADMAP § "Soundness gap"): a
1418
+ # second call to `x.last` could in principle return a
1419
+ # different value than the first. The chain is dropped
1420
+ # on (1) receiver variable rebind (handled inside
1421
+ # `Scope#with_local` / `#with_ivar`), and (2) any
1422
+ # intervening call against the same root receiver
1423
+ # (handled by `StatementEvaluator#eval_call`'s
1424
+ # invalidation step). The Law of Demeter justifies the
1425
+ # single-hop restriction: a single-hop chain is the
1426
+ # idiomatic Ruby shape where re-evaluation soundness is
1427
+ # the strongest.
1428
+ def analyse_class_predicate_on_chain(node, scope, class_name, exact)
1429
+ address = stable_chain_address(node.receiver)
1430
+ return nil if address.nil?
1431
+
1432
+ current = scope.type_of(node.receiver)
1433
+ return nil if current.nil?
1434
+
1435
+ truthy_type = narrow_class(current, class_name, exact: exact, environment: scope.environment)
1436
+ falsey_type = narrow_not_class(current, class_name, exact: exact, environment: scope.environment)
1437
+
1438
+ [
1439
+ scope.with_method_chain_narrowing(*address, truthy_type),
1440
+ scope.with_method_chain_narrowing(*address, falsey_type)
1441
+ ]
1442
+ end
1443
+
1444
+ # Returns `[receiver_kind, receiver_name, method_name]`
1445
+ # iff `chain_call` is a stable single-hop chain whose
1446
+ # root is a local / ivar read and whose own call shape
1447
+ # has no positional arguments and no block. Other shapes
1448
+ # (multi-hop, args, block, method-defined-on-arbitrary-
1449
+ # receiver) lose stability for one of the reasons
1450
+ # enumerated in the slice's design notes.
1451
+ def stable_chain_address(chain_call)
1452
+ return nil unless chain_call.is_a?(Prism::CallNode)
1453
+ return nil unless chain_call.block.nil?
1454
+
1455
+ args = chain_call.arguments
1456
+ return nil unless args.nil? || args.arguments.empty?
1457
+
1458
+ case chain_call.receiver
1459
+ when Prism::LocalVariableReadNode
1460
+ [:local, chain_call.receiver.name, chain_call.name]
1461
+ when Prism::InstanceVariableReadNode
1462
+ [:ivar, chain_call.receiver.name, chain_call.name]
1463
+ end
1464
+ end
1465
+
1398
1466
  # Walks the lexical-nesting chain derived from
1399
1467
  # `scope.self_type` and returns the first
1400
1468
  # `<prefix>::<bare_name>` (or bare `<bare_name>` at the