rigortype 0.0.1 → 0.0.3

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/data/builtins/ruby_core/array.yml +1470 -0
  3. data/data/builtins/ruby_core/file.yml +501 -0
  4. data/data/builtins/ruby_core/io.yml +1594 -0
  5. data/data/builtins/ruby_core/numeric.yml +1809 -0
  6. data/data/builtins/ruby_core/string.yml +1850 -0
  7. data/lib/rigor/analysis/check_rules.rb +297 -5
  8. data/lib/rigor/analysis/diagnostic.rb +13 -2
  9. data/lib/rigor/analysis/runner.rb +52 -5
  10. data/lib/rigor/builtins/imported_refinements.rb +69 -0
  11. data/lib/rigor/cli/type_of_command.rb +11 -5
  12. data/lib/rigor/cli/type_scan_command.rb +13 -8
  13. data/lib/rigor/cli.rb +26 -6
  14. data/lib/rigor/configuration.rb +18 -2
  15. data/lib/rigor/environment.rb +3 -1
  16. data/lib/rigor/inference/acceptance.rb +180 -0
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  19. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  20. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  21. data/lib/rigor/inference/expression_typer.rb +151 -0
  22. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
  23. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  24. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  26. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
  27. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  28. data/lib/rigor/inference/narrowing.rb +471 -10
  29. data/lib/rigor/inference/scope_indexer.rb +66 -0
  30. data/lib/rigor/inference/statement_evaluator.rb +305 -2
  31. data/lib/rigor/rbs_extended.rb +174 -14
  32. data/lib/rigor/scope.rb +44 -5
  33. data/lib/rigor/type/combinator.rb +69 -1
  34. data/lib/rigor/type/difference.rb +155 -0
  35. data/lib/rigor/type/integer_range.rb +137 -0
  36. data/lib/rigor/type.rb +2 -0
  37. data/lib/rigor/version.rb +1 -1
  38. data/sig/rigor/inference.rbs +5 -2
  39. data/sig/rigor/rbs_extended.rbs +25 -1
  40. data/sig/rigor/scope.rbs +4 -0
  41. data/sig/rigor/type.rbs +51 -1
  42. metadata +15 -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
 
@@ -605,9 +651,266 @@ module Rigor
605
651
  call_type = scope.type_of(node, tracer: tracer)
606
652
  evaluate_block_if_present(node)
607
653
  post_scope = record_closure_escape_if_any(node)
654
+ post_scope = apply_rbs_extended_assertions(node, post_scope)
655
+ post_scope = apply_rspec_matcher_narrowing(node, post_scope)
608
656
  [call_type, post_scope]
609
657
  end
610
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
+
821
+ # v0.0.2 — applies `RBS::Extended` `assert <target> is T`
822
+ # directives to the post-call scope. The conditional
823
+ # variants (`assert-if-true` / `assert-if-false`) are
824
+ # NOT applied here — they refine the scope only when the
825
+ # call is observed as a truthy / falsey predicate, which
826
+ # `Narrowing.predicate_scopes` handles separately.
827
+ def apply_rbs_extended_assertions(call_node, current_scope)
828
+ method_def = resolve_call_method(call_node, current_scope)
829
+ return current_scope if method_def.nil?
830
+
831
+ effects = RbsExtended.read_assert_effects(method_def)
832
+ return current_scope if effects.empty?
833
+
834
+ effects.reduce(current_scope) do |scope_acc, effect|
835
+ next scope_acc unless effect.always?
836
+
837
+ apply_assert_effect(effect, call_node, scope_acc, method_def)
838
+ end
839
+ end
840
+
841
+ def resolve_call_method(call_node, current_scope) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
842
+ receiver_node = call_node.receiver
843
+ receiver_type =
844
+ if receiver_node
845
+ current_scope.type_of(receiver_node, tracer: tracer)
846
+ else
847
+ current_scope.self_type
848
+ end
849
+ return nil if receiver_type.nil?
850
+
851
+ loader = current_scope.environment.rbs_loader
852
+ return nil if loader.nil?
853
+
854
+ class_name = assertion_class_name(receiver_type)
855
+ return nil if class_name.nil?
856
+ return nil unless loader.class_known?(class_name)
857
+
858
+ if receiver_type.is_a?(Type::Singleton)
859
+ loader.singleton_method(class_name: class_name, method_name: call_node.name)
860
+ else
861
+ loader.instance_method(class_name: class_name, method_name: call_node.name)
862
+ end
863
+ rescue StandardError
864
+ nil
865
+ end
866
+
867
+ def assertion_class_name(receiver_type)
868
+ case receiver_type
869
+ when Type::Nominal, Type::Singleton then receiver_type.class_name
870
+ end
871
+ end
872
+
873
+ def apply_assert_effect(effect, call_node, current_scope, method_def)
874
+ target_node = assert_effect_target_node(effect, call_node, method_def)
875
+ return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
876
+
877
+ local_name = target_node.name
878
+ current_type = current_scope.local(local_name)
879
+ return current_scope if current_type.nil?
880
+
881
+ narrowed = narrow_for_assert_effect(current_type, effect, current_scope.environment)
882
+ current_scope.with_local(local_name, narrowed)
883
+ end
884
+
885
+ # v0.0.2 #3 — same `target: self` accommodation as
886
+ # `Narrowing.effect_target_node`: the call's receiver
887
+ # serves as the target for self-targeted directives.
888
+ def assert_effect_target_node(effect, call_node, method_def)
889
+ if effect.target_kind == :self
890
+ call_node.receiver
891
+ else
892
+ lookup_assert_arg(call_node, method_def, effect.target_name)
893
+ end
894
+ end
895
+
896
+ def narrow_for_assert_effect(current_type, effect, environment)
897
+ if effect.negative?
898
+ Narrowing.narrow_not_class(current_type, effect.class_name, exact: false, environment: environment)
899
+ else
900
+ Narrowing.narrow_class(current_type, effect.class_name, exact: false, environment: environment)
901
+ end
902
+ end
903
+
904
+ def lookup_assert_arg(call_node, method_def, target_name)
905
+ arguments = call_node.arguments&.arguments || []
906
+ method_def.method_types.each do |mt|
907
+ params = mt.type.required_positionals + mt.type.optional_positionals
908
+ index = params.find_index { |param| param.name == target_name }
909
+ return arguments[index] if index && arguments[index]
910
+ end
911
+ nil
912
+ end
913
+
611
914
  def evaluate_block_if_present(node)
612
915
  block = node.block
613
916
  return unless block.is_a?(Prism::BlockNode)
@@ -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
@@ -10,20 +11,28 @@ module Rigor
10
11
  # This module reads `%a{rigor:v1:<directive> <payload>}`
11
12
  # annotations off RBS method definitions and returns
12
13
  # well-typed effect objects the inference engine can
13
- # consume. The first preview ships only the **type
14
- # predicate** directives:
14
+ # consume. v0.0.2 recognises:
15
15
  #
16
16
  # - `rigor:v1:predicate-if-true <target> is <ClassName>`
17
17
  # - `rigor:v1:predicate-if-false <target> is <ClassName>`
18
+ # - `rigor:v1:assert <target> is <ClassName>`
19
+ # - `rigor:v1:assert-if-true <target> is <ClassName>`
20
+ # - `rigor:v1:assert-if-false <target> is <ClassName>`
18
21
  #
19
- # Other directives in the spec (`assert`, `assert-if-true`,
20
- # `assert-if-false`, `param`, `return`, `conforms-to`, ...)
21
- # are intentionally deferred. Annotations whose key is in
22
- # the `rigor:v1:` namespace but whose directive is
23
- # unrecognised are silently ignored at first-preview
24
- # quality (a future slice MAY surface them as
25
- # diagnostics-on-Rigor-itself per the spec's "unsupported
26
- # metadata" guidance).
22
+ # `predicate-if-*` fires when the call is used as an
23
+ # `if` / `unless` condition; `assert` fires unconditionally
24
+ # at the call's post-scope; `assert-if-true` /
25
+ # `assert-if-false` fire at the post-scope only when the
26
+ # call's return value can be observed as truthy / falsey
27
+ # (currently: when the call is the predicate of a
28
+ # subsequent `if` / `unless`). Other directives in the spec
29
+ # (`param`, `return`, `conforms-to`, negation `~T`,
30
+ # `target: self` narrowing, ...) remain on the v0.0.x
31
+ # roadmap. Annotations whose key is in the `rigor:v1:`
32
+ # namespace but whose directive is unrecognised are
33
+ # silently ignored at first-preview quality (a future slice
34
+ # MAY surface them as diagnostics-on-Rigor-itself per the
35
+ # spec's "unsupported metadata" guidance).
27
36
  #
28
37
  # The parser is minimal: it accepts a strict shape
29
38
  # `<target> is <ClassName>` where `<target>` is a Ruby
@@ -32,15 +41,41 @@ module Rigor
32
41
  # `::Foo::Bar` style constant path. Negative refinements
33
42
  # (`~T`), intersections, and unions are deferred to the
34
43
  # next iteration.
35
- module RbsExtended
44
+ module RbsExtended # rubocop:disable Metrics/ModuleLength
36
45
  DIRECTIVE_PREFIX = "rigor:v1:"
37
46
 
38
47
  # Returned for `predicate-if-true` / `predicate-if-false`.
39
48
  # `target_kind` is `:parameter` (with `target_name` the
40
- # Ruby parameter symbol) or `:self`.
41
- PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name) do
49
+ # Ruby parameter symbol) or `:self`. `negative` is true
50
+ # when the directive uses the `~ClassName` form, in
51
+ # which case the engine narrows AWAY from `class_name`
52
+ # (`Narrowing.narrow_not_class`) instead of toward it.
53
+ PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative) do
42
54
  def truthy_only? = edge == :truthy_only
43
55
  def falsey_only? = edge == :falsey_only
56
+ def negative? = negative == true
57
+ end
58
+
59
+ # Returned for `assert` / `assert-if-true` /
60
+ # `assert-if-false`. `condition` is one of:
61
+ #
62
+ # - `:always` — refines `target` at the call's
63
+ # post-scope unconditionally
64
+ # (`assert`).
65
+ # - `:if_truthy_return` — refines `target` only when the
66
+ # call's return value is observed
67
+ # as truthy (currently: as the
68
+ # predicate of a subsequent
69
+ # `if` / `unless`).
70
+ # - `:if_falsey_return` — symmetric for falsey.
71
+ #
72
+ # `negative` mirrors `PredicateEffect`: true when the
73
+ # directive uses `~ClassName` syntax.
74
+ AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative) do
75
+ def always? = condition == :always
76
+ def if_truthy_return? = condition == :if_truthy_return
77
+ def if_falsey_return? = condition == :if_falsey_return
78
+ def negative? = negative == true
44
79
  end
45
80
 
46
81
  module_function
@@ -71,6 +106,7 @@ module Rigor
71
106
  \s+
72
107
  (?<target>self|[a-z_][a-zA-Z0-9_]*)
73
108
  \s+is\s+
109
+ (?<negation>~?)
74
110
  (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
75
111
  \s*
76
112
  \z
@@ -91,8 +127,132 @@ module Rigor
91
127
  edge: edge,
92
128
  target_kind: target_kind,
93
129
  target_name: target_name,
94
- class_name: class_name
130
+ class_name: class_name,
131
+ negative: match[:negation].to_s == "~"
132
+ )
133
+ end
134
+
135
+ # Reads RBS::Extended assertion effects (`assert`,
136
+ # `assert-if-true`, `assert-if-false`) off
137
+ # `RBS::Definition::Method#annotations`. Returns an empty
138
+ # array when no recognised assertion directives are
139
+ # attached to the method.
140
+ def read_assert_effects(method_def)
141
+ return [] if method_def.nil?
142
+
143
+ annotations = method_def.annotations
144
+ return [] if annotations.nil? || annotations.empty?
145
+
146
+ effects = []
147
+ annotations.each do |annotation|
148
+ effect = parse_assert_annotation(annotation.string)
149
+ effects << effect if effect
150
+ end
151
+ effects.uniq
152
+ end
153
+
154
+ ASSERT_DIRECTIVE_PATTERN = /
155
+ \A
156
+ rigor:v1:(?<directive>assert(?:-if-(?:true|false))?)
157
+ \s+
158
+ (?<target>self|[a-z_][a-zA-Z0-9_]*)
159
+ \s+is\s+
160
+ (?<negation>~?)
161
+ (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
162
+ \s*
163
+ \z
164
+ /x
165
+ private_constant :ASSERT_DIRECTIVE_PATTERN
166
+
167
+ ASSERT_CONDITIONS = {
168
+ "assert" => :always,
169
+ "assert-if-true" => :if_truthy_return,
170
+ "assert-if-false" => :if_falsey_return
171
+ }.freeze
172
+ private_constant :ASSERT_CONDITIONS
173
+
174
+ def parse_assert_annotation(string)
175
+ match = ASSERT_DIRECTIVE_PATTERN.match(string)
176
+ return nil if match.nil?
177
+
178
+ directive = match[:directive].to_s
179
+ condition = ASSERT_CONDITIONS[directive]
180
+ return nil if condition.nil?
181
+
182
+ target = match[:target].to_s
183
+ class_name = match[:class_name].to_s.sub(/\A::/, "")
184
+ target_kind = target == "self" ? :self : :parameter
185
+ target_name = target == "self" ? :self : target.to_sym
186
+ AssertEffect.new(
187
+ condition: condition,
188
+ target_kind: target_kind,
189
+ target_name: target_name,
190
+ class_name: class_name,
191
+ negative: match[:negation].to_s == "~"
95
192
  )
96
193
  end
194
+
195
+ # Reads the `rigor:v1:return: <kebab-name>` directive off
196
+ # `RBS::Definition::Method#annotations`. The directive
197
+ # overrides a method's RBS-declared return type with one of
198
+ # the imported-built-in refinements registered in
199
+ # `Rigor::Builtins::ImportedRefinements`. The override is the
200
+ # primary integration path for refinement carriers
201
+ # (`non-empty-string`, `positive-int`, `non-empty-array`, …)
202
+ # in v0.0 — annotation-driven, opt-in per method, and never
203
+ # silently rewrites a hand-authored RBS signature outside the
204
+ # annotation.
205
+ #
206
+ # Example annotation in an RBS file:
207
+ #
208
+ # class User
209
+ # %a{rigor:v1:return: non-empty-string}
210
+ # def name: () -> String
211
+ # end
212
+ #
213
+ # The RBS-declared return is `String`. The override
214
+ # tightens it to `non-empty-string` (i.e.
215
+ # `Difference[String, ""]`) for callers; RBS erasure of the
216
+ # tightened return goes back to `String` so the round-trip
217
+ # to ordinary RBS is unaffected.
218
+ #
219
+ # Returns the resolved `Rigor::Type` value, or `nil` when:
220
+ # - the method has no annotations,
221
+ # - none of the annotations match the `rigor:v1:return:`
222
+ # directive,
223
+ # - the directive's payload names a refinement not
224
+ # registered in `Rigor::Builtins::ImportedRefinements`
225
+ # (the analyzer prefers a silent miss over crashing on a
226
+ # typo; future slices MAY surface the miss as a
227
+ # `:warning` self-diagnostic).
228
+ def read_return_type_override(method_def)
229
+ return nil if method_def.nil?
230
+
231
+ annotations = method_def.annotations
232
+ return nil if annotations.nil? || annotations.empty?
233
+
234
+ annotations.each do |annotation|
235
+ type = parse_return_type_override(annotation.string)
236
+ return type if type
237
+ end
238
+ nil
239
+ end
240
+
241
+ RETURN_DIRECTIVE_PATTERN = /
242
+ \A
243
+ rigor:v1:return:
244
+ \s+
245
+ (?<refinement>[a-z][a-z0-9-]*)
246
+ \s*
247
+ \z
248
+ /x
249
+ private_constant :RETURN_DIRECTIVE_PATTERN
250
+
251
+ def parse_return_type_override(string)
252
+ match = RETURN_DIRECTIVE_PATTERN.match(string)
253
+ return nil if match.nil?
254
+
255
+ Builtins::ImportedRefinements.lookup(match[:refinement])
256
+ end
97
257
  end
98
258
  end
data/lib/rigor/scope.rb CHANGED
@@ -19,7 +19,8 @@ module Rigor
19
19
  attr_reader :environment, :locals, :fact_store, :self_type, :declared_types,
20
20
  :ivars, :cvars, :globals,
21
21
  :class_ivars, :class_cvars, :program_globals,
22
- :discovered_classes, :in_source_constants, :discovered_methods
22
+ :discovered_classes, :in_source_constants, :discovered_methods,
23
+ :discovered_def_nodes
23
24
 
24
25
  EMPTY_DECLARED_TYPES = {}.compare_by_identity.freeze
25
26
  EMPTY_VAR_BINDINGS = {}.freeze
@@ -45,7 +46,8 @@ module Rigor
45
46
  program_globals: EMPTY_VAR_BINDINGS,
46
47
  discovered_classes: EMPTY_VAR_BINDINGS,
47
48
  in_source_constants: EMPTY_VAR_BINDINGS,
48
- discovered_methods: EMPTY_CLASS_BINDINGS
49
+ discovered_methods: EMPTY_CLASS_BINDINGS,
50
+ discovered_def_nodes: EMPTY_CLASS_BINDINGS
49
51
  )
50
52
  @environment = environment
51
53
  @locals = locals
@@ -61,6 +63,7 @@ module Rigor
61
63
  @discovered_classes = discovered_classes
62
64
  @in_source_constants = in_source_constants
63
65
  @discovered_methods = discovered_methods
66
+ @discovered_def_nodes = discovered_def_nodes
64
67
  freeze
65
68
  end
66
69
 
@@ -231,6 +234,40 @@ module Rigor
231
234
  rebuild(discovered_methods: table)
232
235
  end
233
236
 
237
+ # v0.0.2 #5 — per-class table mapping
238
+ # `method_name (Symbol) → Prism::DefNode`. Populated by
239
+ # `ScopeIndexer` alongside `discovered_methods` for
240
+ # instance-side defs only (singleton-side and
241
+ # `define_method`-introduced methods do not contribute a
242
+ # static body the engine can re-type). Consumed by
243
+ # `ExpressionTyper` to do inter-procedural return-type
244
+ # inference when the receiver class is user-defined and
245
+ # has no RBS sig.
246
+ def user_def_for(class_name, method_name)
247
+ table = @discovered_def_nodes[class_name.to_s]
248
+ return nil unless table
249
+
250
+ table[method_name.to_sym]
251
+ end
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
+
267
+ def with_discovered_def_nodes(table)
268
+ rebuild(discovered_def_nodes: table)
269
+ end
270
+
234
271
  def facts_for(target: nil, bucket: nil)
235
272
  fact_store.facts_for(target: target, bucket: bucket)
236
273
  end
@@ -297,7 +334,7 @@ module Rigor
297
334
  declared_types: @declared_types, ivars: @ivars, cvars: @cvars, globals: @globals,
298
335
  class_ivars: @class_ivars, class_cvars: @class_cvars, program_globals: @program_globals,
299
336
  discovered_classes: @discovered_classes, in_source_constants: @in_source_constants,
300
- discovered_methods: @discovered_methods
337
+ discovered_methods: @discovered_methods, discovered_def_nodes: @discovered_def_nodes
301
338
  )
302
339
  self.class.new(
303
340
  environment: environment, locals: locals,
@@ -308,7 +345,8 @@ module Rigor
308
345
  program_globals: program_globals,
309
346
  discovered_classes: discovered_classes,
310
347
  in_source_constants: in_source_constants,
311
- discovered_methods: discovered_methods
348
+ discovered_methods: discovered_methods,
349
+ discovered_def_nodes: discovered_def_nodes
312
350
  )
313
351
  end
314
352
 
@@ -332,7 +370,8 @@ module Rigor
332
370
  program_globals: program_globals,
333
371
  discovered_classes: discovered_classes,
334
372
  in_source_constants: in_source_constants,
335
- discovered_methods: discovered_methods
373
+ discovered_methods: discovered_methods,
374
+ discovered_def_nodes: discovered_def_nodes
336
375
  )
337
376
  end
338
377
  end