rigortype 0.0.8 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -22
  3. data/data/builtins/ruby_core/encoding.yml +210 -0
  4. data/data/builtins/ruby_core/exception.yml +641 -0
  5. data/data/builtins/ruby_core/numeric.yml +3 -2
  6. data/data/builtins/ruby_core/proc.yml +731 -0
  7. data/data/builtins/ruby_core/random.yml +166 -0
  8. data/data/builtins/ruby_core/re.yml +689 -0
  9. data/data/builtins/ruby_core/struct.yml +449 -0
  10. data/lib/rigor/analysis/check_rules.rb +228 -40
  11. data/lib/rigor/analysis/diagnostic.rb +15 -1
  12. data/lib/rigor/analysis/runner.rb +199 -4
  13. data/lib/rigor/builtins/imported_refinements.rb +6 -1
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +63 -0
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +60 -0
  16. data/lib/rigor/cache/rbs_constant_table.rb +15 -51
  17. data/lib/rigor/cache/rbs_descriptor.rb +55 -0
  18. data/lib/rigor/cache/rbs_environment.rb +52 -0
  19. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +40 -0
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  21. data/lib/rigor/cache/rbs_known_class_names.rb +43 -0
  22. data/lib/rigor/cache/store.rb +81 -15
  23. data/lib/rigor/cli.rb +45 -7
  24. data/lib/rigor/configuration/severity_profile.rb +109 -0
  25. data/lib/rigor/configuration.rb +110 -6
  26. data/lib/rigor/environment/rbs_hierarchy.rb +18 -5
  27. data/lib/rigor/environment/rbs_loader.rb +220 -32
  28. data/lib/rigor/environment.rb +11 -2
  29. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  30. data/lib/rigor/flow_contribution/element.rb +53 -0
  31. data/lib/rigor/flow_contribution/fact.rb +88 -0
  32. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  33. data/lib/rigor/flow_contribution/merger.rb +275 -0
  34. data/lib/rigor/flow_contribution.rb +179 -0
  35. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  36. data/lib/rigor/inference/builtins/encoding_catalog.rb +67 -0
  37. data/lib/rigor/inference/builtins/exception_catalog.rb +92 -0
  38. data/lib/rigor/inference/builtins/proc_catalog.rb +122 -0
  39. data/lib/rigor/inference/builtins/random_catalog.rb +58 -0
  40. data/lib/rigor/inference/builtins/re_catalog.rb +81 -0
  41. data/lib/rigor/inference/builtins/struct_catalog.rb +55 -0
  42. data/lib/rigor/inference/expression_typer.rb +110 -6
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -1
  44. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +173 -0
  45. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  46. data/lib/rigor/inference/method_dispatcher.rb +2 -0
  47. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  48. data/lib/rigor/inference/narrowing.rb +134 -144
  49. data/lib/rigor/inference/scope_indexer.rb +75 -1
  50. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  51. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  52. data/lib/rigor/plugin/base.rb +241 -0
  53. data/lib/rigor/plugin/io_boundary.rb +102 -0
  54. data/lib/rigor/plugin/load_error.rb +23 -0
  55. data/lib/rigor/plugin/loader.rb +191 -0
  56. data/lib/rigor/plugin/manifest.rb +134 -0
  57. data/lib/rigor/plugin/registry.rb +50 -0
  58. data/lib/rigor/plugin/services.rb +65 -0
  59. data/lib/rigor/plugin/trust_policy.rb +99 -0
  60. data/lib/rigor/plugin.rb +61 -0
  61. data/lib/rigor/rbs_extended.rb +103 -0
  62. data/lib/rigor/reflection.rb +2 -2
  63. data/lib/rigor/type/combinator.rb +72 -0
  64. data/lib/rigor/type/refined.rb +50 -2
  65. data/lib/rigor/version.rb +1 -1
  66. data/lib/rigor.rb +13 -0
  67. data/sig/rigor/environment.rbs +7 -1
  68. data/sig/rigor/inference.rbs +1 -0
  69. data/sig/rigor/rbs_extended.rbs +2 -0
  70. data/sig/rigor/scope.rbs +1 -0
  71. data/sig/rigor/type.rbs +7 -0
  72. data/sig/rigor.rbs +3 -1
  73. metadata +38 -1
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Dispatcher tier that lifts string-composition results into
9
+ # the `literal-string` carrier when every operand is itself
10
+ # literal-bearing. Sits between {ConstantFolding} (which
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.
26
+ #
27
+ # Result rule:
28
+ #
29
+ # - `+`, `<<`, `concat`: receiver and argument MUST both be
30
+ # `Type::Combinator.literal_string_compatible?`. The result
31
+ # is `literal-string`. `<<` and `concat` mutate the
32
+ # receiver at runtime; the analyzer does not track that
33
+ # mutation against the local's binding, but the call's
34
+ # *return value* is the receiver itself, and the receiver
35
+ # stays literal-bearing because every appended slice was
36
+ # literal-bearing too.
37
+ # - `*`: receiver MUST be literal-bearing; argument MUST be
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.
45
+ #
46
+ # Other receiver / argument shapes decline so the next tier
47
+ # (ShapeDispatch / FileFolding / RbsDispatch) takes over and
48
+ # the call site widens to the RBS-declared `Nominal[String]`
49
+ # as before.
50
+ module LiteralStringFolding
51
+ module_function
52
+
53
+ CONCAT_METHODS = %i[+ << concat].freeze
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)
60
+
61
+ return nil unless Type::Combinator.literal_string_compatible?(receiver)
62
+
63
+ return fold_string_percent(args) if method_name == :%
64
+ return nil unless args.size == 1
65
+
66
+ if CONCAT_METHODS.include?(method_name)
67
+ fold_concat(args.first)
68
+ elsif method_name == :*
69
+ fold_repeat(args.first)
70
+ end
71
+ end
72
+
73
+ def fold_concat(arg_type)
74
+ return nil unless Type::Combinator.literal_string_compatible?(arg_type)
75
+
76
+ Type::Combinator.literal_string
77
+ end
78
+
79
+ def fold_repeat(arg_type)
80
+ return nil unless integer_typed?(arg_type)
81
+ return nil if known_negative_integer?(arg_type)
82
+
83
+ Type::Combinator.literal_string
84
+ end
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
+
149
+ def integer_typed?(type)
150
+ case type
151
+ when Type::Constant then type.value.is_a?(Integer)
152
+ when Type::Nominal then type.class_name == "Integer"
153
+ when Type::IntegerRange then true
154
+ else false
155
+ end
156
+ end
157
+
158
+ # `String#*` raises ArgumentError on a negative multiplier, so a
159
+ # `Constant<-1>` argument is not a valid lift target. Decline so
160
+ # the call site keeps the existing nil-result behaviour rather
161
+ # than promising a `literal-string` value that could never
162
+ # exist at runtime.
163
+ def known_negative_integer?(type)
164
+ type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.negative?
165
+ end
166
+
167
+ private_class_method :fold_concat, :fold_repeat, :fold_array_join,
168
+ :fold_format, :fold_string_percent,
169
+ :literal_or_constant?, :integer_typed?
170
+ end
171
+ end
172
+ end
173
+ 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
@@ -3,6 +3,7 @@
3
3
  require_relative "../reflection"
4
4
  require_relative "../type"
5
5
  require_relative "method_dispatcher/constant_folding"
6
+ require_relative "method_dispatcher/literal_string_folding"
6
7
  require_relative "method_dispatcher/shape_dispatch"
7
8
  require_relative "method_dispatcher/rbs_dispatch"
8
9
  require_relative "method_dispatcher/iterator_dispatch"
@@ -101,6 +102,7 @@ module Rigor
101
102
  return meta_result if meta_result
102
103
 
103
104
  ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
105
+ LiteralStringFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
104
106
  ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
105
107
  FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
106
108
  KernelDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
@@ -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
 
@@ -539,26 +564,41 @@ module Rigor
539
564
  # downstream narrowing knows the refinement subset is
540
565
  # excluded.
541
566
  #
542
- # The result is sound but imprecise: without a
543
- # complementary carrier (e.g. `mixed-case-string` for
544
- # `~lowercase-string`) we cannot enumerate the surviving
545
- # values. Difference is the carrier-of-last-resort, and
546
- # the existing `Type::Difference` consumers already
547
- # treat it correctly.
567
+ # v0.0.9 when the predicate has a registered
568
+ # complement (see {Type::Refined::COMPLEMENT_PAIRS}) and
569
+ # the part is exactly the refinement's base, the
570
+ # narrowing returns `Refined[base, complement_predicate]`
571
+ # instead of `Difference[base, refined]`. This is the
572
+ # `~T` symmetry the spec promises: `~lowercase-string`
573
+ # narrows `String` to `non-lowercase-string` rather than
574
+ # `Difference[String, lowercase-string]`.
575
+ #
576
+ # Predicates without a registered complement still fall
577
+ # back to the imprecise but sound `Difference[part,
578
+ # refined]` carrier so behaviour is unchanged for
579
+ # untouched call sites.
548
580
  def complement_refined(current_type, refined)
549
- base = refined.base
581
+ complement = registered_complement_for(refined)
550
582
  parts = current_type.is_a?(Type::Union) ? current_type.members : [current_type]
583
+ survivors = parts.filter_map { |part| complement_refined_part(part, refined, complement) }
584
+ return current_type if survivors.empty?
585
+
586
+ Type::Combinator.union(*survivors)
587
+ end
551
588
 
552
- survivors = parts.map do |part|
553
- next nil if part == refined
554
- next part if base_disjoint?(base, part)
589
+ def complement_refined_part(part, refined, complement)
590
+ return nil if part == refined
591
+ return part if base_disjoint?(refined.base, part)
592
+ return complement if complement && part == refined.base
555
593
 
556
- Type::Combinator.difference(part, refined)
557
- end.compact
594
+ Type::Combinator.difference(part, refined)
595
+ end
558
596
 
559
- return current_type if survivors.empty?
597
+ def registered_complement_for(refined)
598
+ complement_id = refined.complement_predicate_id
599
+ return nil if complement_id.nil?
560
600
 
561
- Type::Combinator.union(*survivors)
601
+ Type::Combinator.refined(refined.base, complement_id)
562
602
  end
563
603
 
564
604
  def falsey_value?(value)
@@ -697,6 +737,29 @@ module Rigor
697
737
  ]
698
738
  end
699
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
+
700
763
  # Recognised CallNode predicates:
701
764
  # - `recv.nil?` (Slice 6 phase 1, no args, no block)
702
765
  # - unary `!recv` (`name == :!`, no args, no block)
@@ -719,32 +782,7 @@ module Rigor
719
782
  # `rigor:v1:predicate-if-true` / `predicate-if-false`
720
783
  # annotations, apply them to narrow the corresponding
721
784
  # local-variable arguments on each edge.
722
- predicate_result = analyse_rbs_extended_predicate(node, scope)
723
- assert_result = analyse_rbs_extended_assert_if(node, scope)
724
- merge_extended_results(predicate_result, assert_result, scope)
725
- end
726
-
727
- # Combines two `[truthy_scope, falsey_scope]` pair
728
- # results from sibling RBS::Extended analysers
729
- # (`predicate-if-*` and `assert-if-*`). When only one
730
- # side fires, return it directly; when both fire the
731
- # right side's per-local deltas are applied on top of
732
- # the left side's edges so the rules compose.
733
- def merge_extended_results(left, right, base_scope)
734
- return left if right.nil?
735
- return right if left.nil?
736
-
737
- [
738
- merge_scope_pair(left[0], right[0], base_scope),
739
- merge_scope_pair(left[1], right[1], base_scope)
740
- ]
741
- end
742
-
743
- def merge_scope_pair(left_scope, right_scope, base_scope)
744
- right_scope.locals.reduce(left_scope) do |acc, (name, type)|
745
- base_type = base_scope.local(name)
746
- type.equal?(base_type) ? acc : acc.with_local(name, type)
747
- end
785
+ analyse_rbs_extended_contribution(node, scope)
748
786
  end
749
787
 
750
788
  ZERO_CLASS_PREDICATES = %i[positive? negative? zero? nonzero?].freeze
@@ -1172,113 +1210,83 @@ module Rigor
1172
1210
  node
1173
1211
  end
1174
1212
 
1175
- # Slice 7 phase 15RBS::Extended predicate-effect
1176
- # analyser. Resolves the called method through the
1177
- # RBS environment, reads any `rigor:v1:predicate-if-*`
1178
- # annotations, and applies them to the call's
1179
- # 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.
1180
1226
  #
1181
- # Conservative envelope:
1227
+ # Conservative envelope (carried over from the previous
1228
+ # implementation):
1182
1229
  # - Receiver type must be `Type::Nominal`,
1183
1230
  # `Type::Singleton`, or `Type::Constant`.
1184
1231
  # - The method must be present in the loader.
1185
- # - For each predicate effect, the corresponding
1186
- # positional argument (matched by parameter name in
1187
- # the selected overload) MUST be a
1188
- # `Prism::LocalVariableReadNode` for narrowing to
1189
- # apply.
1190
- # - When the target is `self`, narrowing applies to
1191
- # the receiver but the engine does not yet narrow
1192
- # `self` itself (Slice A-engine self-typing is
1193
- # read-only), so `self`-targeted effects are
1194
- # 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
1195
1240
  # scope edits.
1196
- def analyse_rbs_extended_predicate(node, scope)
1241
+ def analyse_rbs_extended_contribution(node, scope)
1197
1242
  method_def = resolve_rbs_extended_method(node, scope)
1198
1243
  return nil if method_def.nil?
1199
1244
 
1200
- effects = RbsExtended.read_predicate_effects(method_def)
1201
- return nil if effects.empty?
1245
+ contribution = RbsExtended.read_flow_contribution(method_def)
1246
+ return nil if contribution.nil?
1202
1247
 
1203
- truthy_scope = scope
1204
- falsey_scope = scope
1205
- effects.each do |effect|
1206
- truthy_scope, falsey_scope =
1207
- apply_predicate_effect(effect, node, scope, truthy_scope, falsey_scope, method_def)
1208
- end
1209
- [truthy_scope, falsey_scope]
1210
- end
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?
1211
1252
 
1212
- # v0.0.2 `assert-if-true` / `assert-if-false`. Reads
1213
- # the conditional assertion effects off the called
1214
- # method and narrows the matching argument on the
1215
- # corresponding edge. The unconditional `assert`
1216
- # variant is NOT applied here; `StatementEvaluator`
1217
- # applies it directly to the post-call scope.
1218
- def analyse_rbs_extended_assert_if(node, scope)
1219
- method_def = resolve_rbs_extended_method(node, scope)
1220
- return nil if method_def.nil?
1221
-
1222
- effects = RbsExtended.read_assert_effects(method_def).reject(&:always?)
1223
- return nil if effects.empty?
1253
+ apply_facts_to_scope_pair(node, scope, truthy_facts, falsey_facts, method_def)
1254
+ end
1224
1255
 
1225
- truthy_scope = scope
1226
- falsey_scope = scope
1227
- effects.each do |effect|
1228
- truthy_scope, falsey_scope =
1229
- 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)
1230
1264
  end
1231
1265
  [truthy_scope, falsey_scope]
1232
1266
  end
1233
1267
 
1234
- # rubocop:disable Metrics/ParameterLists
1235
- def apply_assert_if_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
1236
- target_node = effect_target_node(effect, call_node, method_def)
1237
- 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)
1238
1271
 
1239
1272
  local_name = target_node.name
1240
1273
  current = entry_scope.local(local_name)
1241
- return [truthy_scope, falsey_scope] if current.nil?
1274
+ return target_scope if current.nil?
1242
1275
 
1243
- narrowed = narrow_for_effect(current, effect, entry_scope.environment)
1244
- if effect.if_truthy_return?
1245
- [truthy_scope.with_local(local_name, narrowed), falsey_scope]
1246
- else
1247
- [truthy_scope, falsey_scope.with_local(local_name, narrowed)]
1248
- end
1249
- end
1250
- # rubocop:enable Metrics/ParameterLists
1251
-
1252
- # v0.0.2 #3 — resolves an effect's target node. For
1253
- # `target: <param>` we look up the matching positional
1254
- # argument; for `target: self` we use the call's
1255
- # receiver. In both cases the caller still requires a
1256
- # `Prism::LocalVariableReadNode` for narrowing to
1257
- # actually fire (the engine's narrowing surface only
1258
- # rebinds locals).
1259
- def effect_target_node(effect, call_node, method_def)
1260
- if effect.target_kind == :self
1261
- call_node.receiver
1262
- else
1263
- lookup_positional_arg(call_node, method_def, effect.target_name)
1264
- end
1276
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1277
+ target_scope.with_local(local_name, narrowed)
1265
1278
  end
1266
1279
 
1267
- # v0.0.2 selects `narrow_class` (positive) or
1268
- # `narrow_not_class` (negative `~T` form) based on
1269
- # the effect's `negative?` flag. Shared between
1270
- # predicate-if-* and assert-if-* application paths.
1271
- def narrow_for_effect(current, effect, environment)
1272
- if effect.respond_to?(:refinement?) && effect.refinement?
1273
- return narrow_not_refinement(current, effect.refinement_type) if effect.negative?
1274
-
1275
- return effect.refinement_type
1276
- end
1277
-
1278
- if effect.negative?
1279
- 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
1280
1288
  else
1281
- narrow_class(current, effect.class_name, exact: false, environment: environment)
1289
+ lookup_positional_arg(call_node, method_def, fact.target_name)
1282
1290
  end
1283
1291
  end
1284
1292
 
@@ -1317,24 +1325,6 @@ module Rigor
1317
1325
  nil
1318
1326
  end
1319
1327
 
1320
- # rubocop:disable Metrics/ParameterLists
1321
- def apply_predicate_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
1322
- target_node = effect_target_node(effect, call_node, method_def)
1323
- return [truthy_scope, falsey_scope] unless target_node.is_a?(Prism::LocalVariableReadNode)
1324
-
1325
- local_name = target_node.name
1326
- current = entry_scope.local(local_name)
1327
- return [truthy_scope, falsey_scope] if current.nil?
1328
-
1329
- narrowed = narrow_for_effect(current, effect, entry_scope.environment)
1330
- if effect.truthy_only?
1331
- [truthy_scope.with_local(local_name, narrowed), falsey_scope]
1332
- else
1333
- [truthy_scope, falsey_scope.with_local(local_name, narrowed)]
1334
- end
1335
- end
1336
- # rubocop:enable Metrics/ParameterLists
1337
-
1338
1328
  # Maps the effect's target parameter name to the call
1339
1329
  # site argument by inspecting the selected overload's
1340
1330
  # required-positional parameter list. Returns the Prism