rigortype 0.0.1

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,1008 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "../environment"
7
+ require_relative "../rbs_extended"
8
+ require_relative "../analysis/fact_store"
9
+
10
+ module Rigor
11
+ module Inference
12
+ # Slice 6 phase 1 minimal narrowing surface.
13
+ #
14
+ # `Rigor::Inference::Narrowing` answers two related questions:
15
+ #
16
+ # 1. Type-level narrowing: given a `Rigor::Type` value, what is its
17
+ # truthy fragment, its falsey fragment, its nil fragment, and its
18
+ # non-nil fragment? These primitives understand the value-lattice
19
+ # algebra (`Constant`, `Nominal`, `Singleton`, `Tuple`, `HashShape`,
20
+ # `Union`) and stay conservative on `Top` and `Dynamic[T]`, where
21
+ # the analyzer cannot prove the boundary either way.
22
+ # 2. Predicate-level narrowing: given a Prism predicate node and an
23
+ # entry scope, what are the truthy-edge scope and the falsey-edge
24
+ # scope after the predicate has been evaluated? The phase 1
25
+ # catalogue covers truthiness on `LocalVariableReadNode`, `nil?`
26
+ # against a local, the unary `!` inverter, parenthesised
27
+ # predicates, and short-circuiting `&&` / `||` chains.
28
+ #
29
+ # Predicate-level narrowing is consumed by
30
+ # `Rigor::Inference::StatementEvaluator` to refine the `then` and
31
+ # `else` scopes of `IfNode`/`UnlessNode`. Phase 1 narrows local
32
+ # bindings on truthiness and `nil?`; phase 2 extends the catalogue
33
+ # with class-membership predicates (`is_a?`, `kind_of?`,
34
+ # `instance_of?`) and trusted equality/inequality checks against
35
+ # static literals.
36
+ #
37
+ # The module is pure: every public function returns fresh values and
38
+ # MUST NOT mutate its inputs. Unrecognised predicate shapes degrade
39
+ # silently to "no narrowing" by returning `nil` from the internal
40
+ # analyser; the public `predicate_scopes` always returns an
41
+ # `[truthy_scope, falsey_scope]` pair (the entry scope twice when no
42
+ # rule matches).
43
+ #
44
+ # See docs/internal-spec/inference-engine.md (Slice 6 — Narrowing)
45
+ # and docs/type-specification/control-flow-analysis.md for the
46
+ # binding contract.
47
+ # rubocop:disable Metrics/ModuleLength
48
+ module Narrowing
49
+ TRUSTED_EQUALITY_LITERAL_CLASSES = [String, Symbol, Integer, TrueClass, FalseClass, NilClass].freeze
50
+ SINGLETON_LITERAL_CLASSES = [TrueClass, FalseClass, NilClass].freeze
51
+ ClassNarrowingContext = Data.define(:exact, :polarity, :environment)
52
+ private_constant :TRUSTED_EQUALITY_LITERAL_CLASSES, :SINGLETON_LITERAL_CLASSES, :ClassNarrowingContext
53
+
54
+ module_function
55
+
56
+ # Truthy fragment of `type`: the subset whose inhabitants are truthy
57
+ # in Ruby's sense (anything other than `nil` and `false`).
58
+ #
59
+ # `Top`, `Dynamic[T]`, `Bot`, `Singleton[C]`, `Tuple[*]`, and
60
+ # `HashShape{*}` flow through unchanged: Top/Dynamic stay
61
+ # conservative because the analyzer cannot express the
62
+ # difference type without a richer carrier and Dynamic must
63
+ # preserve its provenance under the value-lattice algebra; the
64
+ # remaining carriers are already truthy by inhabitance.
65
+ def narrow_truthy(type)
66
+ case type
67
+ when Type::Constant
68
+ falsey_value?(type.value) ? Type::Combinator.bot : type
69
+ when Type::Nominal
70
+ falsey_nominal?(type) ? Type::Combinator.bot : type
71
+ when Type::Union
72
+ Type::Combinator.union(*type.members.map { |m| narrow_truthy(m) })
73
+ else
74
+ type
75
+ end
76
+ end
77
+
78
+ # Falsey fragment of `type`: the subset whose inhabitants are
79
+ # `nil` or `false`. Carriers that cannot inhabit a falsey value
80
+ # collapse to `Bot`.
81
+ def narrow_falsey(type)
82
+ case type
83
+ when Type::Constant then falsey_value?(type.value) ? type : Type::Combinator.bot
84
+ when Type::Nominal then falsey_nominal?(type) ? type : Type::Combinator.bot
85
+ when Type::Union then Type::Combinator.union(*type.members.map { |m| narrow_falsey(m) })
86
+ else narrow_falsey_other(type)
87
+ end
88
+ end
89
+
90
+ # Nil fragment of `type`: the subset whose inhabitants are `nil`.
91
+ # Used by `nil?` predicate narrowing. `Top`/`Dynamic` narrow to
92
+ # the canonical `Constant[nil]` so downstream dispatch resolves
93
+ # through `NilClass`; carriers that never inhabit `nil`
94
+ # (`Singleton`, `Tuple`, `HashShape`) collapse to `Bot`. `Bot`
95
+ # is its own nil fragment.
96
+ def narrow_nil(type)
97
+ case type
98
+ when Type::Constant then type.value.nil? ? type : Type::Combinator.bot
99
+ when Type::Nominal then type.class_name == "NilClass" ? type : Type::Combinator.bot
100
+ when Type::Union then Type::Combinator.union(*type.members.map { |m| narrow_nil(m) })
101
+ else narrow_nil_other(type)
102
+ end
103
+ end
104
+
105
+ # Non-nil fragment of `type`: the subset whose inhabitants are
106
+ # not `nil`. Mirror of {.narrow_nil} for the falsey edge of
107
+ # `x.nil?`.
108
+ def narrow_non_nil(type)
109
+ case type
110
+ when Type::Constant
111
+ type.value.nil? ? Type::Combinator.bot : type
112
+ when Type::Nominal
113
+ type.class_name == "NilClass" ? Type::Combinator.bot : type
114
+ when Type::Union
115
+ Type::Combinator.union(*type.members.map { |m| narrow_non_nil(m) })
116
+ else
117
+ # Top, Dynamic, Singleton, Tuple, HashShape, Bot: there is
118
+ # no nil contribution to remove, so the type is its own
119
+ # non-nil fragment.
120
+ type
121
+ end
122
+ end
123
+
124
+ # Equality fragment of `type` against a trusted literal.
125
+ #
126
+ # String/Symbol/Integer equality narrows only when the current
127
+ # domain is already a finite union of trusted literals. Nil and
128
+ # booleans are singleton values, so they can be extracted from a
129
+ # mixed union such as `Integer | nil` without manufacturing a new
130
+ # positive domain from the comparison alone.
131
+ def narrow_equal(type, literal)
132
+ return type unless trusted_equality_literal?(literal)
133
+
134
+ if singleton_literal?(literal)
135
+ narrow_singleton_equal(type, literal)
136
+ elsif finite_trusted_literal_domain?(type)
137
+ narrow_finite_equal(type, literal)
138
+ else
139
+ type
140
+ end
141
+ end
142
+
143
+ # Complement of {.narrow_equal}. Negative facts are domain-relative:
144
+ # they remove a literal from an already-known domain but do not create
145
+ # an unbounded difference type when the domain is broad or dynamic.
146
+ def narrow_not_equal(type, literal)
147
+ return type unless trusted_equality_literal?(literal)
148
+
149
+ if singleton_literal?(literal)
150
+ narrow_singleton_not_equal(type, literal)
151
+ elsif finite_trusted_literal_domain?(type)
152
+ narrow_finite_not_equal(type, literal)
153
+ else
154
+ type
155
+ end
156
+ end
157
+
158
+ # Class-membership fragment of `type`: the subset whose
159
+ # inhabitants are instances of `class_name` (or its subclasses
160
+ # when `exact: false`). `class_name` is the qualified name of
161
+ # the class as it appears in source (`"Integer"`, `"Foo::Bar"`).
162
+ # Slice 6 phase 2 sub-phase 1 narrows the `if x.is_a?(C)`
163
+ # / `if x.kind_of?(C)` / `if x.instance_of?(C)` truthy edge.
164
+ #
165
+ # Nominal narrowing is hierarchy-aware through the analyzer
166
+ # environment: when the bound type is a supertype of
167
+ # `class_name` the result narrows DOWN to `Nominal[class_name]`
168
+ # (e.g., `Numeric & Integer = Integer`); when the bound type is
169
+ # already a subtype it is preserved; disjoint hierarchies
170
+ # collapse to `Bot`. Classes the environment cannot resolve
171
+ # fall back to the conservative answer (the type unchanged) so
172
+ # the analyzer never asserts narrowing it cannot prove.
173
+ def narrow_class(type, class_name, exact: false, environment: Environment.default)
174
+ context = ClassNarrowingContext.new(exact: exact, polarity: :positive, environment: environment)
175
+ narrow_class_dispatch(type, class_name, context)
176
+ end
177
+
178
+ # Mirror of {.narrow_class} for the falsey edge of
179
+ # `is_a?`/`kind_of?`/`instance_of?`. Inhabitants that DO
180
+ # satisfy the predicate are removed; inhabitants that do not
181
+ # are preserved. Conservative on Top/Dynamic/Bot (preserved
182
+ # unchanged) because the analyzer cannot prove the negative
183
+ # without a richer carrier.
184
+ def narrow_not_class(type, class_name, exact: false, environment: Environment.default)
185
+ context = ClassNarrowingContext.new(exact: exact, polarity: :negative, environment: environment)
186
+ narrow_class_dispatch(type, class_name, context)
187
+ end
188
+
189
+ # Public predicate analyser. Returns `[truthy_scope, falsey_scope]`,
190
+ # always; when no narrowing rule matches the predicate node both
191
+ # entries are the receiver scope unchanged.
192
+ #
193
+ # @param node [Prism::Node, nil]
194
+ # @param scope [Rigor::Scope]
195
+ # @return [Array(Rigor::Scope, Rigor::Scope)]
196
+ def predicate_scopes(node, scope)
197
+ return [scope, scope] if node.nil?
198
+
199
+ result = analyse(node, scope)
200
+ result || [scope, scope]
201
+ end
202
+
203
+ # Slice 7 phase 5 — `case`/`when` narrowing.
204
+ #
205
+ # Given the subject of a `case` (the expression after the
206
+ # `case` keyword) and an array of `when`-clause condition
207
+ # nodes (`when_clause.conditions`), returns a pair of
208
+ # scopes:
209
+ #
210
+ # - `body_scope`: the scope under which the body of the
211
+ # `when` clause MUST be evaluated. The subject local is
212
+ # narrowed by the union of every condition's truthy
213
+ # edge so the body sees the most specific type
214
+ # compatible with "any of the conditions matched".
215
+ # - `falsey_scope`: the scope under which the next branch
216
+ # (the next `when` or the `else`) MUST be evaluated.
217
+ # The subject is narrowed by the conjunction of every
218
+ # condition's falsey edge.
219
+ #
220
+ # The narrowing is best-effort: if the subject is not a
221
+ # `Prism::LocalVariableReadNode` or none of the condition
222
+ # shapes are recognised, both returned scopes equal the
223
+ # input scope. The catalogue mirrors
224
+ # {.case_equality_target_class}: static class/module
225
+ # constants narrow as `is_a?`; integer/float-endpoint
226
+ # ranges narrow to `Numeric`; string-endpoint ranges and
227
+ # regexp literals narrow to `String`.
228
+ #
229
+ # @param subject [Prism::Node, nil] the `case` subject.
230
+ # @param conditions [Array<Prism::Node>] the `when`
231
+ # clause's `conditions` array.
232
+ # @param scope [Rigor::Scope]
233
+ # @return [Array(Rigor::Scope, Rigor::Scope)]
234
+ def case_when_scopes(subject, conditions, scope)
235
+ return [scope, scope] unless subject.is_a?(Prism::LocalVariableReadNode)
236
+
237
+ local_name = subject.name
238
+ current = scope.local(local_name)
239
+ return [scope, scope] if current.nil?
240
+
241
+ accumulate_case_when_scopes(scope, local_name, current, conditions)
242
+ end
243
+
244
+ # Internal analyser. Returns `[truthy_scope, falsey_scope]` when
245
+ # the predicate shape is recognised, or `nil` to signal "no
246
+ # narrowing" so the public surface can fall back to the entry
247
+ # scope.
248
+ def analyse(node, scope)
249
+ case node
250
+ when Prism::ParenthesesNode
251
+ analyse_parentheses(node, scope)
252
+ when Prism::StatementsNode
253
+ analyse_statements(node, scope)
254
+ when Prism::LocalVariableReadNode
255
+ analyse_local_read(node, scope)
256
+ when Prism::CallNode
257
+ analyse_call(node, scope)
258
+ when Prism::AndNode
259
+ analyse_and(node, scope)
260
+ when Prism::OrNode
261
+ analyse_or(node, scope)
262
+ end
263
+ end
264
+
265
+ # rubocop:disable Metrics/ClassLength
266
+ class << self
267
+ private
268
+
269
+ def falsey_value?(value)
270
+ value.nil? || value == false
271
+ end
272
+
273
+ def falsey_nominal?(nominal)
274
+ %w[NilClass FalseClass].include?(nominal.class_name)
275
+ end
276
+
277
+ # Carriers that the {.narrow_falsey} fast path does not handle
278
+ # by structural inspection. Singleton/Tuple/HashShape inhabit
279
+ # truthy values, so their falsey fragment is empty; everything
280
+ # else (Top, Dynamic, Bot, and any future carrier) stays
281
+ # conservative and is returned unchanged.
282
+ def narrow_falsey_other(type)
283
+ case type
284
+ when Type::Singleton, Type::Tuple, Type::HashShape then Type::Combinator.bot
285
+ else type
286
+ end
287
+ end
288
+
289
+ # Carriers that the {.narrow_nil} fast path does not handle by
290
+ # structural inspection. Top/Dynamic narrow to `Constant[nil]`
291
+ # so dispatch resolves through `NilClass`; Bot is its own nil
292
+ # fragment; the remaining carriers (Singleton, Tuple,
293
+ # HashShape, and any future carrier whose inhabitants exclude
294
+ # nil) collapse to `Bot`.
295
+ def narrow_nil_other(type)
296
+ case type
297
+ when Type::Dynamic, Type::Top then Type::Combinator.constant_of(nil)
298
+ when Type::Bot then type
299
+ else Type::Combinator.bot
300
+ end
301
+ end
302
+
303
+ def trusted_equality_literal?(literal)
304
+ literal.is_a?(Type::Constant) &&
305
+ TRUSTED_EQUALITY_LITERAL_CLASSES.include?(literal.value.class)
306
+ end
307
+
308
+ def singleton_literal?(literal)
309
+ SINGLETON_LITERAL_CLASSES.include?(literal.value.class)
310
+ end
311
+
312
+ def finite_trusted_literal_domain?(type)
313
+ case type
314
+ when Type::Bot then true
315
+ when Type::Constant then trusted_equality_literal?(type)
316
+ when Type::Union then type.members.all? { |member| finite_trusted_literal_domain?(member) }
317
+ else false
318
+ end
319
+ end
320
+
321
+ def narrow_finite_equal(type, literal)
322
+ case type
323
+ when Type::Bot then type
324
+ when Type::Constant then type == literal ? type : Type::Combinator.bot
325
+ when Type::Union
326
+ Type::Combinator.union(*type.members.map { |member| narrow_finite_equal(member, literal) })
327
+ else Type::Combinator.bot
328
+ end
329
+ end
330
+
331
+ def narrow_finite_not_equal(type, literal)
332
+ case type
333
+ when Type::Constant then type == literal ? Type::Combinator.bot : type
334
+ when Type::Union
335
+ Type::Combinator.union(*type.members.map { |member| narrow_finite_not_equal(member, literal) })
336
+ else type
337
+ end
338
+ end
339
+
340
+ def narrow_singleton_equal(type, literal)
341
+ case type
342
+ when Type::Constant then type == literal ? type : Type::Combinator.bot
343
+ when Type::Nominal then singleton_nominal_matches?(type, literal) ? type : Type::Combinator.bot
344
+ when Type::Union
345
+ Type::Combinator.union(*type.members.map { |member| narrow_singleton_equal(member, literal) })
346
+ else narrow_singleton_equal_other(type)
347
+ end
348
+ end
349
+
350
+ def narrow_singleton_equal_other(type)
351
+ case type
352
+ when Type::Singleton, Type::Tuple, Type::HashShape then Type::Combinator.bot
353
+ else type
354
+ end
355
+ end
356
+
357
+ def narrow_singleton_not_equal(type, literal)
358
+ case type
359
+ when Type::Constant then type == literal ? Type::Combinator.bot : type
360
+ when Type::Nominal then singleton_nominal_matches?(type, literal) ? Type::Combinator.bot : type
361
+ when Type::Union
362
+ Type::Combinator.union(*type.members.map { |member| narrow_singleton_not_equal(member, literal) })
363
+ else type
364
+ end
365
+ end
366
+
367
+ def singleton_nominal_matches?(nominal, literal)
368
+ case literal.value
369
+ when nil then nominal.class_name == "NilClass"
370
+ when true then nominal.class_name == "TrueClass"
371
+ when false then nominal.class_name == "FalseClass"
372
+ else false
373
+ end
374
+ end
375
+
376
+ def analyse_parentheses(node, scope)
377
+ return nil if node.body.nil?
378
+
379
+ analyse(node.body, scope)
380
+ end
381
+
382
+ # The truthiness of a `StatementsNode` is determined by its
383
+ # last statement (intermediate statements run for effect and
384
+ # then the predicate's value is the tail's). Earlier
385
+ # statements MAY have scope effects, but Slice 6 phase 1 does
386
+ # NOT thread those through the analyser (the StatementEvaluator
387
+ # has already produced `post_pred` for the call site, and
388
+ # narrowing is layered on that scope).
389
+ def analyse_statements(node, scope)
390
+ return nil if node.body.empty?
391
+
392
+ analyse(node.body.last, scope)
393
+ end
394
+
395
+ def analyse_local_read(node, scope)
396
+ current = scope.local(node.name)
397
+ return nil if current.nil?
398
+
399
+ [
400
+ scope.with_local(node.name, narrow_truthy(current)),
401
+ scope.with_local(node.name, narrow_falsey(current))
402
+ ]
403
+ end
404
+
405
+ # Recognised CallNode predicates:
406
+ # - `recv.nil?` (Slice 6 phase 1, no args, no block)
407
+ # - unary `!recv` (`name == :!`, no args, no block)
408
+ # - `recv.is_a?(C)` / `recv.kind_of?(C)` / `recv.instance_of?(C)`
409
+ # with a single static-constant argument and no block
410
+ # (Slice 6 phase 2 sub-phase 1).
411
+ # - `local == literal` / `literal == local` and the `!=` mirror
412
+ # for trusted static literals (Slice 6 phase 2 sub-phase 2).
413
+ # Anything else returns nil so the surrounding analyser falls
414
+ # through to the no-narrowing fallback.
415
+ def analyse_call(node, scope)
416
+ return nil if node.block
417
+ return nil if node.receiver.nil?
418
+
419
+ shape_result = dispatch_call(node, scope, node.name)
420
+ return shape_result if shape_result
421
+
422
+ # Slice 7 phase 15 — RBS::Extended predicate
423
+ # effects. When the method's RBS signature carries
424
+ # `rigor:v1:predicate-if-true` / `predicate-if-false`
425
+ # annotations, apply them to narrow the corresponding
426
+ # local-variable arguments on each edge.
427
+ analyse_rbs_extended_predicate(node, scope)
428
+ end
429
+
430
+ def dispatch_call(node, scope, name)
431
+ case name
432
+ when :nil?, :! then dispatch_unary_predicate(node, scope, name)
433
+ when :is_a?, :kind_of? then analyse_class_predicate(node, scope, exact: false)
434
+ when :instance_of? then analyse_class_predicate(node, scope, exact: true)
435
+ when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
436
+ when :=== then analyse_case_equality_predicate(node, scope)
437
+ end
438
+ end
439
+
440
+ def dispatch_unary_predicate(node, scope, name)
441
+ return nil unless argument_free?(node)
442
+
443
+ case name
444
+ when :nil? then analyse_nil_predicate(node.receiver, scope)
445
+ when :! then analyse(node.receiver, scope)&.reverse
446
+ end
447
+ end
448
+
449
+ def argument_free?(node)
450
+ node.arguments.nil? || node.arguments.arguments.empty?
451
+ end
452
+
453
+ def analyse_equality_predicate(node, scope, equality:)
454
+ return nil if node.arguments.nil?
455
+ return nil unless node.arguments.arguments.size == 1
456
+
457
+ match = equality_local_literal(node.receiver, node.arguments.arguments.first, scope)
458
+ return nil if match.nil?
459
+
460
+ name, literal = match
461
+ current = scope.local(name)
462
+ return nil if current.nil?
463
+
464
+ positive = equality_scope(scope, name, current, literal, predicate: :==)
465
+ negative = equality_scope(scope, name, current, literal, predicate: :!=)
466
+ equality == :== ? [positive, negative] : [negative, positive]
467
+ end
468
+
469
+ def equality_local_literal(left, right, scope)
470
+ if left.is_a?(Prism::LocalVariableReadNode)
471
+ literal = static_literal_type(right, scope)
472
+ return [left.name, literal] if trusted_equality_literal?(literal)
473
+ end
474
+
475
+ return nil unless right.is_a?(Prism::LocalVariableReadNode)
476
+
477
+ literal = static_literal_type(left, scope)
478
+ return [right.name, literal] if trusted_equality_literal?(literal)
479
+
480
+ nil
481
+ end
482
+
483
+ def static_literal_type(node, scope)
484
+ case node
485
+ when Prism::IntegerNode,
486
+ Prism::StringNode,
487
+ Prism::SymbolNode,
488
+ Prism::TrueNode,
489
+ Prism::FalseNode,
490
+ Prism::NilNode
491
+ scope.type_of(node)
492
+ end
493
+ end
494
+
495
+ def equality_scope(scope, name, current, literal, predicate:)
496
+ narrowed =
497
+ if predicate == :==
498
+ narrow_equal(current, literal)
499
+ else
500
+ narrow_not_equal(current, literal)
501
+ end
502
+
503
+ scope
504
+ .with_local(name, narrowed)
505
+ .with_fact(equality_fact(name, current, narrowed, literal, predicate: predicate))
506
+ end
507
+
508
+ def equality_fact(name, original, narrowed, literal, predicate:)
509
+ Analysis::FactStore::Fact.new(
510
+ bucket: equality_fact_bucket(original, narrowed),
511
+ target: Analysis::FactStore::Target.local(name),
512
+ predicate: predicate,
513
+ payload: literal,
514
+ polarity: predicate == :== ? :positive : :negative,
515
+ stability: :local_binding
516
+ )
517
+ end
518
+
519
+ def equality_fact_bucket(original, narrowed)
520
+ narrowed == original ? :relational : :local_binding
521
+ end
522
+
523
+ # `recv.is_a?(C)` / `recv.kind_of?(C)` / `recv.instance_of?(C)`
524
+ # narrowing. The receiver MUST be a `LocalVariableReadNode`
525
+ # (so we have a name to rebind), and the argument MUST be a
526
+ # single static constant reference (`Foo` or `Foo::Bar`) we
527
+ # can resolve to a qualified class name. Anything else falls
528
+ # through to "no narrowing".
529
+ def analyse_class_predicate(node, scope, exact:)
530
+ return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
531
+ return nil if node.arguments.nil?
532
+ return nil unless node.arguments.arguments.size == 1
533
+
534
+ class_name = static_class_name(node.arguments.arguments.first)
535
+ return nil if class_name.nil?
536
+
537
+ current = scope.local(node.receiver.name)
538
+ return nil if current.nil?
539
+
540
+ class_predicate_scopes(scope, node.receiver.name, current, class_name, exact: exact)
541
+ end
542
+
543
+ def class_predicate_scopes(scope, name, current, class_name, exact:)
544
+ [
545
+ scope.with_local(
546
+ name,
547
+ narrow_class(current, class_name, exact: exact, environment: scope.environment)
548
+ ),
549
+ scope.with_local(
550
+ name,
551
+ narrow_not_class(current, class_name, exact: exact, environment: scope.environment)
552
+ )
553
+ ]
554
+ end
555
+
556
+ # Slice 7 phase 4 — `===`-narrowing. The case-equality
557
+ # predicate `<receiver> === local` is the operator that
558
+ # backs Ruby's `case`/`when` dispatch. Three receiver
559
+ # shapes produce sound narrowing rules:
560
+ #
561
+ # - **Class / Module receiver**: `Foo === x` is
562
+ # isomorphic to `x.is_a?(Foo)` (the default behaviour
563
+ # of `Module#===`). Reuse `class_predicate_scopes` so
564
+ # the truthy edge narrows down to `Foo` and the falsey
565
+ # edge subtracts `Foo` from a union.
566
+ # - **Range literal receiver** (`(1..10) === x`): the
567
+ # default `Range#===` includes `x` iff the endpoints
568
+ # compare it. We conservatively narrow `x` to
569
+ # `Numeric` for integer-endpoint ranges and to
570
+ # `String` for string-endpoint ranges; other endpoint
571
+ # types fall through.
572
+ # - **Regexp literal receiver** (`/foo/ === x`): the
573
+ # match operator coerces `x` to a String, so the
574
+ # truthy edge narrows `x` to `String`. The falsey
575
+ # edge keeps the entry type unchanged because
576
+ # `Regexp#===` returns false for non-Strings AND for
577
+ # Strings that simply do not match.
578
+ #
579
+ # Anything else — non-local LHS argument, dynamic
580
+ # receiver, custom `===` method — falls through to
581
+ # the no-narrowing branch (nil), preserving the
582
+ # entry scope on both edges.
583
+ def analyse_case_equality_predicate(node, scope)
584
+ return nil if node.arguments.nil?
585
+ return nil unless node.arguments.arguments.size == 1
586
+
587
+ local_arg = node.arguments.arguments.first
588
+ return nil unless local_arg.is_a?(Prism::LocalVariableReadNode)
589
+
590
+ current = scope.local(local_arg.name)
591
+ return nil if current.nil?
592
+
593
+ analyse_case_equality_receiver(node.receiver, scope, local_arg.name, current)
594
+ end
595
+
596
+ def analyse_case_equality_receiver(receiver, scope, local_name, current)
597
+ if (class_name = static_class_name(receiver))
598
+ return class_predicate_scopes(scope, local_name, current, class_name, exact: false)
599
+ end
600
+
601
+ target_class = case_equality_target_class(receiver)
602
+ return nil if target_class.nil?
603
+
604
+ narrowed = narrow_class(current, target_class, exact: false, environment: scope.environment)
605
+ [
606
+ scope.with_local(local_name, narrowed),
607
+ scope.with_local(local_name, current)
608
+ ]
609
+ end
610
+
611
+ # Maps a case-equality literal receiver to the class
612
+ # whose membership is implied by the truthy edge.
613
+ # `Prism::ParenthesesNode` wrappers are transparently
614
+ # unwrapped (`(1..10) === x` is parsed with the range
615
+ # inside parentheses). Range literals: integer
616
+ # endpoints → `Numeric`; string endpoints → `String`.
617
+ # Regexp literals → `String`. Other shapes return nil
618
+ # so the caller falls through.
619
+ def case_equality_target_class(receiver)
620
+ receiver = unwrap_parens(receiver)
621
+ case receiver
622
+ when Prism::RangeNode then range_target_class(receiver)
623
+ when Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode then "String"
624
+ end
625
+ end
626
+
627
+ def unwrap_parens(node)
628
+ while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode) &&
629
+ node.body.body.size == 1
630
+ node = node.body.body.first
631
+ end
632
+ node
633
+ end
634
+
635
+ # Slice 7 phase 15 — RBS::Extended predicate-effect
636
+ # analyser. Resolves the called method through the
637
+ # RBS environment, reads any `rigor:v1:predicate-if-*`
638
+ # annotations, and applies them to the call's
639
+ # local-variable arguments.
640
+ #
641
+ # Conservative envelope:
642
+ # - Receiver type must be `Type::Nominal`,
643
+ # `Type::Singleton`, or `Type::Constant`.
644
+ # - The method must be present in the loader.
645
+ # - For each predicate effect, the corresponding
646
+ # positional argument (matched by parameter name in
647
+ # the selected overload) MUST be a
648
+ # `Prism::LocalVariableReadNode` for narrowing to
649
+ # apply.
650
+ # - When the target is `self`, narrowing applies to
651
+ # the receiver — but the engine does not yet narrow
652
+ # `self` itself (Slice A-engine self-typing is
653
+ # read-only), so `self`-targeted effects are
654
+ # accepted by the parser but currently produce no
655
+ # scope edits.
656
+ def analyse_rbs_extended_predicate(node, scope)
657
+ method_def = resolve_rbs_extended_method(node, scope)
658
+ return nil if method_def.nil?
659
+
660
+ effects = RbsExtended.read_predicate_effects(method_def)
661
+ return nil if effects.empty?
662
+
663
+ truthy_scope = scope
664
+ falsey_scope = scope
665
+ effects.each do |effect|
666
+ truthy_scope, falsey_scope =
667
+ apply_predicate_effect(effect, node, scope, truthy_scope, falsey_scope, method_def)
668
+ end
669
+ [truthy_scope, falsey_scope]
670
+ end
671
+
672
+ def resolve_rbs_extended_method(node, scope)
673
+ loader = scope.environment.rbs_loader
674
+ return nil if loader.nil?
675
+
676
+ receiver_type = scope.type_of(node.receiver)
677
+ class_name = rbs_extended_class_name(receiver_type)
678
+ return nil if class_name.nil?
679
+ return nil unless loader.class_known?(class_name)
680
+
681
+ if receiver_type.is_a?(Type::Singleton)
682
+ loader.singleton_method(class_name: class_name, method_name: node.name)
683
+ else
684
+ loader.instance_method(class_name: class_name, method_name: node.name)
685
+ end
686
+ rescue StandardError
687
+ nil
688
+ end
689
+
690
+ def rbs_extended_class_name(receiver_type)
691
+ case receiver_type
692
+ when Type::Nominal, Type::Singleton then receiver_type.class_name
693
+ when Type::Constant then rbs_extended_constant_class(receiver_type.value)
694
+ end
695
+ end
696
+
697
+ CONSTANT_CLASSES = {
698
+ Integer => "Integer", Float => "Float", String => "String",
699
+ Symbol => "Symbol",
700
+ TrueClass => "TrueClass", FalseClass => "FalseClass",
701
+ NilClass => "NilClass"
702
+ }.freeze
703
+ private_constant :CONSTANT_CLASSES
704
+
705
+ def rbs_extended_constant_class(value)
706
+ CONSTANT_CLASSES.each { |klass, name| return name if value.is_a?(klass) }
707
+ nil
708
+ end
709
+
710
+ # rubocop:disable Metrics/ParameterLists
711
+ def apply_predicate_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
712
+ arg_node = lookup_positional_arg(call_node, method_def, effect.target_name)
713
+ return [truthy_scope, falsey_scope] if effect.target_kind != :parameter
714
+ return [truthy_scope, falsey_scope] unless arg_node.is_a?(Prism::LocalVariableReadNode)
715
+
716
+ local_name = arg_node.name
717
+ current = entry_scope.local(local_name)
718
+ return [truthy_scope, falsey_scope] if current.nil?
719
+
720
+ narrowed = narrow_class(current, effect.class_name, exact: false, environment: entry_scope.environment)
721
+ if effect.truthy_only?
722
+ [truthy_scope.with_local(local_name, narrowed), falsey_scope]
723
+ else
724
+ [truthy_scope, falsey_scope.with_local(local_name, narrowed)]
725
+ end
726
+ end
727
+ # rubocop:enable Metrics/ParameterLists
728
+
729
+ # Maps the effect's target parameter name to the call
730
+ # site argument by inspecting the selected overload's
731
+ # required-positional parameter list. Returns the Prism
732
+ # arg node at that position, or nil when the overload
733
+ # shape does not allow a precise match.
734
+ def lookup_positional_arg(call_node, method_def, target_name)
735
+ arguments = call_node.arguments&.arguments || []
736
+ method_def.method_types.each do |mt|
737
+ params = mt.type.required_positionals + mt.type.optional_positionals
738
+ index = params.find_index { |param| param.name == target_name }
739
+ return arguments[index] if index && arguments[index]
740
+ end
741
+ nil
742
+ end
743
+
744
+ # Slice 7 phase 5 — case/when accumulator. Walks each
745
+ # `when` condition, computes the narrowed type for the
746
+ # subject as if `condition === subject`, and accumulates
747
+ # them. The body's narrowed type is the union across
748
+ # all conditions; the falsey type is the running result
749
+ # after subtracting every condition's class. Conditions
750
+ # whose shape we cannot statically classify are treated
751
+ # as "no narrowing": the body falls back to the union of
752
+ # what we did learn (or the entry type when nothing
753
+ # learned), and the falsey edge is the entry type
754
+ # (because we cannot prove the unknown condition didn't
755
+ # match).
756
+ def accumulate_case_when_scopes(scope, local_name, current, conditions)
757
+ truthy_members = []
758
+ falsey_type = current
759
+ fully_narrowable = true
760
+
761
+ conditions.each do |condition|
762
+ target = static_class_name(condition) || case_equality_target_class(condition)
763
+ if target
764
+ truthy_members << narrow_class(current, target, exact: false, environment: scope.environment)
765
+ falsey_type = narrow_not_class(falsey_type, target, exact: false, environment: scope.environment)
766
+ else
767
+ fully_narrowable = false
768
+ end
769
+ end
770
+
771
+ truthy = truthy_members.empty? ? current : Type::Combinator.union(*truthy_members)
772
+ [
773
+ scope.with_local(local_name, truthy),
774
+ scope.with_local(local_name, fully_narrowable ? falsey_type : current)
775
+ ]
776
+ end
777
+
778
+ def range_target_class(range_node)
779
+ left = range_node.left
780
+ right = range_node.right
781
+ return "Numeric" if integer_endpoint?(left) || integer_endpoint?(right)
782
+
783
+ "String" if string_endpoint?(left) || string_endpoint?(right)
784
+ end
785
+
786
+ def integer_endpoint?(node)
787
+ node.is_a?(Prism::IntegerNode) || node.is_a?(Prism::FloatNode)
788
+ end
789
+
790
+ def string_endpoint?(node)
791
+ node.is_a?(Prism::StringNode)
792
+ end
793
+
794
+ # Walks a constant-reference subtree (`Prism::ConstantReadNode`,
795
+ # `Prism::ConstantPathNode`) and renders its qualified name.
796
+ # Returns nil for any non-constant argument shape so the
797
+ # caller can fall through.
798
+ def static_class_name(node)
799
+ case node
800
+ when Prism::ConstantReadNode
801
+ node.name.to_s
802
+ when Prism::ConstantPathNode
803
+ parent = node.parent
804
+ return node.name.to_s if parent.nil?
805
+
806
+ parent_name = static_class_name(parent)
807
+ return nil if parent_name.nil?
808
+
809
+ "#{parent_name}::#{node.name}"
810
+ end
811
+ end
812
+
813
+ # ----- narrow_class / narrow_not_class helpers -----
814
+
815
+ # Polarity-aware dispatch table for {.narrow_class} /
816
+ # {.narrow_not_class}. Avoids duplicating the per-carrier
817
+ # case statement and keeps each public surface a thin
818
+ # delegate; the per-carrier helpers know which polarity to
819
+ # apply by looking at `polarity:`.
820
+ def narrow_class_dispatch(type, class_name, context)
821
+ case type
822
+ when Type::Constant then narrow_constant_class(type, class_name, context)
823
+ when Type::Nominal then narrow_nominal_class(type, class_name, context)
824
+ when Type::Union then narrow_union_class(type, class_name, context)
825
+ when Type::Tuple then narrow_shape_class(type, "Array", class_name, context)
826
+ when Type::HashShape then narrow_shape_class(type, "Hash", class_name, context)
827
+ when Type::Singleton then narrow_singleton_class(type, class_name, context)
828
+ else narrow_other_class(type, class_name, context)
829
+ end
830
+ end
831
+
832
+ def narrow_constant_class(constant, class_name, context)
833
+ if context.polarity == :positive
834
+ narrow_constant_to_class(constant, class_name, context)
835
+ else
836
+ narrow_constant_not_class(constant, class_name, context)
837
+ end
838
+ end
839
+
840
+ def narrow_nominal_class(nominal, class_name, context)
841
+ if context.polarity == :positive
842
+ narrow_nominal_to_class(nominal, class_name, context)
843
+ else
844
+ narrow_nominal_not_class(nominal, class_name, context)
845
+ end
846
+ end
847
+
848
+ def narrow_union_class(union, class_name, context)
849
+ Type::Combinator.union(
850
+ *union.members.map { |member| narrow_class_dispatch(member, class_name, context) }
851
+ )
852
+ end
853
+
854
+ def narrow_shape_class(shape, projected_class, class_name, context)
855
+ if context.polarity == :positive
856
+ narrow_shape_to_class(shape, projected_class, class_name, context)
857
+ else
858
+ narrow_shape_not_class(shape, projected_class, class_name, context)
859
+ end
860
+ end
861
+
862
+ def narrow_singleton_class(singleton, class_name, context)
863
+ if context.polarity == :positive
864
+ narrow_singleton_to_class(singleton, class_name, context)
865
+ else
866
+ narrow_singleton_not_class(singleton, class_name, context)
867
+ end
868
+ end
869
+
870
+ def narrow_other_class(type, class_name, context)
871
+ context.polarity == :positive ? narrow_class_other(type, class_name) : type
872
+ end
873
+
874
+ def narrow_constant_to_class(constant, class_name, context)
875
+ rigor_class = constant.value.class.name
876
+ subclass_of?(rigor_class, class_name, context) ? constant : Type::Combinator.bot
877
+ end
878
+
879
+ def narrow_constant_not_class(constant, class_name, context)
880
+ rigor_class = constant.value.class.name
881
+ subclass_of?(rigor_class, class_name, context) ? Type::Combinator.bot : constant
882
+ end
883
+
884
+ # Narrow a Nominal under `is_a?(class_name)`: when the
885
+ # nominal's class is already a subclass of `class_name`
886
+ # (or matches under `exact: true`) preserve it; when
887
+ # `class_name` is a subclass of the nominal's class
888
+ # (`Nominal[Numeric]` under `is_a?(Integer)`) narrow DOWN
889
+ # to `Nominal[class_name]`; otherwise (disjoint hierarchies
890
+ # under `is_a?`, mismatch under `instance_of?`) collapse to
891
+ # `Bot`. Conservative when the analyzer environment cannot
892
+ # resolve either class.
893
+ def narrow_nominal_to_class(nominal, class_name, context)
894
+ return nominal if nominal.class_name == class_name
895
+ return Type::Combinator.bot if context.exact
896
+
897
+ case class_ordering(nominal.class_name, class_name, context)
898
+ when :superclass then Type::Combinator.nominal_of(class_name)
899
+ when :disjoint then Type::Combinator.bot
900
+ else nominal # :subclass preserves the bound; :unknown stays conservative
901
+ end
902
+ end
903
+
904
+ def narrow_nominal_not_class(nominal, class_name, context)
905
+ return Type::Combinator.bot if nominal.class_name == class_name
906
+ return nominal if context.exact
907
+
908
+ ordering = class_ordering(nominal.class_name, class_name, context)
909
+ case ordering
910
+ when :subclass then Type::Combinator.bot
911
+ else nominal
912
+ end
913
+ end
914
+
915
+ def narrow_shape_to_class(shape, projected_class, class_name, context)
916
+ subclass_of?(projected_class, class_name, context) ? shape : Type::Combinator.bot
917
+ end
918
+
919
+ def narrow_shape_not_class(shape, projected_class, class_name, context)
920
+ subclass_of?(projected_class, class_name, context) ? Type::Combinator.bot : shape
921
+ end
922
+
923
+ # `Singleton[Foo]` is the *class object* `Foo`, an instance of
924
+ # `Class` (which is a subclass of `Module`). Asking
925
+ # `Foo.is_a?(Class)` returns true; `Foo.is_a?(Foo)` returns
926
+ # false unless `Foo` is `Class` itself. We approximate this
927
+ # by treating singletons uniformly as `Class` instances.
928
+ def narrow_singleton_to_class(singleton, class_name, context)
929
+ subclass_of?("Class", class_name, context) ? singleton : Type::Combinator.bot
930
+ end
931
+
932
+ def narrow_singleton_not_class(singleton, class_name, context)
933
+ subclass_of?("Class", class_name, context) ? Type::Combinator.bot : singleton
934
+ end
935
+
936
+ # Top/Dynamic narrow to `Nominal[class_name]` so dispatch
937
+ # can resolve through the asked class; Bot stays Bot.
938
+ def narrow_class_other(type, class_name)
939
+ case type
940
+ when Type::Dynamic, Type::Top then Type::Combinator.nominal_of(class_name)
941
+ else type
942
+ end
943
+ end
944
+
945
+ # Returns `true` when an instance of `rigor_class_name`
946
+ # satisfies `is_a?(target_class_name)` (or
947
+ # `instance_of?(target_class_name)` when `exact: true`).
948
+ # Falls back to the safe `false` when either name does not
949
+ # resolve through the analyzer environment.
950
+ def subclass_of?(rigor_class_name, target_class_name, context)
951
+ return rigor_class_name == target_class_name if context.exact
952
+
953
+ %i[subclass equal].include?(
954
+ class_ordering(rigor_class_name, target_class_name, context)
955
+ )
956
+ end
957
+
958
+ # Compares two class names through the analyzer environment.
959
+ # Returns `:equal` when they resolve to the same class,
960
+ # `:subclass` when `lhs <= rhs`, `:superclass` when
961
+ # `rhs <= lhs`, `:disjoint` when neither, and `:unknown` when
962
+ # either name does not resolve.
963
+ def class_ordering(lhs, rhs, context)
964
+ return :equal if lhs == rhs
965
+
966
+ context.environment.class_ordering(lhs, rhs)
967
+ end
968
+
969
+ def analyse_nil_predicate(receiver, scope)
970
+ return nil unless receiver.is_a?(Prism::LocalVariableReadNode)
971
+
972
+ current = scope.local(receiver.name)
973
+ return nil if current.nil?
974
+
975
+ [
976
+ scope.with_local(receiver.name, narrow_nil(current)),
977
+ scope.with_local(receiver.name, narrow_non_nil(current))
978
+ ]
979
+ end
980
+
981
+ # `a && b` short-circuits: the truthy edge is the truthy edge
982
+ # of `b` evaluated under `a`'s truthy scope; the falsey edge
983
+ # is the union of `a`'s falsey scope (b skipped) and `b`'s
984
+ # falsey scope (b ran but returned falsey). When a sub-edge
985
+ # cannot be narrowed we fall back to the entry scope so the
986
+ # caller still sees consistent keys across the two output
987
+ # scopes.
988
+ def analyse_and(node, scope)
989
+ truthy_a, falsey_a = analyse(node.left, scope) || [scope, scope]
990
+ truthy_b, falsey_b = analyse(node.right, truthy_a) || [truthy_a, truthy_a]
991
+ [truthy_b, falsey_a.join(falsey_b)]
992
+ end
993
+
994
+ # `a || b` short-circuits: the truthy edge is the union of
995
+ # `a`'s truthy scope (b skipped) and `b`'s truthy scope (b
996
+ # ran and was truthy); the falsey edge is `b`'s falsey scope
997
+ # evaluated under `a`'s falsey scope.
998
+ def analyse_or(node, scope)
999
+ truthy_a, falsey_a = analyse(node.left, scope) || [scope, scope]
1000
+ truthy_b, falsey_b = analyse(node.right, falsey_a) || [falsey_a, falsey_a]
1001
+ [truthy_a.join(truthy_b), falsey_b]
1002
+ end
1003
+ end
1004
+ # rubocop:enable Metrics/ClassLength
1005
+ end
1006
+ # rubocop:enable Metrics/ModuleLength
1007
+ end
1008
+ end