rigortype 0.1.0 → 0.1.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/time.yml +3 -3
  10. data/lib/rigor/analysis/runner.rb +88 -5
  11. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  12. data/lib/rigor/cli/type_of_command.rb +3 -3
  13. data/lib/rigor/cli/type_scan_command.rb +4 -4
  14. data/lib/rigor/cli.rb +11 -4
  15. data/lib/rigor/configuration.rb +177 -10
  16. data/lib/rigor/environment.rb +12 -4
  17. data/lib/rigor/inference/expression_typer.rb +3 -1
  18. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  19. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
  20. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  21. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  22. data/lib/rigor/inference/narrowing.rb +150 -6
  23. data/lib/rigor/inference/scope_indexer.rb +49 -15
  24. data/lib/rigor/inference/statement_evaluator.rb +29 -0
  25. data/lib/rigor/plugin/base.rb +43 -0
  26. data/lib/rigor/plugin/fact_store.rb +92 -0
  27. data/lib/rigor/plugin/load_error.rb +14 -2
  28. data/lib/rigor/plugin/loader.rb +116 -0
  29. data/lib/rigor/plugin/manifest.rb +75 -6
  30. data/lib/rigor/plugin/services.rb +14 -2
  31. data/lib/rigor/plugin.rb +1 -0
  32. data/lib/rigor/trinary.rb +1 -1
  33. data/lib/rigor/type/integer_range.rb +6 -2
  34. data/lib/rigor/version.rb +1 -1
  35. data/sig/rigor/environment.rbs +3 -2
  36. data/sig/rigor.rbs +8 -2
  37. metadata +3 -1
@@ -42,14 +42,45 @@ module Rigor
42
42
  }.freeze
43
43
  private_constant :NUMERIC_CONSTRUCTORS
44
44
 
45
+ # `Kernel#Integer(s)` predicate-aware refinement set
46
+ # (v0.1.1 Track 1 slice 2b). Both `decimal-int-string` and
47
+ # `numeric-string` describe digit-only ASCII strings, so
48
+ # `Integer(s)` is total over the carrier domain and the
49
+ # result is `>= 0`. The default `base: 10` invocation
50
+ # accepts the same shape `String#to_i` does for these
51
+ # predicates; the `Integer(s, base)` overload is left for
52
+ # a later slice.
53
+ INTEGER_REFINEMENT_PREDICATES = Set[:decimal_int, :numeric].freeze
54
+ private_constant :INTEGER_REFINEMENT_PREDICATES
55
+
45
56
  def try_dispatch(receiver:, method_name:, args:)
46
57
  return nil if receiver.nil?
47
58
  return try_array(args) if method_name == :Array
48
59
  return try_numeric_constructor(method_name, args) if NUMERIC_CONSTRUCTORS.key?(method_name)
60
+ return try_integer_from_refinement(args) if method_name == :Integer
49
61
 
50
62
  nil
51
63
  end
52
64
 
65
+ # `Kernel#Integer(s)` over a `Refined[String, predicate]`
66
+ # whose predicate is in {INTEGER_REFINEMENT_PREDICATES}.
67
+ # Mirrors the `String#to_i` projection in `ShapeDispatch`
68
+ # (v0.1.1 slice 2a) — the result is always
69
+ # `non-negative-int`. Returns nil for any other arg shape
70
+ # so the RBS tier handles the generic `Integer(arg)` case.
71
+ def try_integer_from_refinement(args)
72
+ return nil unless args.size == 1
73
+
74
+ arg = args.first
75
+ return nil unless arg.is_a?(Type::Refined)
76
+
77
+ base = arg.base
78
+ return nil unless base.is_a?(Type::Nominal) && base.class_name == "String"
79
+ return nil unless INTEGER_REFINEMENT_PREDICATES.include?(arg.predicate_id)
80
+
81
+ Type::Combinator.non_negative_int
82
+ end
83
+
53
84
  def try_array(args)
54
85
  return nil if args.length != 1
55
86
 
@@ -52,7 +52,27 @@ module Rigor
52
52
 
53
53
  CONCAT_METHODS = %i[+ << concat].freeze
54
54
  FORMAT_METHODS = %i[format sprintf].freeze
55
- private_constant :CONCAT_METHODS, :FORMAT_METHODS
55
+ # v0.1.1 Track 1 slice 5a — methods that, called with no
56
+ # arguments on a literal-bearing receiver, return a value
57
+ # that is also literal-bearing. `#strip` / `#lstrip` /
58
+ # `#rstrip` / `#chomp` (no-arg) / `#chop` strip a known
59
+ # subset of characters from the ends, so the survivors
60
+ # are always a substring of an already-literal value.
61
+ # `#scrub` (no-arg) replaces invalid bytes; a literal-string
62
+ # value comes from source code and is always valid UTF-8,
63
+ # so the result is identical to the receiver. None of
64
+ # these preserve `non-empty-string`-ness (e.g. `" ".strip
65
+ # == ""`); the carrier collapses from `non-empty-literal-string`
66
+ # down to plain `literal-string`.
67
+ LITERAL_PRESERVING_METHODS = %i[strip lstrip rstrip chomp chop scrub].freeze
68
+ # v0.1.1 Track 1 slice 5c — width-padding methods. `center`
69
+ # / `ljust` / `rjust` take a `width` Integer plus an
70
+ # optional literal padding `String`. When the receiver
71
+ # and the (default or supplied) padding are both
72
+ # literal-bearing, the result is literal-bearing too.
73
+ WIDTH_PADDING_METHODS = %i[center ljust rjust].freeze
74
+ private_constant :CONCAT_METHODS, :FORMAT_METHODS,
75
+ :LITERAL_PRESERVING_METHODS, :WIDTH_PADDING_METHODS
56
76
 
57
77
  def try_dispatch(receiver:, method_name:, args:, **) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
58
78
  return fold_array_join(receiver, args) if method_name == :join
@@ -61,6 +81,10 @@ module Rigor
61
81
  return nil unless Type::Combinator.literal_string_compatible?(receiver)
62
82
 
63
83
  return fold_string_percent(args) if method_name == :%
84
+ if args.empty?
85
+ return LITERAL_PRESERVING_METHODS.include?(method_name) ? Type::Combinator.literal_string : nil
86
+ end
87
+ return fold_width_pad(args) if WIDTH_PADDING_METHODS.include?(method_name)
64
88
  return nil unless args.size == 1
65
89
 
66
90
  if CONCAT_METHODS.include?(method_name)
@@ -70,6 +94,23 @@ module Rigor
70
94
  end
71
95
  end
72
96
 
97
+ # `String#center` / `#ljust` / `#rjust` — first argument is
98
+ # the target width (Integer-typed), optional second
99
+ # argument is the padding string (must be literal-bearing
100
+ # for the result to stay literal). The default padding
101
+ # (a space) is always literal so the no-second-arg form
102
+ # passes through. Width is allowed to be any Integer
103
+ # because Ruby's runtime accepts negative widths and
104
+ # widths smaller than the receiver's length without
105
+ # raising.
106
+ def fold_width_pad(args)
107
+ return nil unless [1, 2].include?(args.size)
108
+ return nil unless integer_typed?(args[0])
109
+ return nil if args.size == 2 && !Type::Combinator.literal_string_compatible?(args[1])
110
+
111
+ Type::Combinator.literal_string
112
+ end
113
+
73
114
  def fold_concat(arg_type)
74
115
  return nil unless Type::Combinator.literal_string_compatible?(arg_type)
75
116
 
@@ -165,7 +206,7 @@ module Rigor
165
206
  end
166
207
 
167
208
  private_class_method :fold_concat, :fold_repeat, :fold_array_join,
168
- :fold_format, :fold_string_percent,
209
+ :fold_format, :fold_string_percent, :fold_width_pad,
169
210
  :literal_or_constant?, :integer_typed?
170
211
  end
171
212
  end
@@ -122,10 +122,25 @@ module Rigor
122
122
  Type::Nominal => :dispatch_nominal_size,
123
123
  Type::Difference => :dispatch_difference,
124
124
  Type::Refined => :dispatch_refined,
125
- Type::Intersection => :dispatch_intersection
125
+ Type::Intersection => :dispatch_intersection,
126
+ Type::IntegerRange => :dispatch_integer_range
126
127
  }.freeze
127
128
  private_constant :RECEIVER_HANDLERS
128
129
 
130
+ # v0.1.1 Track 1 slice 5b — `Integer#to_s(base)` on a
131
+ # non-negative `IntegerRange` receiver. The output of
132
+ # `n.to_s(b)` for `n >= 0` is digit-string-only (no
133
+ # leading sign), so when the base is in this table the
134
+ # result lifts to the matching imported refinement.
135
+ # Bases not listed (2, 36, ...) keep the v0.1.0 baseline
136
+ # since Rigor has no carrier for the resulting alphabet.
137
+ TO_S_BASE_REFINEMENTS = {
138
+ 10 => :decimal_int_string,
139
+ 8 => :octal_int_string,
140
+ 16 => :hex_int_string
141
+ }.freeze
142
+ private_constant :TO_S_BASE_REFINEMENTS
143
+
129
144
  def try_dispatch(receiver:, method_name:, args:)
130
145
  args ||= []
131
146
  handler = RECEIVER_HANDLERS[receiver.class]
@@ -184,6 +199,42 @@ module Rigor
184
199
  Type::Combinator.non_negative_int
185
200
  end
186
201
 
202
+ # `IntegerRange#to_s` precision (v0.1.1 Track 1 slice 5b).
203
+ # When the range's lower bound is `>= 0`, every member is
204
+ # a non-negative integer and `to_s(base)` returns a
205
+ # digit-string with no leading sign. The result lifts to
206
+ # the matching imported refinement (`decimal-int-string`
207
+ # for base 10, `octal-int-string` for 8, `hex-int-string`
208
+ # for 16). Signed ranges fall through (the result could
209
+ # carry a `-` sign that no Rigor refinement currently
210
+ # captures), as do bases without a digit-only refinement.
211
+ def dispatch_integer_range(range, method_name, args)
212
+ return nil unless method_name == :to_s
213
+ return nil unless range.lower >= 0
214
+
215
+ base = base_argument(args)
216
+ return nil if base.nil?
217
+
218
+ refinement = TO_S_BASE_REFINEMENTS[base]
219
+ return nil if refinement.nil?
220
+
221
+ Type::Combinator.public_send(refinement)
222
+ end
223
+
224
+ # `to_s` with no argument defaults to base 10. With one
225
+ # argument, the value MUST be a `Constant<Integer>` to
226
+ # be statically known. Anything else (Nominal[Integer]
227
+ # arg, multi-arg, etc.) declines.
228
+ def base_argument(args)
229
+ return 10 if args.empty?
230
+ return nil unless args.size == 1
231
+
232
+ arg = args.first
233
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Integer)
234
+
235
+ arg.value
236
+ end
237
+
187
238
  # Refinement-aware projections over a `Difference[base,
188
239
  # removed]` receiver. When the removed value is the
189
240
  # empty witness of the base (`Constant[""]` for
@@ -289,7 +340,21 @@ module Rigor
289
340
  %i[octal_int downcase] => :refined_self,
290
341
  %i[octal_int upcase] => :refined_self,
291
342
  %i[hex_int downcase] => :refined_self,
292
- %i[hex_int upcase] => :refined_self
343
+ %i[hex_int upcase] => :refined_self,
344
+ # v0.1.1 Track 1 slice 2 — `to_i` / `to_int` on a
345
+ # known digit-only string. `decimal-int-string`
346
+ # (`/\A\d+\z/`) and `numeric-string` (Rigor's
347
+ # numeric-string predicate, ASCII digits) are
348
+ # predicates over digit-only strings, so the parse
349
+ # is total over the carrier domain and the result
350
+ # is always `>= 0`. `non-negative-int` is the
351
+ # tightest carrier that captures both the lower
352
+ # bound and the integer-ness without inventing a
353
+ # narrower carrier.
354
+ %i[decimal_int to_i] => :non_negative_int,
355
+ %i[decimal_int to_int] => :non_negative_int,
356
+ %i[numeric to_i] => :non_negative_int,
357
+ %i[numeric to_int] => :non_negative_int
293
358
  }.freeze
294
359
  private_constant :REFINED_STRING_PROJECTIONS
295
360
 
@@ -313,6 +378,7 @@ module Rigor
313
378
  when :refined_self then refined
314
379
  when :uppercase_string then Type::Combinator.uppercase_string
315
380
  when :lowercase_string then Type::Combinator.lowercase_string
381
+ when :non_negative_int then Type::Combinator.non_negative_int
316
382
  end
317
383
  end
318
384
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "../reflection"
4
4
  require_relative "../type"
5
+ require_relative "../flow_contribution"
6
+ require_relative "../flow_contribution/merger"
5
7
  require_relative "method_dispatcher/constant_folding"
6
8
  require_relative "method_dispatcher/literal_string_folding"
7
9
  require_relative "method_dispatcher/shape_dispatch"
@@ -59,12 +61,25 @@ module Rigor
59
61
  # @param environment [Rigor::Environment, nil] required for
60
62
  # RBS-backed dispatch; when nil only constant folding can fire.
61
63
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
62
- def dispatch(receiver_type:, method_name:, arg_types:, block_type: nil, environment: nil)
64
+ def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/ParameterLists
65
+ block_type: nil, environment: nil,
66
+ call_node: nil, scope: nil)
63
67
  return nil if receiver_type.nil?
64
68
 
65
69
  precise = dispatch_precise_tiers(receiver_type, method_name, arg_types, block_type)
66
70
  return precise if precise
67
71
 
72
+ # v0.1.1 Track 2 slice 7 — plugin return-type contribution
73
+ # tier. Sits ahead of `RbsDispatch` so a plugin that
74
+ # understands a domain-specific dispatch (e.g. an
75
+ # `ActiveRecord::Base.find` returning `Nominal[<resolved
76
+ # model>]`) wins over the RBS-projected envelope. Only
77
+ # consults the registry when both `call_node` and `scope`
78
+ # are supplied — the dispatcher's own internal callers
79
+ # (per-element block fold, etc.) skip this tier.
80
+ plugin_result = try_plugin_contribution(call_node, scope)
81
+ return plugin_result if plugin_result
82
+
68
83
  rbs_result = RbsDispatch.try_dispatch(
69
84
  receiver: receiver_type, method_name: method_name, args: arg_types,
70
85
  environment: environment, block_type: block_type
@@ -85,6 +100,40 @@ module Rigor
85
100
  try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
86
101
  end
87
102
 
103
+ # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
104
+ # slice 7. Walks every loaded plugin's
105
+ # `#flow_contribution_for(call_node:, scope:)` hook,
106
+ # collects the non-nil `FlowContribution` bundles, merges
107
+ # them through `FlowContribution::Merger`, and returns
108
+ # the merged `return_type` slot (or nil when no plugin
109
+ # contributed a return type).
110
+ #
111
+ # Plugins whose hook raises have their contribution
112
+ # silently dropped for this call so the dispatch chain
113
+ # keeps moving — the run-level diagnostic envelope (per
114
+ # ADR-2 § "Plugin Trust and I/O Policy") is owned by
115
+ # `Analysis::Runner#plugin_emitted_diagnostics`.
116
+ def try_plugin_contribution(call_node, scope)
117
+ return nil if call_node.nil? || scope.nil?
118
+
119
+ registry = scope.environment&.plugin_registry
120
+ return nil if registry.nil? || registry.empty?
121
+
122
+ contributions = collect_plugin_contributions(registry, call_node, scope)
123
+ return nil if contributions.empty?
124
+
125
+ FlowContribution::Merger.merge(contributions).return_type
126
+ end
127
+
128
+ def collect_plugin_contributions(registry, call_node, scope)
129
+ registry.plugins.filter_map do |plugin|
130
+ contribution = plugin.flow_contribution_for(call_node: call_node, scope: scope)
131
+ contribution.is_a?(FlowContribution) ? contribution : nil
132
+ rescue StandardError
133
+ nil
134
+ end
135
+ end
136
+
88
137
  # Runs the precision tiers (constant fold, shape dispatch,
89
138
  # file-path fold, block fold) in order and returns the first
90
139
  # non-nil answer. Each tier owns its own receiver/argument
@@ -7,6 +7,7 @@ require_relative "../type"
7
7
  require_relative "../environment"
8
8
  require_relative "../rbs_extended"
9
9
  require_relative "../analysis/fact_store"
10
+ require_relative "../builtins/regex_refinement"
10
11
 
11
12
  module Rigor
12
13
  module Inference
@@ -746,20 +747,51 @@ module Rigor
746
747
  # `nil`. Subtract the dead half on each edge so callers
747
748
  # like `year.upcase` inside the truthy branch no longer
748
749
  # fire `possible-nil-receiver`.
750
+ #
751
+ # v0.1.1 Track 1 slice 1 — when the regex source is a
752
+ # statically known `RegularExpressionNode` and a named
753
+ # capture's body matches one of the curated shapes in
754
+ # {Rigor::Builtins::RegexRefinement::RULES}, the truthy
755
+ # branch narrows further than `String` to the matching
756
+ # imported refinement (e.g. `decimal-int-string` for
757
+ # `\d+`). Bodies outside the table fall back to the
758
+ # v0.1.0 baseline (plain `String`).
749
759
  def analyse_match_write(node, scope)
750
760
  string_t = Type::Combinator.nominal_of("String")
751
761
  nil_t = Type::Combinator.constant_of(nil)
762
+ refinements = match_write_capture_refinements(node)
752
763
  truthy = scope
753
764
  falsey = scope
754
765
  node.targets.each do |target|
755
766
  next unless target.is_a?(Prism::LocalVariableTargetNode)
756
767
 
757
- truthy = truthy.with_local(target.name, string_t)
768
+ truthy = truthy.with_local(target.name, refinements[target.name] || string_t)
758
769
  falsey = falsey.with_local(target.name, nil_t)
759
770
  end
760
771
  [truthy, falsey]
761
772
  end
762
773
 
774
+ # Extracts `{ capture_name => Refinement }` for every named
775
+ # capture group in the `MatchWriteNode`'s wrapped `=~` call
776
+ # whose body the recogniser table accepts. Bodies that
777
+ # contain nested groups, anchors, alternation, or anything
778
+ # else outside the curated forms drop out and the caller
779
+ # falls back to plain `String`.
780
+ NAMED_CAPTURE_BODY_RE = /\(\?<([A-Za-z_][A-Za-z0-9_]*)>([^()|]*)\)/
781
+ private_constant :NAMED_CAPTURE_BODY_RE
782
+
783
+ def match_write_capture_refinements(node)
784
+ regex = node.call.is_a?(Prism::CallNode) ? node.call.receiver : nil
785
+ return {} unless regex.is_a?(Prism::RegularExpressionNode)
786
+
787
+ refinements = {}
788
+ regex.unescaped.scan(NAMED_CAPTURE_BODY_RE) do |name, body|
789
+ type = ::Rigor::Builtins::RegexRefinement.for_capture_body(body)
790
+ refinements[name.to_sym] = type if type
791
+ end
792
+ refinements
793
+ end
794
+
763
795
  # Recognised CallNode predicates:
764
796
  # - `recv.nil?` (Slice 6 phase 1, no args, no block)
765
797
  # - unary `!recv` (`name == :!`, no args, no block)
@@ -772,19 +804,82 @@ module Rigor
772
804
  # through to the no-narrowing fallback.
773
805
  def analyse_call(node, scope)
774
806
  return nil if node.block
775
- return nil if node.receiver.nil?
776
807
 
777
- shape_result = dispatch_call(node, scope, node.name)
778
- return shape_result if shape_result
808
+ unless node.receiver.nil?
809
+ shape_result = dispatch_call(node, scope, node.name)
810
+ return shape_result if shape_result
811
+
812
+ # v0.1.1 Track 1 slice 4 — String predicate flow facts.
813
+ string_predicate_result = analyse_string_predicate(node, scope)
814
+ return string_predicate_result if string_predicate_result
815
+ end
779
816
 
780
817
  # Slice 7 phase 15 — RBS::Extended predicate
781
818
  # effects. When the method's RBS signature carries
782
819
  # `rigor:v1:predicate-if-true` / `predicate-if-false`
783
820
  # annotations, apply them to narrow the corresponding
784
- # local-variable arguments on each edge.
821
+ # local-variable arguments on each edge. v0.1.1 Track
822
+ # 1 slice 3 — implicit-self calls (`recv == nil`,
823
+ # e.g. `admin?` inside an instance method body) flow
824
+ # through here too so `self`-targeted facts can edit
825
+ # `scope.self_type`.
785
826
  analyse_rbs_extended_contribution(node, scope)
786
827
  end
787
828
 
829
+ # v0.1.1 Track 1 slice 4 — `String#start_with?` /
830
+ # `#end_with?` / `#include?` against a `Constant<String>`
831
+ # needle attaches a relational `FactStore::Fact` to the
832
+ # local on each edge. The receiver's type does NOT
833
+ # change — Rigor has no "starts-with-X" carrier today —
834
+ # so the fact carries the predicate semantics for any
835
+ # downstream consumer that wants to read it (e.g. a
836
+ # plugin's `prepare(services)` hook in v0.1.x).
837
+ # Truthy edge: positive polarity. Falsey edge: negative
838
+ # polarity. Mirrors the equality-predicate fact pattern
839
+ # already used by `analyse_equality_predicate`.
840
+ STRING_PREDICATE_NAMES = %i[start_with? end_with? include?].freeze
841
+ private_constant :STRING_PREDICATE_NAMES
842
+
843
+ def analyse_string_predicate(node, scope)
844
+ return nil unless string_predicate_call?(node)
845
+
846
+ needle = string_predicate_needle(node, scope)
847
+ return nil if needle.nil?
848
+
849
+ local_name = node.receiver.name
850
+ return nil if scope.local(local_name).nil?
851
+
852
+ [
853
+ scope.with_fact(string_predicate_fact(local_name, node.name, needle, polarity: :positive)),
854
+ scope.with_fact(string_predicate_fact(local_name, node.name, needle, polarity: :negative))
855
+ ]
856
+ end
857
+
858
+ def string_predicate_call?(node)
859
+ STRING_PREDICATE_NAMES.include?(node.name) &&
860
+ node.receiver.is_a?(Prism::LocalVariableReadNode) &&
861
+ !node.arguments.nil? &&
862
+ node.arguments.arguments.size == 1
863
+ end
864
+
865
+ def string_predicate_needle(node, scope)
866
+ needle_type = static_literal_type(node.arguments.arguments.first, scope)
867
+ return nil unless needle_type.is_a?(Type::Constant) && needle_type.value.is_a?(String)
868
+
869
+ needle_type.value
870
+ end
871
+
872
+ def string_predicate_fact(name, predicate, needle, polarity:)
873
+ Analysis::FactStore::Fact.new(
874
+ bucket: :relational,
875
+ target: Analysis::FactStore::Target.local(name),
876
+ predicate: predicate,
877
+ payload: needle,
878
+ polarity: polarity,
879
+ stability: :local_binding
880
+ )
881
+ end
882
+
788
883
  ZERO_CLASS_PREDICATES = %i[positive? negative? zero? nonzero?].freeze
789
884
  COMPARISON_OPERATORS = %i[< <= > >=].freeze
790
885
  private_constant :ZERO_CLASS_PREDICATES, :COMPARISON_OPERATORS
@@ -1267,6 +1362,7 @@ module Rigor
1267
1362
 
1268
1363
  def apply_fact_to_scope(fact, call_node, entry_scope, target_scope, method_def)
1269
1364
  target_node = fact_target_node(fact, call_node, method_def)
1365
+ return apply_self_fact(fact, target_node, entry_scope, target_scope) if fact.target_kind == :self
1270
1366
  return target_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
1271
1367
 
1272
1368
  local_name = target_node.name
@@ -1277,6 +1373,45 @@ module Rigor
1277
1373
  target_scope.with_local(local_name, narrowed)
1278
1374
  end
1279
1375
 
1376
+ # v0.1.1 Track 1 slice 3 — `target_kind == :self` facts
1377
+ # (`predicate-if-true self is T`, `predicate-if-false
1378
+ # self is T`) narrow whichever scope binding is bound to
1379
+ # the call's receiver. Four receiver shapes participate:
1380
+ #
1381
+ # `recv.method?` where recv is...
1382
+ # - `LocalVariableReadNode` -> narrow that local
1383
+ # - `InstanceVariableReadNode` -> narrow that ivar
1384
+ # - `Prism::SelfNode` (explicit self) -> narrow `self_type`
1385
+ # - nil (implicit self call inside a method body)
1386
+ # -> narrow `self_type`
1387
+ #
1388
+ # Other receiver shapes (method chains, expressions) have
1389
+ # no scope binding to narrow against and fall through.
1390
+ def apply_self_fact(fact, receiver_node, entry_scope, target_scope)
1391
+ case receiver_node
1392
+ when nil, Prism::SelfNode
1393
+ current = entry_scope.self_type
1394
+ return target_scope if current.nil?
1395
+
1396
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1397
+ target_scope.with_self_type(narrowed)
1398
+ when Prism::LocalVariableReadNode
1399
+ current = entry_scope.local(receiver_node.name)
1400
+ return target_scope if current.nil?
1401
+
1402
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1403
+ target_scope.with_local(receiver_node.name, narrowed)
1404
+ when Prism::InstanceVariableReadNode
1405
+ current = entry_scope.ivar(receiver_node.name)
1406
+ return target_scope if current.nil?
1407
+
1408
+ narrowed = narrow_for_fact(current, fact, entry_scope.environment)
1409
+ target_scope.with_ivar(receiver_node.name, narrowed)
1410
+ else
1411
+ target_scope
1412
+ end
1413
+ end
1414
+
1280
1415
  # Resolves a Fact's target node. Mirrors the earlier
1281
1416
  # `effect_target_node` helper but reads from the
1282
1417
  # canonical Fact carrier; `:self` routes to the call
@@ -1291,7 +1426,9 @@ module Rigor
1291
1426
  end
1292
1427
 
1293
1428
  def resolve_rbs_extended_method(node, scope)
1294
- receiver_type = scope.type_of(node.receiver)
1429
+ receiver_type = receiver_type_for_resolve(node, scope)
1430
+ return nil if receiver_type.nil?
1431
+
1295
1432
  class_name = rbs_extended_class_name(receiver_type)
1296
1433
  return nil if class_name.nil?
1297
1434
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
@@ -1305,6 +1442,13 @@ module Rigor
1305
1442
  nil
1306
1443
  end
1307
1444
 
1445
+ # Implicit self call (`admin?` with no receiver inside an
1446
+ # instance method body) — read the method definition from
1447
+ # `scope.self_type` per v0.1.1 Track 1 slice 3.
1448
+ def receiver_type_for_resolve(node, scope)
1449
+ node.receiver.nil? ? scope.self_type : scope.type_of(node.receiver)
1450
+ end
1451
+
1308
1452
  def rbs_extended_class_name(receiver_type)
1309
1453
  case receiver_type
1310
1454
  when Type::Nominal, Type::Singleton then receiver_type.class_name
@@ -552,7 +552,7 @@ module Rigor
552
552
  when Prism::ModuleNode, Prism::ClassNode
553
553
  return if record_class_or_module?(node, qualified_prefix, identity_table, discovered)
554
554
  when Prism::ConstantWriteNode
555
- return if record_data_define_constant?(node, qualified_prefix, identity_table, discovered)
555
+ return if record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
556
556
  end
557
557
 
558
558
  node.compact_child_nodes.each do |child|
@@ -573,17 +573,23 @@ module Rigor
573
573
  true
574
574
  end
575
575
 
576
- # Recognises `Const = Data.define(*Symbol) [do ... end]` and registers
577
- # `Const` (qualified by the surrounding class/module path) as a
578
- # discovered class. `Const.new(...)` then resolves to a fresh
579
- # `Nominal[Const]` via `meta_new`, instead of the un-narrowed
580
- # `Dynamic[top]` returned by the default `Class#new` envelope.
576
+ # Recognises class-creating meta calls at constant-write rvalue
577
+ # position and registers `Const` (qualified by the surrounding
578
+ # class/module path) as a discovered class. `Const.new(...)`
579
+ # then resolves to a fresh `Nominal[Const]` via `meta_new`,
580
+ # instead of the un-narrowed `Dynamic[top]` returned by the
581
+ # default `Class#new` envelope.
581
582
  #
582
- # The Data.define block body, if present, is recursed into so any
583
- # nested class/module declarations in the override block (rare but
584
- # legal) still feed the discovered table.
585
- def record_data_define_constant?(node, qualified_prefix, identity_table, discovered)
586
- return false unless data_define_call?(node.value)
583
+ # Two recognised meta forms:
584
+ #
585
+ # - `Const = Data.define(*Symbol) [do ... end]`
586
+ # - `Const = Struct.new(*Symbol [, keyword_init: ...]) [do ... end]`
587
+ #
588
+ # The block body, if present, is recursed into so any nested
589
+ # class/module declarations in the override block (rare but legal)
590
+ # still feed the discovered table.
591
+ def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
592
+ return false unless data_define_call?(node.value) || struct_new_call?(node.value)
587
593
 
588
594
  full = (qualified_prefix + [node.name.to_s]).join("::")
589
595
  discovered[full] = Type::Combinator.singleton_of(full)
@@ -599,18 +605,46 @@ module Rigor
599
605
  def data_define_call?(node)
600
606
  return false unless node.is_a?(Prism::CallNode)
601
607
  return false unless node.name == :define
602
- return false unless data_constant_receiver?(node.receiver)
608
+ return false unless meta_constant_receiver?(node.receiver, :Data)
603
609
 
604
610
  args = node.arguments&.arguments || []
605
611
  args.all?(Prism::SymbolNode)
606
612
  end
607
613
 
608
- def data_constant_receiver?(node)
614
+ # Recognises `Struct.new(*Symbol)` and
615
+ # `Struct.new(*Symbol, keyword_init: <expr>)` at constant-write
616
+ # rvalue position. A trailing `KeywordHashNode` (the
617
+ # `keyword_init: ...` form) is accepted but does not contribute
618
+ # to member discovery; every other argument MUST be a
619
+ # `Prism::SymbolNode`. At least one Symbol member is required —
620
+ # `Struct.new()` is a degenerate form callers don't typically use.
621
+ def struct_new_call?(node)
622
+ return false unless meta_call_with_name?(node, :Struct, :new)
623
+
624
+ args = node.arguments&.arguments || []
625
+ positional = struct_new_positionals(args)
626
+ return false if positional.nil? || positional.empty?
627
+
628
+ positional.all?(Prism::SymbolNode)
629
+ end
630
+
631
+ def meta_call_with_name?(node, receiver_name, method_name)
632
+ return false unless node.is_a?(Prism::CallNode)
633
+ return false unless node.name == method_name
634
+
635
+ meta_constant_receiver?(node.receiver, receiver_name)
636
+ end
637
+
638
+ def struct_new_positionals(args)
639
+ args.last.is_a?(Prism::KeywordHashNode) ? args[0..-2] : args
640
+ end
641
+
642
+ def meta_constant_receiver?(node, expected_name)
609
643
  case node
610
644
  when Prism::ConstantReadNode
611
- node.name == :Data
645
+ node.name == expected_name
612
646
  when Prism::ConstantPathNode
613
- node.parent.nil? && node.name == :Data
647
+ node.parent.nil? && node.name == expected_name
614
648
  end
615
649
  end
616
650
 
@@ -1015,6 +1015,7 @@ module Rigor
1015
1015
  # same hierarchy-aware narrowing rules.
1016
1016
  def apply_post_return_fact(fact, call_node, current_scope, method_def)
1017
1017
  target_node = fact_target_node(fact, call_node, method_def)
1018
+ return apply_self_post_return_fact(fact, target_node, current_scope) if fact.target_kind == :self
1018
1019
  return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
1019
1020
 
1020
1021
  local_name = target_node.name
@@ -1025,6 +1026,34 @@ module Rigor
1025
1026
  current_scope.with_local(local_name, narrowed)
1026
1027
  end
1027
1028
 
1029
+ # v0.1.1 Track 1 slice 3 — `assert self is T` post-return
1030
+ # narrowing for the four supported receiver shapes (mirrors
1031
+ # `Narrowing#apply_self_fact`).
1032
+ def apply_self_post_return_fact(fact, receiver_node, current_scope)
1033
+ case receiver_node
1034
+ when nil, Prism::SelfNode
1035
+ current = current_scope.self_type
1036
+ return current_scope if current.nil?
1037
+
1038
+ narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
1039
+ current_scope.with_self_type(narrowed)
1040
+ when Prism::LocalVariableReadNode
1041
+ current = current_scope.local(receiver_node.name)
1042
+ return current_scope if current.nil?
1043
+
1044
+ narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
1045
+ current_scope.with_local(receiver_node.name, narrowed)
1046
+ when Prism::InstanceVariableReadNode
1047
+ current = current_scope.ivar(receiver_node.name)
1048
+ return current_scope if current.nil?
1049
+
1050
+ narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
1051
+ current_scope.with_ivar(receiver_node.name, narrowed)
1052
+ else
1053
+ current_scope
1054
+ end
1055
+ end
1056
+
1028
1057
  # `:self` routes to the call receiver; otherwise we look
1029
1058
  # up the matching positional argument by parameter name.
1030
1059
  def fact_target_node(fact, call_node, method_def)