rigortype 0.0.2 → 0.0.4

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 +24 -7
  3. data/data/builtins/ruby_core/array.yml +1470 -0
  4. data/data/builtins/ruby_core/file.yml +501 -0
  5. data/data/builtins/ruby_core/hash.yml +936 -0
  6. data/data/builtins/ruby_core/io.yml +1594 -0
  7. data/data/builtins/ruby_core/numeric.yml +1809 -0
  8. data/data/builtins/ruby_core/range.yml +389 -0
  9. data/data/builtins/ruby_core/set.yml +594 -0
  10. data/data/builtins/ruby_core/string.yml +1850 -0
  11. data/data/builtins/ruby_core/time.yml +750 -0
  12. data/lib/rigor/analysis/check_rules.rb +97 -4
  13. data/lib/rigor/analysis/runner.rb +4 -0
  14. data/lib/rigor/builtins/imported_refinements.rb +251 -0
  15. data/lib/rigor/configuration.rb +6 -1
  16. data/lib/rigor/inference/acceptance.rb +324 -6
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
  19. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  20. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  21. data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
  22. data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
  23. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  24. data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
  25. data/lib/rigor/inference/expression_typer.rb +48 -1
  26. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +670 -16
  27. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  28. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +215 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
  30. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +240 -4
  32. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  33. data/lib/rigor/inference/method_parameter_binder.rb +29 -4
  34. data/lib/rigor/inference/narrowing.rb +376 -4
  35. data/lib/rigor/inference/scope_indexer.rb +10 -2
  36. data/lib/rigor/inference/statement_evaluator.rb +213 -2
  37. data/lib/rigor/rbs_extended.rb +230 -15
  38. data/lib/rigor/scope.rb +14 -0
  39. data/lib/rigor/type/combinator.rb +159 -1
  40. data/lib/rigor/type/difference.rb +155 -0
  41. data/lib/rigor/type/integer_range.rb +137 -0
  42. data/lib/rigor/type/intersection.rb +135 -0
  43. data/lib/rigor/type/refined.rb +174 -0
  44. data/lib/rigor/type.rb +4 -0
  45. data/lib/rigor/version.rb +1 -1
  46. data/sig/rigor/rbs_extended.rbs +14 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +91 -1
  49. metadata +25 -1
@@ -335,7 +335,17 @@ module Rigor
335
335
  # nil-injection on half-bound names so a name set in one branch
336
336
  # but not the other is observable as `T | nil` after the if.
337
337
  def eval_if(node)
338
- _pred_type, post_pred = sub_eval(node.predicate, scope)
338
+ pred_type, post_pred = sub_eval(node.predicate, scope)
339
+
340
+ # When the predicate is a known-truthy / known-falsey type
341
+ # (notably `Constant[true]` / `Constant[false]` after the
342
+ # constant-fold tier), only the live branch contributes a
343
+ # type and a post-scope. The dead branch is skipped so the
344
+ # result type is precise (`Constant[:even]` instead of the
345
+ # joined `Constant[:even] | Constant[:odd]`).
346
+ live = live_branch_for_if(node, pred_type, post_pred)
347
+ return live if live
348
+
339
349
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
340
350
  then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
341
351
  else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
@@ -360,7 +370,11 @@ module Rigor
360
370
  # narrower's truthy/falsey edges are routed in swapped form
361
371
  # because `unless` runs its body when the predicate is falsey.
362
372
  def eval_unless(node)
363
- _pred_type, post_pred = sub_eval(node.predicate, scope)
373
+ pred_type, post_pred = sub_eval(node.predicate, scope)
374
+
375
+ live = live_branch_for_unless(node, pred_type, post_pred)
376
+ return live if live
377
+
364
378
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
365
379
  then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
366
380
  else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
@@ -378,6 +392,38 @@ module Rigor
378
392
  ]
379
393
  end
380
394
 
395
+ # Returns the `[type, post_scope]` of the live branch when the
396
+ # predicate is provably truthy / falsey, else nil so the
397
+ # caller falls through to the standard both-branch evaluation.
398
+ # Constant `true`/`false` is the obvious trigger; non-falsey
399
+ # carriers like `Nominal[Integer]` (Integer is always truthy
400
+ # in Ruby — including 0) also collapse the dead else.
401
+ def live_branch_for_if(node, pred_type, post_pred)
402
+ case predicate_certainty(pred_type)
403
+ when :always_truthy then eval_branch_or_nil(node.statements, post_pred)
404
+ when :always_falsey then eval_branch_or_nil(node.subsequent, post_pred)
405
+ end
406
+ end
407
+
408
+ def live_branch_for_unless(node, pred_type, post_pred)
409
+ case predicate_certainty(pred_type)
410
+ when :always_truthy then eval_branch_or_nil(node.else_clause, post_pred)
411
+ when :always_falsey then eval_branch_or_nil(node.statements, post_pred)
412
+ end
413
+ end
414
+
415
+ def predicate_certainty(pred_type)
416
+ return nil if pred_type.nil? || pred_type.is_a?(Type::Bot)
417
+
418
+ truthy_bot = Narrowing.narrow_truthy(pred_type).is_a?(Type::Bot)
419
+ falsey_bot = Narrowing.narrow_falsey(pred_type).is_a?(Type::Bot)
420
+
421
+ return :always_falsey if truthy_bot && !falsey_bot
422
+ return :always_truthy if !truthy_bot && falsey_bot
423
+
424
+ nil
425
+ end
426
+
381
427
  def eval_else(node)
382
428
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
383
429
 
@@ -606,9 +652,172 @@ module Rigor
606
652
  evaluate_block_if_present(node)
607
653
  post_scope = record_closure_escape_if_any(node)
608
654
  post_scope = apply_rbs_extended_assertions(node, post_scope)
655
+ post_scope = apply_rspec_matcher_narrowing(node, post_scope)
609
656
  [call_type, post_scope]
610
657
  end
611
658
 
659
+ # v0.0.3 — recognises a small catalogue of RSpec
660
+ # matcher patterns as assert-shaped narrows on the
661
+ # local passed to `expect(...)`. The pattern is
662
+ # matched purely on AST shape; no RBS for RSpec is
663
+ # required (and none is shipped today).
664
+ #
665
+ # Recognised today:
666
+ #
667
+ # expect(x).not_to(be_nil)
668
+ # expect(x).to_not(be_nil)
669
+ # → narrow `x` AWAY from `NilClass`.
670
+ #
671
+ # expect(x).to(be_a(C))
672
+ # expect(x).to(be_kind_of(C))
673
+ # expect(x).to(be_an_instance_of(C))
674
+ # → narrow `x` to `C` (exact for
675
+ # `be_an_instance_of`, subtype-permitting
676
+ # otherwise).
677
+ #
678
+ # Anything else is silently passed through. Symmetric
679
+ # negative class assertions (`not_to be_a(C)`) and
680
+ # narrowing TO `NilClass` are intentionally NOT
681
+ # modelled: they are rarely useful in practice and
682
+ # risk masking bugs if the assertion later fails.
683
+ def apply_rspec_matcher_narrowing(call_node, current_scope)
684
+ narrow = rspec_matcher_narrowing_request(call_node)
685
+ return current_scope if narrow.nil?
686
+
687
+ local_name = narrow.fetch(:local)
688
+ current_type = current_scope.local(local_name)
689
+ return current_scope if current_type.nil?
690
+
691
+ narrowed = apply_rspec_narrow(current_type, narrow, current_scope.environment)
692
+ current_scope.with_local(local_name, narrowed)
693
+ end
694
+
695
+ # Decodes an `expect(x).<chain>` outer call into a
696
+ # narrowing request hash, or `nil` when the shape is
697
+ # not recognised. The hash carries `:local` (the local
698
+ # name being narrowed) plus the narrowing parameters.
699
+ def rspec_matcher_narrowing_request(call_node)
700
+ local_name = rspec_expectation_target(call_node)
701
+ return nil if local_name.nil?
702
+
703
+ case call_node.name
704
+ when :not_to, :to_not
705
+ rspec_negative_narrow(call_node, local_name)
706
+ when :to
707
+ rspec_positive_narrow(call_node, local_name)
708
+ end
709
+ end
710
+
711
+ def rspec_negative_narrow(call_node, local_name)
712
+ return nil unless rspec_matcher_argument?(call_node, :be_nil)
713
+
714
+ { local: local_name, kind: :not_class, class_name: "NilClass", exact: false }
715
+ end
716
+
717
+ def rspec_positive_narrow(call_node, local_name)
718
+ matcher = rspec_matcher_node(call_node)
719
+ return nil if matcher.nil?
720
+
721
+ case matcher.name
722
+ when :be_a, :be_kind_of
723
+ rspec_be_a_narrow(matcher, local_name, exact: false)
724
+ when :be_an_instance_of, :be_instance_of
725
+ rspec_be_a_narrow(matcher, local_name, exact: true)
726
+ end
727
+ end
728
+
729
+ # `be_a` / `be_kind_of` / `be_an_instance_of` accept a
730
+ # single class argument — either a `ConstantReadNode`
731
+ # (`Integer`) or a `ConstantPathNode` (`Rigor::Type::Nominal`).
732
+ def rspec_be_a_narrow(matcher, local_name, exact:)
733
+ args = matcher.arguments&.arguments || []
734
+ return nil unless args.size == 1
735
+
736
+ class_name = constant_node_name(args.first)
737
+ return nil if class_name.nil?
738
+
739
+ { local: local_name, kind: :class, class_name: class_name, exact: exact }
740
+ end
741
+
742
+ def apply_rspec_narrow(current_type, narrow, environment)
743
+ case narrow.fetch(:kind)
744
+ when :not_class
745
+ Narrowing.narrow_not_class(current_type, narrow.fetch(:class_name),
746
+ exact: narrow.fetch(:exact), environment: environment)
747
+ when :class
748
+ Narrowing.narrow_class(current_type, narrow.fetch(:class_name),
749
+ exact: narrow.fetch(:exact), environment: environment)
750
+ end
751
+ end
752
+
753
+ # Returns the local name passed to `expect(...)` when
754
+ # the receiver chain matches `expect(<local>)` exactly,
755
+ # or nil otherwise. Centralised so each per-matcher
756
+ # decoder can short-circuit on a non-matching outer
757
+ # call.
758
+ def rspec_expectation_target(call_node) # rubocop:disable Metrics/CyclomaticComplexity
759
+ receiver = call_node.receiver
760
+ return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :expect
761
+ return nil unless receiver.receiver.nil?
762
+
763
+ args = receiver.arguments&.arguments || []
764
+ return nil unless args.size == 1
765
+
766
+ target = args.first
767
+ target.is_a?(Prism::LocalVariableReadNode) ? target.name : nil
768
+ end
769
+
770
+ def rspec_matcher_node(call_node)
771
+ args = call_node.arguments&.arguments || []
772
+ return nil unless args.size == 1
773
+
774
+ matcher = args.first
775
+ return nil unless matcher.is_a?(Prism::CallNode) && matcher.receiver.nil? && matcher.block.nil?
776
+
777
+ matcher
778
+ end
779
+
780
+ # True when `call_node`'s sole argument is an
781
+ # implicit-self matcher call with the given name and
782
+ # no positional arguments — used by the no-arg
783
+ # matchers (`be_nil`).
784
+ def rspec_matcher_argument?(call_node, matcher_name)
785
+ matcher = rspec_matcher_node(call_node)
786
+ return false if matcher.nil?
787
+ return false unless matcher.name == matcher_name
788
+
789
+ matcher.arguments.nil? || matcher.arguments.arguments.empty?
790
+ end
791
+
792
+ # Decodes a `Prism::ConstantReadNode` /
793
+ # `Prism::ConstantPathNode` into a colon-joined class
794
+ # name string, or returns nil for any other node
795
+ # shape. Mirrors the conservative envelope used by the
796
+ # `is_a?` / `kind_of?` predicate narrower.
797
+ def constant_node_name(node)
798
+ case node
799
+ when Prism::ConstantReadNode
800
+ node.name.to_s
801
+ when Prism::ConstantPathNode
802
+ flatten_constant_path(node)
803
+ end
804
+ end
805
+
806
+ def flatten_constant_path(node)
807
+ parts = []
808
+ cursor = node
809
+ while cursor.is_a?(Prism::ConstantPathNode)
810
+ parts.unshift(cursor.name.to_s)
811
+ cursor = cursor.parent
812
+ end
813
+ case cursor
814
+ when Prism::ConstantReadNode then parts.unshift(cursor.name.to_s)
815
+ when nil then nil # ::Foo absolute root — preserve as-is
816
+ else return nil
817
+ end
818
+ parts.join("::")
819
+ end
820
+
612
821
  # v0.0.2 — applies `RBS::Extended` `assert <target> is T`
613
822
  # directives to the post-call scope. The conditional
614
823
  # variants (`assert-if-true` / `assert-if-false`) are
@@ -685,6 +894,8 @@ module Rigor
685
894
  end
686
895
 
687
896
  def narrow_for_assert_effect(current_type, effect, environment)
897
+ return effect.refinement_type if effect.refinement?
898
+
688
899
  if effect.negative?
689
900
  Narrowing.narrow_not_class(current_type, effect.class_name, exact: false, environment: environment)
690
901
  else
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "type"
4
+ require_relative "builtins/imported_refinements"
4
5
 
5
6
  module Rigor
6
7
  # Slice 7 phase 15 — first-preview reader for the
@@ -40,7 +41,7 @@ module Rigor
40
41
  # `::Foo::Bar` style constant path. Negative refinements
41
42
  # (`~T`), intersections, and unions are deferred to the
42
43
  # next iteration.
43
- module RbsExtended
44
+ module RbsExtended # rubocop:disable Metrics/ModuleLength
44
45
  DIRECTIVE_PREFIX = "rigor:v1:"
45
46
 
46
47
  # Returned for `predicate-if-true` / `predicate-if-false`.
@@ -49,10 +50,19 @@ module Rigor
49
50
  # when the directive uses the `~ClassName` form, in
50
51
  # which case the engine narrows AWAY from `class_name`
51
52
  # (`Narrowing.narrow_not_class`) instead of toward it.
52
- PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative) do
53
+ #
54
+ # `refinement_type` is non-nil when the right-hand side is
55
+ # a kebab-case refinement name (`non-empty-string`,
56
+ # `lowercase-string`, …) instead of a Capitalised class
57
+ # name. The narrowing tier substitutes the carrier for the
58
+ # current local type; `class_name` is then nil and
59
+ # `negative` is false (refinement-form directives do not
60
+ # support `~T` negation in v0.0.4).
61
+ PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
53
62
  def truthy_only? = edge == :truthy_only
54
63
  def falsey_only? = edge == :falsey_only
55
64
  def negative? = negative == true
65
+ def refinement? = !refinement_type.nil?
56
66
  end
57
67
 
58
68
  # Returned for `assert` / `assert-if-true` /
@@ -70,11 +80,12 @@ module Rigor
70
80
  #
71
81
  # `negative` mirrors `PredicateEffect`: true when the
72
82
  # directive uses `~ClassName` syntax.
73
- AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative) do
83
+ AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
74
84
  def always? = condition == :always
75
85
  def if_truthy_return? = condition == :if_truthy_return
76
86
  def if_falsey_return? = condition == :if_falsey_return
77
87
  def negative? = negative == true
88
+ def refinement? = !refinement_type.nil?
78
89
  end
79
90
 
80
91
  module_function
@@ -99,14 +110,26 @@ module Rigor
99
110
  effects.uniq
100
111
  end
101
112
 
113
+ # The right-hand side accepts either a Capitalised class
114
+ # name (with optional `~` negation, optional `::` prefix,
115
+ # qualified names) OR a kebab-case refinement payload
116
+ # routed through `Builtins::ImportedRefinements::Parser`
117
+ # (bare names, `name[T]`, `name<min, max>`). The two arms
118
+ # share the same overall directive shape; the parser
119
+ # detects which form matched by looking at the `class_name`
120
+ # vs `refinement` capture groups.
102
121
  PREDICATE_DIRECTIVE_PATTERN = /
103
122
  \A
104
123
  rigor:v1:(?<directive>predicate-if-(?:true|false))
105
124
  \s+
106
125
  (?<target>self|[a-z_][a-zA-Z0-9_]*)
107
126
  \s+is\s+
108
- (?<negation>~?)
109
- (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
127
+ (?:
128
+ (?<negation>~?)
129
+ (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
130
+ |
131
+ (?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
132
+ )
110
133
  \s*
111
134
  \z
112
135
  /x
@@ -118,16 +141,18 @@ module Rigor
118
141
 
119
142
  directive = match[:directive].to_s
120
143
  target = match[:target].to_s
121
- class_name = match[:class_name].to_s.sub(/\A::/, "")
122
144
  edge = directive == "predicate-if-true" ? :truthy_only : :falsey_only
123
- target_kind = target == "self" ? :self : :parameter
124
- target_name = target == "self" ? :self : target.to_sym
145
+ target_kind, target_name = target_fields(target)
146
+ class_name, refinement_type, negative = resolve_directive_rhs(match)
147
+ return nil if class_name.nil? && refinement_type.nil?
148
+
125
149
  PredicateEffect.new(
126
150
  edge: edge,
127
151
  target_kind: target_kind,
128
152
  target_name: target_name,
129
153
  class_name: class_name,
130
- negative: match[:negation].to_s == "~"
154
+ negative: negative,
155
+ refinement_type: refinement_type
131
156
  )
132
157
  end
133
158
 
@@ -156,8 +181,12 @@ module Rigor
156
181
  \s+
157
182
  (?<target>self|[a-z_][a-zA-Z0-9_]*)
158
183
  \s+is\s+
159
- (?<negation>~?)
160
- (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
184
+ (?:
185
+ (?<negation>~?)
186
+ (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
187
+ |
188
+ (?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
189
+ )
161
190
  \s*
162
191
  \z
163
192
  /x
@@ -179,16 +208,202 @@ module Rigor
179
208
  return nil if condition.nil?
180
209
 
181
210
  target = match[:target].to_s
182
- class_name = match[:class_name].to_s.sub(/\A::/, "")
183
- target_kind = target == "self" ? :self : :parameter
184
- target_name = target == "self" ? :self : target.to_sym
211
+ target_kind, target_name = target_fields(target)
212
+ class_name, refinement_type, negative = resolve_directive_rhs(match)
213
+ return nil if class_name.nil? && refinement_type.nil?
214
+
185
215
  AssertEffect.new(
186
216
  condition: condition,
187
217
  target_kind: target_kind,
188
218
  target_name: target_name,
189
219
  class_name: class_name,
190
- negative: match[:negation].to_s == "~"
220
+ negative: negative,
221
+ refinement_type: refinement_type
191
222
  )
192
223
  end
224
+
225
+ # Resolves the `class_name` / `refinement` alternation in
226
+ # the assert / predicate directive patterns. Returns
227
+ # `[class_name, refinement_type, negative]`:
228
+ #
229
+ # - Class-name arm matched: `class_name` is the resolved
230
+ # string (leading `::` stripped), `refinement_type` is
231
+ # nil, `negative` reflects the optional `~` prefix.
232
+ # - Refinement arm matched: `class_name` is nil,
233
+ # `refinement_type` is the resolved `Rigor::Type`,
234
+ # `negative` is `false` (refinement-form directives do
235
+ # not support `~` negation in v0.0.4).
236
+ # - Refinement payload unparseable: returns
237
+ # `[nil, nil, false]` so callers can drop the directive
238
+ # silently (fail-soft policy).
239
+ def resolve_directive_rhs(match)
240
+ class_capture = match[:class_name]
241
+ return [class_capture.to_s.sub(/\A::/, ""), nil, match[:negation].to_s == "~"] if class_capture
242
+
243
+ refinement_capture = match[:refinement]
244
+ return [nil, nil, false] if refinement_capture.nil?
245
+
246
+ type = Builtins::ImportedRefinements.parse(refinement_capture)
247
+ return [nil, nil, false] if type.nil?
248
+
249
+ [nil, type, false]
250
+ end
251
+
252
+ def target_fields(target)
253
+ if target == "self"
254
+ %i[self self]
255
+ else
256
+ [:parameter, target.to_sym]
257
+ end
258
+ end
259
+
260
+ # Reads the `rigor:v1:return: <kebab-name>` directive off
261
+ # `RBS::Definition::Method#annotations`. The directive
262
+ # overrides a method's RBS-declared return type with one of
263
+ # the imported-built-in refinements registered in
264
+ # `Rigor::Builtins::ImportedRefinements`. The override is the
265
+ # primary integration path for refinement carriers
266
+ # (`non-empty-string`, `positive-int`, `non-empty-array`, …)
267
+ # in v0.0 — annotation-driven, opt-in per method, and never
268
+ # silently rewrites a hand-authored RBS signature outside the
269
+ # annotation.
270
+ #
271
+ # Example annotation in an RBS file:
272
+ #
273
+ # class User
274
+ # %a{rigor:v1:return: non-empty-string}
275
+ # def name: () -> String
276
+ # end
277
+ #
278
+ # The RBS-declared return is `String`. The override
279
+ # tightens it to `non-empty-string` (i.e.
280
+ # `Difference[String, ""]`) for callers; RBS erasure of the
281
+ # tightened return goes back to `String` so the round-trip
282
+ # to ordinary RBS is unaffected.
283
+ #
284
+ # Returns the resolved `Rigor::Type` value, or `nil` when:
285
+ # - the method has no annotations,
286
+ # - none of the annotations match the `rigor:v1:return:`
287
+ # directive,
288
+ # - the directive's payload names a refinement not
289
+ # registered in `Rigor::Builtins::ImportedRefinements`
290
+ # (the analyzer prefers a silent miss over crashing on a
291
+ # typo; future slices MAY surface the miss as a
292
+ # `:warning` self-diagnostic).
293
+ def read_return_type_override(method_def)
294
+ return nil if method_def.nil?
295
+
296
+ annotations = method_def.annotations
297
+ return nil if annotations.nil? || annotations.empty?
298
+
299
+ annotations.each do |annotation|
300
+ type = parse_return_type_override(annotation.string)
301
+ return type if type
302
+ end
303
+ nil
304
+ end
305
+
306
+ # The trailing payload supports the full refinement
307
+ # grammar in `Builtins::ImportedRefinements::Parser` —
308
+ # bare kebab-case names plus parameterised forms like
309
+ # `non-empty-array[Integer]`, `non-empty-hash[Symbol,
310
+ # Integer]`, and `int<5, 10>`. The directive head is
311
+ # consumed by the regex; the rest is forwarded to the
312
+ # refinement parser. Anything the parser cannot resolve
313
+ # falls back to nil so the call site keeps the
314
+ # RBS-declared return type.
315
+ RETURN_DIRECTIVE_PATTERN = /
316
+ \A
317
+ rigor:v1:return:
318
+ \s+
319
+ (?<payload>\S(?:.*\S)?)
320
+ \s*
321
+ \z
322
+ /x
323
+ private_constant :RETURN_DIRECTIVE_PATTERN
324
+
325
+ def parse_return_type_override(string)
326
+ match = RETURN_DIRECTIVE_PATTERN.match(string)
327
+ return nil if match.nil?
328
+
329
+ Builtins::ImportedRefinements.parse(match[:payload])
330
+ end
331
+
332
+ # Returned for `rigor:v1:param: <name> <refinement>`. The
333
+ # parameter name is a Ruby identifier (Symbol); the type
334
+ # is any `Rigor::Type` the refinement parser resolves
335
+ # (bare kebab-case name, parameterised form, or `int<...>`
336
+ # range — the same grammar the `return:` directive
337
+ # accepts).
338
+ ParamOverride = Data.define(:param_name, :type)
339
+
340
+ # Reads every `rigor:v1:param: <name> <refinement>`
341
+ # directive off `RBS::Definition::Method#annotations` and
342
+ # returns the resolved `ParamOverride` list. Annotations
343
+ # the parser cannot resolve (typo, unknown refinement, no
344
+ # `param:` directive at all) are silently dropped — the
345
+ # call site keeps the RBS-declared parameter type for
346
+ # those parameters. The reader accepts a nil method
347
+ # definition so call sites can pass through optional
348
+ # method lookups without a guard.
349
+ #
350
+ # Example annotation in an RBS file:
351
+ #
352
+ # class Slug
353
+ # %a{rigor:v1:param: id is non-empty-string}
354
+ # def normalise: (::String id) -> String
355
+ # end
356
+ #
357
+ # The RBS-declared type of `id` is `String`. The override
358
+ # tightens it to `non-empty-string` for argument-check
359
+ # purposes; passing a too-wide `Nominal[String]` argument
360
+ # is flagged as an argument-type mismatch at the call
361
+ # site.
362
+ def read_param_type_overrides(method_def)
363
+ return [] if method_def.nil?
364
+
365
+ annotations = method_def.annotations
366
+ return [] if annotations.nil? || annotations.empty?
367
+
368
+ annotations.filter_map { |annotation| parse_param_annotation(annotation.string) }
369
+ end
370
+
371
+ # Convenience reader for call sites that want to look up
372
+ # a single override by parameter name. Returns a frozen
373
+ # Hash<Symbol, Rigor::Type>; missing keys mean "use the
374
+ # RBS-declared type". Callers MUST treat the hash as
375
+ # read-only.
376
+ def param_type_override_map(method_def)
377
+ read_param_type_overrides(method_def).to_h { |o| [o.param_name, o.type] }.freeze
378
+ end
379
+
380
+ # The `is` glue word is optional so authors can write
381
+ # either `param: id is non-empty-string` (consistent with
382
+ # the existing `assert` / `predicate-if-*` directives) or
383
+ # the terser `param: id non-empty-string`. The trailing
384
+ # payload accepts the full refinement grammar in
385
+ # `Builtins::ImportedRefinements::Parser`.
386
+ PARAM_DIRECTIVE_PATTERN = /
387
+ \A
388
+ rigor:v1:param:
389
+ \s+
390
+ (?<param>[a-z_][a-zA-Z0-9_]*)
391
+ \s+
392
+ (?:is\s+)?
393
+ (?<payload>\S(?:.*\S)?)
394
+ \s*
395
+ \z
396
+ /x
397
+ private_constant :PARAM_DIRECTIVE_PATTERN
398
+
399
+ def parse_param_annotation(string)
400
+ match = PARAM_DIRECTIVE_PATTERN.match(string)
401
+ return nil if match.nil?
402
+
403
+ type = Builtins::ImportedRefinements.parse(match[:payload])
404
+ return nil if type.nil?
405
+
406
+ ParamOverride.new(param_name: match[:param].to_sym, type: type)
407
+ end
193
408
  end
194
409
  end
data/lib/rigor/scope.rb CHANGED
@@ -250,6 +250,20 @@ module Rigor
250
250
  table[method_name.to_sym]
251
251
  end
252
252
 
253
+ # v0.0.3 A — top-level def lookup for implicit-self
254
+ # calls. Returns the `Prism::DefNode` for a top-level
255
+ # (or DSL-block-nested, outside any class body) `def
256
+ # <method_name>` in the file, or nil. The sentinel key
257
+ # is owned by `Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY`;
258
+ # consumers should treat its presence as an opaque
259
+ # implementation detail and go through this accessor.
260
+ def top_level_def_for(method_name)
261
+ table = @discovered_def_nodes[Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY]
262
+ return nil unless table
263
+
264
+ table[method_name.to_sym]
265
+ end
266
+
253
267
  def with_discovered_def_nodes(table)
254
268
  rebuild(discovered_def_nodes: table)
255
269
  end