rigortype 0.0.9 → 0.1.0

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/lib/rigor/analysis/check_rules.rb +228 -40
  4. data/lib/rigor/analysis/diagnostic.rb +15 -1
  5. data/lib/rigor/analysis/runner.rb +183 -4
  6. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  7. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  8. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  9. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  10. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  11. data/lib/rigor/cache/store.rb +2 -0
  12. data/lib/rigor/cli.rb +9 -3
  13. data/lib/rigor/configuration/severity_profile.rb +109 -0
  14. data/lib/rigor/configuration.rb +110 -6
  15. data/lib/rigor/environment/rbs_loader.rb +89 -13
  16. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  17. data/lib/rigor/flow_contribution/element.rb +53 -0
  18. data/lib/rigor/flow_contribution/fact.rb +88 -0
  19. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  20. data/lib/rigor/flow_contribution/merger.rb +275 -0
  21. data/lib/rigor/flow_contribution.rb +51 -0
  22. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  23. data/lib/rigor/inference/expression_typer.rb +84 -5
  24. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +95 -9
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  26. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  27. data/lib/rigor/inference/narrowing.rb +105 -130
  28. data/lib/rigor/inference/scope_indexer.rb +75 -1
  29. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  30. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  31. data/lib/rigor/plugin/base.rb +241 -0
  32. data/lib/rigor/plugin/io_boundary.rb +102 -0
  33. data/lib/rigor/plugin/load_error.rb +23 -0
  34. data/lib/rigor/plugin/loader.rb +191 -0
  35. data/lib/rigor/plugin/manifest.rb +134 -0
  36. data/lib/rigor/plugin/registry.rb +50 -0
  37. data/lib/rigor/plugin/services.rb +65 -0
  38. data/lib/rigor/plugin/trust_policy.rb +99 -0
  39. data/lib/rigor/plugin.rb +61 -0
  40. data/lib/rigor/rbs_extended.rb +57 -9
  41. data/lib/rigor/reflection.rb +2 -2
  42. data/lib/rigor/version.rb +1 -1
  43. data/lib/rigor.rb +7 -0
  44. data/sig/rigor/environment.rbs +7 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/rbs_extended.rbs +2 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +7 -0
  49. metadata +18 -1
@@ -65,6 +65,7 @@ module Rigor
65
65
  Prism::NilNode => :type_of_nil,
66
66
  # Locals
67
67
  Prism::LocalVariableReadNode => :local_read,
68
+ Prism::ItLocalVariableReadNode => :it_read,
68
69
  Prism::LocalVariableWriteNode => :type_of_assignment_write,
69
70
  # Containers and pass-throughs
70
71
  Prism::ArrayNode => :array_type_for,
@@ -171,9 +172,11 @@ module Rigor
171
172
  Prism::WhileNode => :type_of_loop,
172
173
  Prism::UntilNode => :type_of_loop,
173
174
  Prism::ForNode => :type_of_dynamic_top,
174
- Prism::DefinedNode => :type_of_dynamic_top,
175
- Prism::MatchPredicateNode => :type_of_dynamic_top,
176
- Prism::MatchRequiredNode => :type_of_dynamic_top,
175
+ Prism::DefinedNode => :type_of_defined,
176
+ Prism::NumberedReferenceReadNode => :type_of_string_or_nil,
177
+ Prism::BackReferenceReadNode => :type_of_string_or_nil,
178
+ Prism::MatchPredicateNode => :type_of_match_predicate,
179
+ Prism::MatchRequiredNode => :type_of_match_required,
177
180
  Prism::MatchWriteNode => :type_of_dynamic_top,
178
181
  # Literal containers
179
182
  Prism::LambdaNode => :type_of_lambda,
@@ -298,6 +301,45 @@ module Rigor
298
301
  dynamic_top
299
302
  end
300
303
 
304
+ # `defined?(expr)` returns `String | nil` per Ruby semantics —
305
+ # a description of the expression's category (`"local-variable"`,
306
+ # `"method"`, ...) when defined, or `nil` when not. The argument
307
+ # is not evaluated (it is statically inspected by the runtime),
308
+ # so the typer does not recurse into it.
309
+ def type_of_defined(_node)
310
+ Type::Combinator.union(
311
+ Type::Combinator.nominal_of("String"),
312
+ Type::Combinator.constant_of(nil)
313
+ )
314
+ end
315
+
316
+ # `$1`, `$&`, `$'`, `$+`, `$\`` — the regex back-reference and
317
+ # numbered-capture globals each carry `String | nil`. They share
318
+ # the typer because the typing rule is identical regardless of
319
+ # which back-reference shape Prism emitted.
320
+ def type_of_string_or_nil(_node)
321
+ Type::Combinator.union(
322
+ Type::Combinator.nominal_of("String"),
323
+ Type::Combinator.constant_of(nil)
324
+ )
325
+ end
326
+
327
+ # `expr in pattern` — pattern-match predicate. Returns `true`
328
+ # when the pattern matches, `false` otherwise.
329
+ def type_of_match_predicate(_node)
330
+ Type::Combinator.union(
331
+ Type::Combinator.constant_of(true),
332
+ Type::Combinator.constant_of(false)
333
+ )
334
+ end
335
+
336
+ # `expr => pattern` — one-line pattern-match assertion. Raises
337
+ # `NoMatchingPatternError` on mismatch; on success the expression
338
+ # itself evaluates to `nil`.
339
+ def type_of_match_required(_node)
340
+ Type::Combinator.constant_of(nil)
341
+ end
342
+
301
343
  # The expression `Foo` evaluates to the *class object* `Foo`, not
302
344
  # an instance. From Slice 4 phase 2b on we therefore type a
303
345
  # bare-constant reference as `Singleton[Foo]`; method dispatch on
@@ -723,9 +765,38 @@ module Rigor
723
765
  def type_of_range(node)
724
766
  left_static, left = static_range_endpoint(node.left)
725
767
  right_static, right = static_range_endpoint(node.right)
726
- return Type::Combinator.nominal_of(Range) unless left_static && right_static
768
+ return Type::Combinator.constant_of(Range.new(left, right, node.exclude_end?)) if left_static && right_static
727
769
 
728
- Type::Combinator.constant_of(Range.new(left, right, node.exclude_end?))
770
+ nominal_range_for_endpoints(node.left, node.right)
771
+ end
772
+
773
+ # Derives `Nominal[Range, [T]]` from the endpoint expression
774
+ # types when at least one endpoint is statically typeable. The
775
+ # element parameter is the union of the endpoint types (lifted
776
+ # from `Constant<v>` to `Nominal<v.class>` so the carrier matches
777
+ # what `Range#each` would yield). Falls back to bare
778
+ # `Nominal[Range]` when no endpoint contributes a typable shape.
779
+ def nominal_range_for_endpoints(left_node, right_node)
780
+ endpoints = [left_node, right_node].compact.map { |n| range_endpoint_element_type(n) }
781
+ endpoints.reject! { |t| t.equal?(Type::Combinator.untyped) }
782
+ return Type::Combinator.nominal_of("Range") if endpoints.empty?
783
+
784
+ Type::Combinator.nominal_of("Range", type_args: [Type::Combinator.union(*endpoints)])
785
+ end
786
+
787
+ def range_endpoint_element_type(node)
788
+ type = type_of(node)
789
+ case type
790
+ when Type::Constant
791
+ value = type.value
792
+ return Type::Combinator.untyped if value.nil?
793
+
794
+ Type::Combinator.nominal_of(value.class.name)
795
+ when Type::IntegerRange
796
+ Type::Combinator.nominal_of("Integer")
797
+ else
798
+ type
799
+ end
729
800
  end
730
801
 
731
802
  # v0.0.7 — non-interpolated regex literals lift to
@@ -746,6 +817,7 @@ module Rigor
746
817
  def static_range_endpoint(node)
747
818
  return [true, nil] if node.nil?
748
819
  return [true, node.value] if node.is_a?(Prism::IntegerNode)
820
+ return [true, node.unescaped] if node.is_a?(Prism::StringNode) && node.respond_to?(:unescaped)
749
821
 
750
822
  [false, nil]
751
823
  end
@@ -805,6 +877,13 @@ module Rigor
805
877
  scope.local(node.name) || dynamic_top
806
878
  end
807
879
 
880
+ # `it` (Ruby 3.4) — `ItLocalVariableReadNode` carries no `name`
881
+ # field; the implicit name is always `:it`, matching the binding
882
+ # `BlockParameterBinder` installs for `Prism::ItParametersNode`.
883
+ def it_read(_node)
884
+ scope.local(:it) || dynamic_top
885
+ end
886
+
808
887
  # Slice 5 phase 1 upgrades array literals to `Tuple[T1..Tn]`
809
888
  # when every element is a non-splat value. Splatted entries
810
889
  # (`[*xs, 1]`) preserve the Slice 4 phase 2d behavior: we union
@@ -8,12 +8,21 @@ module Rigor
8
8
  # Dispatcher tier that lifts string-composition results into
9
9
  # the `literal-string` carrier when every operand is itself
10
10
  # literal-bearing. Sits between {ConstantFolding} (which
11
- # handles all-Constant cases) and {ShapeDispatch}; runs only
12
- # for `String#+` / `String#*` / `String#<<` / `String#concat`
13
- # calls whose inputs the ConstantFolding tier could not fold
14
- # to a precise `Constant<String>` (e.g. one operand is
15
- # `literal-string` rather than `Constant<String>`, or the
16
- # multiplication exceeds the constant-fold size cap).
11
+ # handles all-Constant cases) and {ShapeDispatch}; runs for:
12
+ #
13
+ # - `String#+` / `String#*` / `String#<<` / `String#concat`
14
+ # on string-typed receivers whose inputs the
15
+ # ConstantFolding tier could not fold to a precise
16
+ # `Constant<String>` (e.g. one operand is `literal-string`
17
+ # rather than `Constant<String>`, or the multiplication
18
+ # exceeds the constant-fold size cap).
19
+ # - `Array#join` on `Tuple[…]` receivers whose every element
20
+ # plus the separator argument (when given) is
21
+ # literal-bearing.
22
+ # - `Kernel#format` / `Kernel#sprintf` (any receiver) and
23
+ # `String#%` (literal-bearing receiver) when every value
24
+ # argument is literal-bearing or a Type::Constant of any
25
+ # value.
17
26
  #
18
27
  # Result rule:
19
28
  #
@@ -27,6 +36,12 @@ module Rigor
27
36
  # literal-bearing too.
28
37
  # - `*`: receiver MUST be literal-bearing; argument MUST be
29
38
  # integer-typed. The result is `literal-string`.
39
+ # - `join`: receiver MUST be `Tuple[…]` with every element
40
+ # literal-string-compatible; the optional separator
41
+ # argument MUST also be literal-string-compatible.
42
+ # Result: `literal-string`. Empty `Tuple[]` lifts too —
43
+ # `[].join` is the empty string at runtime, which is
44
+ # literal-bearing trivially.
30
45
  #
31
46
  # Other receiver / argument shapes decline so the next tier
32
47
  # (ShapeDispatch / FileFolding / RbsDispatch) takes over and
@@ -36,10 +51,16 @@ module Rigor
36
51
  module_function
37
52
 
38
53
  CONCAT_METHODS = %i[+ << concat].freeze
39
- private_constant :CONCAT_METHODS
54
+ FORMAT_METHODS = %i[format sprintf].freeze
55
+ private_constant :CONCAT_METHODS, :FORMAT_METHODS
56
+
57
+ def try_dispatch(receiver:, method_name:, args:, **) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
58
+ return fold_array_join(receiver, args) if method_name == :join
59
+ return fold_format(args) if FORMAT_METHODS.include?(method_name)
40
60
 
41
- def try_dispatch(receiver:, method_name:, args:, **)
42
61
  return nil unless Type::Combinator.literal_string_compatible?(receiver)
62
+
63
+ return fold_string_percent(args) if method_name == :%
43
64
  return nil unless args.size == 1
44
65
 
45
66
  if CONCAT_METHODS.include?(method_name)
@@ -62,6 +83,69 @@ module Rigor
62
83
  Type::Combinator.literal_string
63
84
  end
64
85
 
86
+ # `[lit, lit].join(sep)` — receiver must be a Tuple
87
+ # whose every element is literal-bearing; separator
88
+ # (when given) must be literal-bearing too. Multi-arg
89
+ # forms / `Array#join(*args)` splat shapes don't reach
90
+ # here because the dispatcher only routes through this
91
+ # tier when the call resolves to a single named method.
92
+ def fold_array_join(receiver, args)
93
+ return nil unless receiver.is_a?(Type::Tuple)
94
+ return nil unless receiver.elements.all? { |el| Type::Combinator.literal_string_compatible?(el) }
95
+ return nil unless args.size <= 1
96
+ return nil if args.size == 1 && !Type::Combinator.literal_string_compatible?(args.first)
97
+
98
+ Type::Combinator.literal_string
99
+ end
100
+
101
+ # `format("hello %s", lit)` / `sprintf(...)` — template
102
+ # plus every value argument must be literal-bearing
103
+ # ({Type::Combinator.literal_string_compatible?}) or a
104
+ # `Type::Constant` of any value (Constants are always
105
+ # provably literal). The template arg specifically must
106
+ # be literal-bearing — a Constant<Integer> first arg
107
+ # would not be a valid format template, so the
108
+ # `Type::Constant` allowance applies only to subsequent
109
+ # value args.
110
+ def fold_format(args)
111
+ return nil if args.empty?
112
+ return nil unless Type::Combinator.literal_string_compatible?(args.first)
113
+ return nil unless args.drop(1).all? { |arg| literal_or_constant?(arg) }
114
+
115
+ Type::Combinator.literal_string
116
+ end
117
+
118
+ # `"foo %s" % "x"` / `"foo %s" % ["x", "y"]` — receiver
119
+ # is the template (already verified literal-bearing by
120
+ # the caller); arg is either:
121
+ #
122
+ # - a single literal-bearing string / Constant value, or
123
+ # - a Tuple whose every element is literal-bearing or a
124
+ # Constant.
125
+ #
126
+ # Hash-form `%` (e.g. `"%{name}" % {name: "x"}`) is not
127
+ # yet folded — the analyzer's HashShape carrier could
128
+ # support this, but the v0.0.x catalogue declines and
129
+ # widens to Nominal[String].
130
+ def fold_string_percent(args)
131
+ return nil unless args.size == 1
132
+
133
+ arg = args.first
134
+ if arg.is_a?(Type::Tuple)
135
+ return nil unless arg.elements.all? { |el| literal_or_constant?(el) }
136
+
137
+ return Type::Combinator.literal_string
138
+ end
139
+
140
+ return nil unless literal_or_constant?(arg)
141
+
142
+ Type::Combinator.literal_string
143
+ end
144
+
145
+ def literal_or_constant?(type)
146
+ Type::Combinator.literal_string_compatible?(type) || type.is_a?(Type::Constant)
147
+ end
148
+
65
149
  def integer_typed?(type)
66
150
  case type
67
151
  when Type::Constant then type.value.is_a?(Integer)
@@ -80,7 +164,9 @@ module Rigor
80
164
  type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.negative?
81
165
  end
82
166
 
83
- private_class_method :fold_concat, :fold_repeat, :integer_typed?
167
+ private_class_method :fold_concat, :fold_repeat, :fold_array_join,
168
+ :fold_format, :fold_string_percent,
169
+ :literal_or_constant?, :integer_typed?
84
170
  end
85
171
  end
86
172
  end
@@ -243,7 +243,13 @@ module Rigor
243
243
 
244
244
  # rubocop:disable Metrics/ParameterLists
245
245
  def translate_return_type(method_definition, class_name:, kind:, args:, type_vars:, block_type:)
246
- override = RbsExtended.read_return_type_override(method_definition)
246
+ # Slice 4b-3 (ADR-7 § "Slice 4-A/4-B") — read the
247
+ # return-type override through the merger so future
248
+ # plugin / `:rbs_extended` bundles that also assert a
249
+ # `return_type` slot at this call site compose with
250
+ # the RBS::Extended directive instead of silently
251
+ # racing it.
252
+ override = merged_return_type(method_definition)
247
253
  return override if override
248
254
 
249
255
  instance_type = Type::Combinator.nominal_of(class_name)
@@ -274,6 +280,20 @@ module Rigor
274
280
  end
275
281
  # rubocop:enable Metrics/ParameterLists
276
282
 
283
+ # ADR-7 § "Slice 4-A/4-B" — folds the
284
+ # `RBS::Extended` `return:` directive (and any
285
+ # other `return_type`-bearing contribution future
286
+ # slices add at this call site) through the merger
287
+ # before consuming. Returns the merged return type
288
+ # or nil when no contribution overrides the
289
+ # RBS-declared return.
290
+ def merged_return_type(method_definition)
291
+ contribution = RbsExtended.read_flow_contribution(method_definition)
292
+ return nil if contribution.nil?
293
+
294
+ Rigor::FlowContribution::Merger.merge([contribution]).return_type
295
+ end
296
+
277
297
  # When a block type is supplied, locate the method-level
278
298
  # type parameter that the selected overload's block return
279
299
  # type references and bind it to `block_type`. The
@@ -131,6 +131,8 @@ module Rigor
131
131
  end
132
132
 
133
133
  def bind_rest_target(splat_node, type, bindings)
134
+ return unless splat_node.is_a?(Prism::SplatNode)
135
+
134
136
  expression = splat_node.expression
135
137
  case expression
136
138
  when Prism::LocalVariableTargetNode, Prism::RequiredParameterNode
@@ -293,6 +293,29 @@ module Rigor
293
293
  end
294
294
  end
295
295
 
296
+ # ADR-7 § "Slice 4-A" — public Fact-shaped narrowing
297
+ # entry. Distinguishes a `Nominal[<class>]`-typed Fact
298
+ # (uses `narrow_class` / `narrow_not_class` for
299
+ # hierarchy-aware narrowing) from a refinement-shaped
300
+ # Fact (refined types, IntegerRange, Difference, …).
301
+ # The implementation lives next to its sibling helpers
302
+ # `narrow_class` and `narrow_not_refinement`; consumers
303
+ # outside `Narrowing` (today: `StatementEvaluator`'s
304
+ # post-return assertion path) reach for it via
305
+ # `Rigor::Inference::Narrowing.narrow_for_fact`.
306
+ def narrow_for_fact(current, fact, environment)
307
+ if fact.type.is_a?(Type::Nominal) && fact.type.type_args.empty?
308
+ class_name = fact.type.class_name
309
+ return narrow_not_class(current, class_name, exact: false, environment: environment) if fact.negative?
310
+
311
+ return narrow_class(current, class_name, exact: false, environment: environment)
312
+ end
313
+
314
+ return narrow_not_refinement(current, fact.type) if fact.negative?
315
+
316
+ fact.type
317
+ end
318
+
296
319
  # Public predicate analyser. Returns `[truthy_scope, falsey_scope]`,
297
320
  # always; when no narrowing rule matches the predicate node both
298
321
  # entries are the receiver scope unchanged.
@@ -352,7 +375,7 @@ module Rigor
352
375
  # the predicate shape is recognised, or `nil` to signal "no
353
376
  # narrowing" so the public surface can fall back to the entry
354
377
  # scope.
355
- def analyse(node, scope)
378
+ def analyse(node, scope) # rubocop:disable Metrics/CyclomaticComplexity
356
379
  case node
357
380
  when Prism::ParenthesesNode
358
381
  analyse_parentheses(node, scope)
@@ -366,6 +389,8 @@ module Rigor
366
389
  analyse_and(node, scope)
367
390
  when Prism::OrNode
368
391
  analyse_or(node, scope)
392
+ when Prism::MatchWriteNode
393
+ analyse_match_write(node, scope)
369
394
  end
370
395
  end
371
396
 
@@ -712,6 +737,29 @@ module Rigor
712
737
  ]
713
738
  end
714
739
 
740
+ # `if /(?<x>...)/ =~ str` — Prism wraps the `=~` call in a
741
+ # `MatchWriteNode` listing the named-capture targets. The
742
+ # parent `eval_match_write` has already bound each target
743
+ # to `String | nil`; in the truthy branch (the regex
744
+ # matched) every named capture is guaranteed `String`,
745
+ # and in the falsey branch (no match) every capture is
746
+ # `nil`. Subtract the dead half on each edge so callers
747
+ # like `year.upcase` inside the truthy branch no longer
748
+ # fire `possible-nil-receiver`.
749
+ def analyse_match_write(node, scope)
750
+ string_t = Type::Combinator.nominal_of("String")
751
+ nil_t = Type::Combinator.constant_of(nil)
752
+ truthy = scope
753
+ falsey = scope
754
+ node.targets.each do |target|
755
+ next unless target.is_a?(Prism::LocalVariableTargetNode)
756
+
757
+ truthy = truthy.with_local(target.name, string_t)
758
+ falsey = falsey.with_local(target.name, nil_t)
759
+ end
760
+ [truthy, falsey]
761
+ end
762
+
715
763
  # Recognised CallNode predicates:
716
764
  # - `recv.nil?` (Slice 6 phase 1, no args, no block)
717
765
  # - unary `!recv` (`name == :!`, no args, no block)
@@ -734,32 +782,7 @@ module Rigor
734
782
  # `rigor:v1:predicate-if-true` / `predicate-if-false`
735
783
  # annotations, apply them to narrow the corresponding
736
784
  # local-variable arguments on each edge.
737
- predicate_result = analyse_rbs_extended_predicate(node, scope)
738
- assert_result = analyse_rbs_extended_assert_if(node, scope)
739
- merge_extended_results(predicate_result, assert_result, scope)
740
- end
741
-
742
- # Combines two `[truthy_scope, falsey_scope]` pair
743
- # results from sibling RBS::Extended analysers
744
- # (`predicate-if-*` and `assert-if-*`). When only one
745
- # side fires, return it directly; when both fire the
746
- # right side's per-local deltas are applied on top of
747
- # the left side's edges so the rules compose.
748
- def merge_extended_results(left, right, base_scope)
749
- return left if right.nil?
750
- return right if left.nil?
751
-
752
- [
753
- merge_scope_pair(left[0], right[0], base_scope),
754
- merge_scope_pair(left[1], right[1], base_scope)
755
- ]
756
- end
757
-
758
- def merge_scope_pair(left_scope, right_scope, base_scope)
759
- right_scope.locals.reduce(left_scope) do |acc, (name, type)|
760
- base_type = base_scope.local(name)
761
- type.equal?(base_type) ? acc : acc.with_local(name, type)
762
- end
785
+ analyse_rbs_extended_contribution(node, scope)
763
786
  end
764
787
 
765
788
  ZERO_CLASS_PREDICATES = %i[positive? negative? zero? nonzero?].freeze
@@ -1187,113 +1210,83 @@ module Rigor
1187
1210
  node
1188
1211
  end
1189
1212
 
1190
- # Slice 7 phase 15RBS::Extended predicate-effect
1191
- # analyser. Resolves the called method through the
1192
- # RBS environment, reads any `rigor:v1:predicate-if-*`
1193
- # annotations, and applies them to the call's
1194
- # local-variable arguments.
1213
+ # Slice 4b-1 (ADR-7 § "Slice 4-A/4-B") single-point
1214
+ # RBS::Extended contribution analyser. Replaces the
1215
+ # earlier sibling pair (`analyse_rbs_extended_predicate`
1216
+ # + `analyse_rbs_extended_assert_if`) with one path that
1217
+ # routes through `RbsExtended.read_flow_contribution` and
1218
+ # `Rigor::FlowContribution::Merger.merge`. The bundle's
1219
+ # `truthy_facts` slot already includes both the
1220
+ # `predicate-if-true` and `assert-if-true` Facts (slice
1221
+ # 4a routing closed the v0.0.9 imperfection); the
1222
+ # `falsey_facts` slot mirrors that. The merger composes
1223
+ # any future plugin contribution at the same site
1224
+ # alongside the RBS::Extended bundle without changing
1225
+ # this analyser.
1195
1226
  #
1196
- # Conservative envelope:
1227
+ # Conservative envelope (carried over from the previous
1228
+ # implementation):
1197
1229
  # - Receiver type must be `Type::Nominal`,
1198
1230
  # `Type::Singleton`, or `Type::Constant`.
1199
1231
  # - The method must be present in the loader.
1200
- # - For each predicate effect, the corresponding
1201
- # positional argument (matched by parameter name in
1202
- # the selected overload) MUST be a
1203
- # `Prism::LocalVariableReadNode` for narrowing to
1204
- # apply.
1205
- # - When the target is `self`, narrowing applies to
1206
- # the receiver but the engine does not yet narrow
1207
- # `self` itself (Slice A-engine self-typing is
1208
- # read-only), so `self`-targeted effects are
1209
- # accepted by the parser but currently produce no
1232
+ # - For each fact, the corresponding positional
1233
+ # argument (matched by parameter name in the selected
1234
+ # overload) MUST be a `Prism::LocalVariableReadNode`
1235
+ # for narrowing to apply.
1236
+ # - When the target is `self`, narrowing applies to the
1237
+ # receiver but the engine does not yet narrow
1238
+ # `self` itself, so `self`-targeted facts are
1239
+ # accepted by the merger but currently produce no
1210
1240
  # scope edits.
1211
- def analyse_rbs_extended_predicate(node, scope)
1241
+ def analyse_rbs_extended_contribution(node, scope)
1212
1242
  method_def = resolve_rbs_extended_method(node, scope)
1213
1243
  return nil if method_def.nil?
1214
1244
 
1215
- effects = RbsExtended.read_predicate_effects(method_def)
1216
- return nil if effects.empty?
1217
-
1218
- truthy_scope = scope
1219
- falsey_scope = scope
1220
- effects.each do |effect|
1221
- truthy_scope, falsey_scope =
1222
- apply_predicate_effect(effect, node, scope, truthy_scope, falsey_scope, method_def)
1223
- end
1224
- [truthy_scope, falsey_scope]
1225
- end
1245
+ contribution = RbsExtended.read_flow_contribution(method_def)
1246
+ return nil if contribution.nil?
1226
1247
 
1227
- # v0.0.2 — `assert-if-true` / `assert-if-false`. Reads
1228
- # the conditional assertion effects off the called
1229
- # method and narrows the matching argument on the
1230
- # corresponding edge. The unconditional `assert`
1231
- # variant is NOT applied here; `StatementEvaluator`
1232
- # applies it directly to the post-call scope.
1233
- def analyse_rbs_extended_assert_if(node, scope)
1234
- method_def = resolve_rbs_extended_method(node, scope)
1235
- return nil if method_def.nil?
1248
+ result = Rigor::FlowContribution::Merger.merge([contribution])
1249
+ truthy_facts = result.truthy_facts
1250
+ falsey_facts = result.falsey_facts
1251
+ return nil if truthy_facts.empty? && falsey_facts.empty?
1236
1252
 
1237
- effects = RbsExtended.read_assert_effects(method_def).reject(&:always?)
1238
- return nil if effects.empty?
1253
+ apply_facts_to_scope_pair(node, scope, truthy_facts, falsey_facts, method_def)
1254
+ end
1239
1255
 
1240
- truthy_scope = scope
1241
- falsey_scope = scope
1242
- effects.each do |effect|
1243
- truthy_scope, falsey_scope =
1244
- apply_assert_if_effect(effect, node, scope, truthy_scope, falsey_scope, method_def)
1256
+ def apply_facts_to_scope_pair(call_node, entry_scope, truthy_facts, falsey_facts, method_def)
1257
+ truthy_scope = entry_scope
1258
+ falsey_scope = entry_scope
1259
+ truthy_facts.each do |fact|
1260
+ truthy_scope = apply_fact_to_scope(fact, call_node, entry_scope, truthy_scope, method_def)
1261
+ end
1262
+ falsey_facts.each do |fact|
1263
+ falsey_scope = apply_fact_to_scope(fact, call_node, entry_scope, falsey_scope, method_def)
1245
1264
  end
1246
1265
  [truthy_scope, falsey_scope]
1247
1266
  end
1248
1267
 
1249
- # rubocop:disable Metrics/ParameterLists
1250
- def apply_assert_if_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
1251
- target_node = effect_target_node(effect, call_node, method_def)
1252
- return [truthy_scope, falsey_scope] unless target_node.is_a?(Prism::LocalVariableReadNode)
1268
+ def apply_fact_to_scope(fact, call_node, entry_scope, target_scope, method_def)
1269
+ target_node = fact_target_node(fact, call_node, method_def)
1270
+ return target_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
1253
1271
 
1254
1272
  local_name = target_node.name
1255
1273
  current = entry_scope.local(local_name)
1256
- return [truthy_scope, falsey_scope] if current.nil?
1274
+ return target_scope if current.nil?
1257
1275
 
1258
- narrowed = narrow_for_effect(current, effect, entry_scope.environment)
1259
- if effect.if_truthy_return?
1260
- [truthy_scope.with_local(local_name, narrowed), falsey_scope]
1261
- else
1262
- [truthy_scope, falsey_scope.with_local(local_name, narrowed)]
1263
- end
1264
- end
1265
- # rubocop:enable Metrics/ParameterLists
1266
-
1267
- # v0.0.2 #3 — resolves an effect's target node. For
1268
- # `target: <param>` we look up the matching positional
1269
- # argument; for `target: self` we use the call's
1270
- # receiver. In both cases the caller still requires a
1271
- # `Prism::LocalVariableReadNode` for narrowing to
1272
- # actually fire (the engine's narrowing surface only
1273
- # rebinds locals).
1274
- def effect_target_node(effect, call_node, method_def)
1275
- if effect.target_kind == :self
1276
- call_node.receiver
1277
- else
1278
- lookup_positional_arg(call_node, method_def, effect.target_name)
1279
- end
1276
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1277
+ target_scope.with_local(local_name, narrowed)
1280
1278
  end
1281
1279
 
1282
- # v0.0.2 selects `narrow_class` (positive) or
1283
- # `narrow_not_class` (negative `~T` form) based on
1284
- # the effect's `negative?` flag. Shared between
1285
- # predicate-if-* and assert-if-* application paths.
1286
- def narrow_for_effect(current, effect, environment)
1287
- if effect.respond_to?(:refinement?) && effect.refinement?
1288
- return narrow_not_refinement(current, effect.refinement_type) if effect.negative?
1289
-
1290
- return effect.refinement_type
1291
- end
1292
-
1293
- if effect.negative?
1294
- narrow_not_class(current, effect.class_name, exact: false, environment: environment)
1280
+ # Resolves a Fact's target node. Mirrors the earlier
1281
+ # `effect_target_node` helper but reads from the
1282
+ # canonical Fact carrier; `:self` routes to the call
1283
+ # receiver, otherwise we look up the matching
1284
+ # positional argument by parameter name.
1285
+ def fact_target_node(fact, call_node, method_def)
1286
+ if fact.target_kind == :self
1287
+ call_node.receiver
1295
1288
  else
1296
- narrow_class(current, effect.class_name, exact: false, environment: environment)
1289
+ lookup_positional_arg(call_node, method_def, fact.target_name)
1297
1290
  end
1298
1291
  end
1299
1292
 
@@ -1332,24 +1325,6 @@ module Rigor
1332
1325
  nil
1333
1326
  end
1334
1327
 
1335
- # rubocop:disable Metrics/ParameterLists
1336
- def apply_predicate_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
1337
- target_node = effect_target_node(effect, call_node, method_def)
1338
- return [truthy_scope, falsey_scope] unless target_node.is_a?(Prism::LocalVariableReadNode)
1339
-
1340
- local_name = target_node.name
1341
- current = entry_scope.local(local_name)
1342
- return [truthy_scope, falsey_scope] if current.nil?
1343
-
1344
- narrowed = narrow_for_effect(current, effect, entry_scope.environment)
1345
- if effect.truthy_only?
1346
- [truthy_scope.with_local(local_name, narrowed), falsey_scope]
1347
- else
1348
- [truthy_scope, falsey_scope.with_local(local_name, narrowed)]
1349
- end
1350
- end
1351
- # rubocop:enable Metrics/ParameterLists
1352
-
1353
1328
  # Maps the effect's target parameter name to the call
1354
1329
  # site argument by inspecting the selected overload's
1355
1330
  # required-positional parameter list. Returns the Prism