rigortype 0.1.10 → 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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  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/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -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