rigortype 0.0.1 → 0.0.2

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.
@@ -9,12 +9,16 @@ module Rigor
9
9
  "target_ruby" => "4.0",
10
10
  "paths" => ["lib"],
11
11
  "plugins" => [],
12
+ "disable" => [],
13
+ "libraries" => [],
14
+ "signature_paths" => nil,
12
15
  "cache" => {
13
16
  "path" => ".rigor/cache"
14
17
  }
15
18
  }.freeze
16
19
 
17
- attr_reader :target_ruby, :paths, :plugins, :cache_path
20
+ attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
21
+ :libraries, :signature_paths
18
22
 
19
23
  def self.load(path = DEFAULT_PATH)
20
24
  data = if File.exist?(path)
@@ -26,12 +30,16 @@ module Rigor
26
30
  new(DEFAULTS.merge(data))
27
31
  end
28
32
 
29
- def initialize(data = DEFAULTS)
33
+ def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize
30
34
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
31
35
 
32
36
  @target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")).to_s
33
37
  @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
34
38
  @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map(&:to_s)
39
+ @disabled_rules = Array(data.fetch("disable", DEFAULTS.fetch("disable"))).map(&:to_s).freeze
40
+ @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
41
+ sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
42
+ @signature_paths = sig_paths.nil? ? nil : Array(sig_paths).map(&:to_s).freeze
35
43
  @cache_path = cache.fetch("path").to_s
36
44
  end
37
45
 
@@ -40,6 +48,9 @@ module Rigor
40
48
  "target_ruby" => target_ruby,
41
49
  "paths" => paths,
42
50
  "plugins" => plugins,
51
+ "disable" => disabled_rules,
52
+ "libraries" => libraries,
53
+ "signature_paths" => signature_paths,
43
54
  "cache" => {
44
55
  "path" => cache_path
45
56
  }
@@ -35,7 +35,9 @@ module Rigor
35
35
  # a strictly RBS-core view MUST construct an `RbsLoader`
36
36
  # directly instead of going through `for_project`.
37
37
  DEFAULT_LIBRARIES = %w[
38
- pathname optparse json yaml fileutils tempfile uri logger date
38
+ pathname optparse json yaml fileutils tempfile tmpdir
39
+ stringio forwardable digest securerandom
40
+ uri logger date
39
41
  prism rbs
40
42
  ].freeze
41
43
 
@@ -188,6 +188,8 @@ module Rigor
188
188
  accepts_nominal_from_nominal(self_type, other_type, mode)
189
189
  when Type::Constant
190
190
  accepts_nominal_from_constant(self_type, other_type, mode)
191
+ when Type::Singleton
192
+ accepts_nominal_from_singleton(self_type, other_type, mode)
191
193
  when Type::Tuple
192
194
  accepts(self_type, project_tuple_to_nominal(other_type), mode: mode)
193
195
  .with_reason("projected Tuple to Nominal[Array]")
@@ -202,6 +204,35 @@ module Rigor
202
204
  end
203
205
  end
204
206
 
207
+ # v0.0.2 — meta-type rule. A `Singleton[T]` is the
208
+ # class object for `T`, so it is an instance of
209
+ # `Class` (when `T` is a class) and always an instance
210
+ # of `Module`. Without this rule a method whose
211
+ # parameter is typed `Class | Module` would reject
212
+ # every `is_a?(SomeClass)` call and similar
213
+ # introspection patterns. The rule conservatively
214
+ # answers `:yes` for `Module` (every singleton is at
215
+ # least a Module) and for `Class` / `Object` /
216
+ # `BasicObject` (the class object inherits from
217
+ # those). Other Nominals fall through to the default
218
+ # `:no`.
219
+ META_NOMINALS_FROM_SINGLETON = %w[Module Class Object BasicObject].freeze
220
+ private_constant :META_NOMINALS_FROM_SINGLETON
221
+
222
+ def accepts_nominal_from_singleton(self_type, other_type, mode)
223
+ if META_NOMINALS_FROM_SINGLETON.include?(self_type.class_name)
224
+ return Type::AcceptsResult.yes(
225
+ mode: mode,
226
+ reasons: "Singleton[#{other_type.class_name}] is-a #{self_type.class_name}"
227
+ )
228
+ end
229
+
230
+ Type::AcceptsResult.no(
231
+ mode: mode,
232
+ reasons: "Nominal[#{self_type.class_name}] rejects Singleton[#{other_type.class_name}]"
233
+ )
234
+ end
235
+
205
236
  def accepts_nominal_from_nominal(self_type, other_type, mode)
206
237
  class_result = class_subtype_result(
207
238
  target_name: self_type.class_name,
@@ -753,6 +753,14 @@ module Rigor
753
753
  )
754
754
  return result if result
755
755
 
756
+ # v0.0.2 #5 — inter-procedural inference for
757
+ # user-defined methods. When dispatch misses but the
758
+ # receiver is a user class with a `def` body, re-type
759
+ # the body with the call's argument types bound and
760
+ # return the body's last-expression type.
761
+ user_inference = try_user_method_inference(receiver, node, arg_types)
762
+ return user_inference if user_inference
763
+
756
764
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and
757
765
  # no positive rule resolves the call, the result inherits the
758
766
  # dynamic origin. Per the value-lattice algebra, this is a
@@ -763,6 +771,102 @@ module Rigor
763
771
  fallback_for(node, family: :prism)
764
772
  end
765
773
 
774
+ # v0.0.2 #5 — re-types the body of a user-defined
775
+ # instance method with the call site's argument types
776
+ # bound to the method's parameters. Used as a
777
+ # last-resort tier after `MethodDispatcher.dispatch`
778
+ # has exhausted its catalogue (RBS, shape, constant
779
+ # folding, user-class fallback). Returns nil when:
780
+ #
781
+ # - the receiver is not `Nominal[T]` for some T;
782
+ # - no def_node is recorded for that class/method
783
+ # (the receiver is foreign or has only an RBS sig);
784
+ # - the def has no body, or has a parameter shape we
785
+ # cannot bind from the call's positional args;
786
+ # - the inference is already in progress for this
787
+ # (class, method, signature) tuple — recursion
788
+ # safety net.
789
+ def try_user_method_inference(receiver, call_node, arg_types)
790
+ return nil unless receiver.is_a?(Type::Nominal)
791
+
792
+ def_node = scope.user_def_for(receiver.class_name, call_node.name)
793
+ return nil if def_node.nil?
794
+
795
+ infer_user_method_return(def_node, receiver, arg_types)
796
+ rescue StandardError
797
+ nil
798
+ end
799
+
800
+ INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
801
+ private_constant :INFERENCE_GUARD_KEY
802
+
803
+ def infer_user_method_return(def_node, receiver, arg_types)
804
+ return nil if def_node.body.nil?
805
+
806
+ body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
807
+ return nil if body_scope.nil?
808
+
809
+ signature = [receiver.class_name, def_node.name, arg_types.map { |t| t.describe(:short) }]
810
+ stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
811
+ return Type::Combinator.untyped if stack.include?(signature)
812
+
813
+ stack.push(signature)
814
+ begin
815
+ type, _post = body_scope.evaluate(def_node.body)
816
+ type
817
+ ensure
818
+ stack.pop
819
+ end
820
+ end
821
+
822
+ # Builds the body scope for a user-defined instance
823
+ # method call: a fresh `Scope` with `self_type` set to
824
+ # the receiver's nominal type, the project-wide
825
+ # accumulators inherited (so the body sees the same
826
+ # `discovered_classes` / `class_ivars` / etc. the
827
+ # caller does), and required positional parameters
828
+ # bound from the call's `arg_types` by index. Returns
829
+ # nil when the parameter shape is too complex for the
830
+ # first-iteration binder (rest args, keyword args,
831
+ # block params, etc.).
832
+ def build_user_method_body_scope(def_node, receiver, arg_types) # rubocop:disable Metrics/AbcSize
833
+ params = def_node.parameters
834
+ required = params&.requireds || []
835
+ return nil unless params.nil? || user_method_param_shape_simple?(params)
836
+ return nil unless required.size == arg_types.size
837
+
838
+ fresh = Scope.empty(environment: scope.environment)
839
+ .with_declared_types(scope.declared_types)
840
+ .with_discovered_classes(scope.discovered_classes)
841
+ .with_in_source_constants(scope.in_source_constants)
842
+ .with_class_ivars(scope.class_ivars)
843
+ .with_class_cvars(scope.class_cvars)
844
+ .with_program_globals(scope.program_globals)
845
+ .with_discovered_methods(scope.discovered_methods)
846
+ .with_discovered_def_nodes(scope.discovered_def_nodes)
847
+ .with_self_type(receiver)
848
+
849
+ required.each_with_index do |param, index|
850
+ fresh = fresh.with_local(param.name, arg_types[index])
851
+ end
852
+ fresh
853
+ end
854
+
855
+ # First iteration accepts only required positional
856
+ # parameters: `def foo(a, b, c)`. Optionals, rest,
857
+ # keyword params, and block params disqualify the
858
+ # method from inference (the caller observes
859
+ # `Dynamic[Top]` instead).
860
+ def user_method_param_shape_simple?(params)
861
+ return false unless params.is_a?(Prism::ParametersNode)
862
+
863
+ params.optionals.empty? &&
864
+ params.rest.nil? &&
865
+ params.keywords.empty? &&
866
+ params.keyword_rest.nil? &&
867
+ params.block.nil?
868
+ end
869
+
766
870
  # Slice A-engine. Implicit-self calls (no `node.receiver`)
767
871
  # adopt the surrounding scope's `self_type` as their receiver
768
872
  # so calls like `attr_reader_method_name` or
@@ -424,7 +424,32 @@ module Rigor
424
424
  # `rigor:v1:predicate-if-true` / `predicate-if-false`
425
425
  # annotations, apply them to narrow the corresponding
426
426
  # local-variable arguments on each edge.
427
- analyse_rbs_extended_predicate(node, scope)
427
+ predicate_result = analyse_rbs_extended_predicate(node, scope)
428
+ assert_result = analyse_rbs_extended_assert_if(node, scope)
429
+ merge_extended_results(predicate_result, assert_result, scope)
430
+ end
431
+
432
+ # Combines two `[truthy_scope, falsey_scope]` pair
433
+ # results from sibling RBS::Extended analysers
434
+ # (`predicate-if-*` and `assert-if-*`). When only one
435
+ # side fires, return it directly; when both fire the
436
+ # right side's per-local deltas are applied on top of
437
+ # the left side's edges so the rules compose.
438
+ def merge_extended_results(left, right, base_scope)
439
+ return left if right.nil?
440
+ return right if left.nil?
441
+
442
+ [
443
+ merge_scope_pair(left[0], right[0], base_scope),
444
+ merge_scope_pair(left[1], right[1], base_scope)
445
+ ]
446
+ end
447
+
448
+ def merge_scope_pair(left_scope, right_scope, base_scope)
449
+ right_scope.locals.reduce(left_scope) do |acc, (name, type)|
450
+ base_type = base_scope.local(name)
451
+ type.equal?(base_type) ? acc : acc.with_local(name, type)
452
+ end
428
453
  end
429
454
 
430
455
  def dispatch_call(node, scope, name)
@@ -669,6 +694,73 @@ module Rigor
669
694
  [truthy_scope, falsey_scope]
670
695
  end
671
696
 
697
+ # v0.0.2 — `assert-if-true` / `assert-if-false`. Reads
698
+ # the conditional assertion effects off the called
699
+ # method and narrows the matching argument on the
700
+ # corresponding edge. The unconditional `assert`
701
+ # variant is NOT applied here; `StatementEvaluator`
702
+ # applies it directly to the post-call scope.
703
+ def analyse_rbs_extended_assert_if(node, scope)
704
+ method_def = resolve_rbs_extended_method(node, scope)
705
+ return nil if method_def.nil?
706
+
707
+ effects = RbsExtended.read_assert_effects(method_def).reject(&:always?)
708
+ return nil if effects.empty?
709
+
710
+ truthy_scope = scope
711
+ falsey_scope = scope
712
+ effects.each do |effect|
713
+ truthy_scope, falsey_scope =
714
+ apply_assert_if_effect(effect, node, scope, truthy_scope, falsey_scope, method_def)
715
+ end
716
+ [truthy_scope, falsey_scope]
717
+ end
718
+
719
+ # rubocop:disable Metrics/ParameterLists
720
+ def apply_assert_if_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
721
+ target_node = effect_target_node(effect, call_node, method_def)
722
+ return [truthy_scope, falsey_scope] unless target_node.is_a?(Prism::LocalVariableReadNode)
723
+
724
+ local_name = target_node.name
725
+ current = entry_scope.local(local_name)
726
+ return [truthy_scope, falsey_scope] if current.nil?
727
+
728
+ narrowed = narrow_for_effect(current, effect, entry_scope.environment)
729
+ if effect.if_truthy_return?
730
+ [truthy_scope.with_local(local_name, narrowed), falsey_scope]
731
+ else
732
+ [truthy_scope, falsey_scope.with_local(local_name, narrowed)]
733
+ end
734
+ end
735
+ # rubocop:enable Metrics/ParameterLists
736
+
737
+ # v0.0.2 #3 — resolves an effect's target node. For
738
+ # `target: <param>` we look up the matching positional
739
+ # argument; for `target: self` we use the call's
740
+ # receiver. In both cases the caller still requires a
741
+ # `Prism::LocalVariableReadNode` for narrowing to
742
+ # actually fire (the engine's narrowing surface only
743
+ # rebinds locals).
744
+ def effect_target_node(effect, call_node, method_def)
745
+ if effect.target_kind == :self
746
+ call_node.receiver
747
+ else
748
+ lookup_positional_arg(call_node, method_def, effect.target_name)
749
+ end
750
+ end
751
+
752
+ # v0.0.2 — selects `narrow_class` (positive) or
753
+ # `narrow_not_class` (negative `~T` form) based on
754
+ # the effect's `negative?` flag. Shared between
755
+ # predicate-if-* and assert-if-* application paths.
756
+ def narrow_for_effect(current, effect, environment)
757
+ if effect.negative?
758
+ narrow_not_class(current, effect.class_name, exact: false, environment: environment)
759
+ else
760
+ narrow_class(current, effect.class_name, exact: false, environment: environment)
761
+ end
762
+ end
763
+
672
764
  def resolve_rbs_extended_method(node, scope)
673
765
  loader = scope.environment.rbs_loader
674
766
  return nil if loader.nil?
@@ -709,15 +801,14 @@ module Rigor
709
801
 
710
802
  # rubocop:disable Metrics/ParameterLists
711
803
  def apply_predicate_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
712
- arg_node = lookup_positional_arg(call_node, method_def, effect.target_name)
713
- return [truthy_scope, falsey_scope] if effect.target_kind != :parameter
714
- return [truthy_scope, falsey_scope] unless arg_node.is_a?(Prism::LocalVariableReadNode)
804
+ target_node = effect_target_node(effect, call_node, method_def)
805
+ return [truthy_scope, falsey_scope] unless target_node.is_a?(Prism::LocalVariableReadNode)
715
806
 
716
- local_name = arg_node.name
807
+ local_name = target_node.name
717
808
  current = entry_scope.local(local_name)
718
809
  return [truthy_scope, falsey_scope] if current.nil?
719
810
 
720
- narrowed = narrow_class(current, effect.class_name, exact: false, environment: entry_scope.environment)
811
+ narrowed = narrow_for_effect(current, effect, entry_scope.environment)
721
812
  if effect.truthy_only?
722
813
  [truthy_scope.with_local(local_name, narrowed), falsey_scope]
723
814
  else
@@ -98,6 +98,13 @@ module Rigor
98
98
  discovered_methods = build_discovered_methods(root)
99
99
  seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
100
100
 
101
+ # v0.0.2 #5 — also record the def node itself for
102
+ # instance methods so the engine can re-type the body
103
+ # when a call site dispatches against a user-defined
104
+ # method without an RBS sig.
105
+ discovered_def_nodes = build_discovered_def_nodes(root)
106
+ seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
107
+
101
108
  table = {}.compare_by_identity
102
109
  table.default = seeded_scope
103
110
 
@@ -369,6 +376,57 @@ module Rigor
369
376
  accumulator[class_name][def_node.name] = kind
370
377
  end
371
378
 
379
+ # v0.0.2 #5 — instance-side def-node recording. Walks
380
+ # class bodies the same way as `build_discovered_methods`
381
+ # but records the actual `Prism::DefNode` for each
382
+ # **instance** method so `ExpressionTyper` can re-type
383
+ # the body at the call site for inter-procedural return
384
+ # inference. Singleton methods and `define_method` calls
385
+ # are intentionally skipped: the inference path needs a
386
+ # statically introspectable body, and singleton dispatch
387
+ # has its own complications (Class / Module ancestry)
388
+ # the first-iteration rule does not yet model.
389
+ def build_discovered_def_nodes(root)
390
+ accumulator = {}
391
+ walk_def_nodes(root, [], false, accumulator)
392
+ accumulator.transform_values(&:freeze).freeze
393
+ end
394
+
395
+ def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity
396
+ return unless node.is_a?(Prism::Node)
397
+
398
+ case node
399
+ when Prism::ClassNode, Prism::ModuleNode
400
+ name = qualified_name_for(node.constant_path)
401
+ if name
402
+ child_prefix = qualified_prefix + [name]
403
+ walk_def_nodes(node.body, child_prefix, false, accumulator) if node.body
404
+ return
405
+ end
406
+ when Prism::SingletonClassNode
407
+ if node.expression.is_a?(Prism::SelfNode) && node.body
408
+ walk_def_nodes(node.body, qualified_prefix, true, accumulator)
409
+ return
410
+ end
411
+ when Prism::DefNode
412
+ record_def_node(node, qualified_prefix, in_singleton_class, accumulator)
413
+ return
414
+ end
415
+
416
+ node.compact_child_nodes.each do |child|
417
+ walk_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
418
+ end
419
+ end
420
+
421
+ def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
422
+ return if qualified_prefix.empty?
423
+ return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
424
+
425
+ class_name = qualified_prefix.join("::")
426
+ accumulator[class_name] ||= {}
427
+ accumulator[class_name][def_node.name] = def_node
428
+ end
429
+
372
430
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
373
431
  return if qualified_prefix.empty?
374
432
  return if call_node.arguments.nil? || call_node.arguments.arguments.empty?
@@ -605,9 +605,103 @@ module Rigor
605
605
  call_type = scope.type_of(node, tracer: tracer)
606
606
  evaluate_block_if_present(node)
607
607
  post_scope = record_closure_escape_if_any(node)
608
+ post_scope = apply_rbs_extended_assertions(node, post_scope)
608
609
  [call_type, post_scope]
609
610
  end
610
611
 
612
+ # v0.0.2 — applies `RBS::Extended` `assert <target> is T`
613
+ # directives to the post-call scope. The conditional
614
+ # variants (`assert-if-true` / `assert-if-false`) are
615
+ # NOT applied here — they refine the scope only when the
616
+ # call is observed as a truthy / falsey predicate, which
617
+ # `Narrowing.predicate_scopes` handles separately.
618
+ def apply_rbs_extended_assertions(call_node, current_scope)
619
+ method_def = resolve_call_method(call_node, current_scope)
620
+ return current_scope if method_def.nil?
621
+
622
+ effects = RbsExtended.read_assert_effects(method_def)
623
+ return current_scope if effects.empty?
624
+
625
+ effects.reduce(current_scope) do |scope_acc, effect|
626
+ next scope_acc unless effect.always?
627
+
628
+ apply_assert_effect(effect, call_node, scope_acc, method_def)
629
+ end
630
+ end
631
+
632
+ def resolve_call_method(call_node, current_scope) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
633
+ receiver_node = call_node.receiver
634
+ receiver_type =
635
+ if receiver_node
636
+ current_scope.type_of(receiver_node, tracer: tracer)
637
+ else
638
+ current_scope.self_type
639
+ end
640
+ return nil if receiver_type.nil?
641
+
642
+ loader = current_scope.environment.rbs_loader
643
+ return nil if loader.nil?
644
+
645
+ class_name = assertion_class_name(receiver_type)
646
+ return nil if class_name.nil?
647
+ return nil unless loader.class_known?(class_name)
648
+
649
+ if receiver_type.is_a?(Type::Singleton)
650
+ loader.singleton_method(class_name: class_name, method_name: call_node.name)
651
+ else
652
+ loader.instance_method(class_name: class_name, method_name: call_node.name)
653
+ end
654
+ rescue StandardError
655
+ nil
656
+ end
657
+
658
+ def assertion_class_name(receiver_type)
659
+ case receiver_type
660
+ when Type::Nominal, Type::Singleton then receiver_type.class_name
661
+ end
662
+ end
663
+
664
+ def apply_assert_effect(effect, call_node, current_scope, method_def)
665
+ target_node = assert_effect_target_node(effect, call_node, method_def)
666
+ return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
667
+
668
+ local_name = target_node.name
669
+ current_type = current_scope.local(local_name)
670
+ return current_scope if current_type.nil?
671
+
672
+ narrowed = narrow_for_assert_effect(current_type, effect, current_scope.environment)
673
+ current_scope.with_local(local_name, narrowed)
674
+ end
675
+
676
+ # v0.0.2 #3 — same `target: self` accommodation as
677
+ # `Narrowing.effect_target_node`: the call's receiver
678
+ # serves as the target for self-targeted directives.
679
+ def assert_effect_target_node(effect, call_node, method_def)
680
+ if effect.target_kind == :self
681
+ call_node.receiver
682
+ else
683
+ lookup_assert_arg(call_node, method_def, effect.target_name)
684
+ end
685
+ end
686
+
687
+ def narrow_for_assert_effect(current_type, effect, environment)
688
+ if effect.negative?
689
+ Narrowing.narrow_not_class(current_type, effect.class_name, exact: false, environment: environment)
690
+ else
691
+ Narrowing.narrow_class(current_type, effect.class_name, exact: false, environment: environment)
692
+ end
693
+ end
694
+
695
+ def lookup_assert_arg(call_node, method_def, target_name)
696
+ arguments = call_node.arguments&.arguments || []
697
+ method_def.method_types.each do |mt|
698
+ params = mt.type.required_positionals + mt.type.optional_positionals
699
+ index = params.find_index { |param| param.name == target_name }
700
+ return arguments[index] if index && arguments[index]
701
+ end
702
+ nil
703
+ end
704
+
611
705
  def evaluate_block_if_present(node)
612
706
  block = node.block
613
707
  return unless block.is_a?(Prism::BlockNode)
@@ -10,20 +10,28 @@ module Rigor
10
10
  # This module reads `%a{rigor:v1:<directive> <payload>}`
11
11
  # annotations off RBS method definitions and returns
12
12
  # well-typed effect objects the inference engine can
13
- # consume. The first preview ships only the **type
14
- # predicate** directives:
13
+ # consume. v0.0.2 recognises:
15
14
  #
16
15
  # - `rigor:v1:predicate-if-true <target> is <ClassName>`
17
16
  # - `rigor:v1:predicate-if-false <target> is <ClassName>`
17
+ # - `rigor:v1:assert <target> is <ClassName>`
18
+ # - `rigor:v1:assert-if-true <target> is <ClassName>`
19
+ # - `rigor:v1:assert-if-false <target> is <ClassName>`
18
20
  #
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).
21
+ # `predicate-if-*` fires when the call is used as an
22
+ # `if` / `unless` condition; `assert` fires unconditionally
23
+ # at the call's post-scope; `assert-if-true` /
24
+ # `assert-if-false` fire at the post-scope only when the
25
+ # call's return value can be observed as truthy / falsey
26
+ # (currently: when the call is the predicate of a
27
+ # subsequent `if` / `unless`). Other directives in the spec
28
+ # (`param`, `return`, `conforms-to`, negation `~T`,
29
+ # `target: self` narrowing, ...) remain on the v0.0.x
30
+ # roadmap. Annotations whose key is in the `rigor:v1:`
31
+ # namespace but whose directive is unrecognised are
32
+ # silently ignored at first-preview quality (a future slice
33
+ # MAY surface them as diagnostics-on-Rigor-itself per the
34
+ # spec's "unsupported metadata" guidance).
27
35
  #
28
36
  # The parser is minimal: it accepts a strict shape
29
37
  # `<target> is <ClassName>` where `<target>` is a Ruby
@@ -37,10 +45,36 @@ module Rigor
37
45
 
38
46
  # Returned for `predicate-if-true` / `predicate-if-false`.
39
47
  # `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
48
+ # Ruby parameter symbol) or `:self`. `negative` is true
49
+ # when the directive uses the `~ClassName` form, in
50
+ # which case the engine narrows AWAY from `class_name`
51
+ # (`Narrowing.narrow_not_class`) instead of toward it.
52
+ PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative) do
42
53
  def truthy_only? = edge == :truthy_only
43
54
  def falsey_only? = edge == :falsey_only
55
+ def negative? = negative == true
56
+ end
57
+
58
+ # Returned for `assert` / `assert-if-true` /
59
+ # `assert-if-false`. `condition` is one of:
60
+ #
61
+ # - `:always` — refines `target` at the call's
62
+ # post-scope unconditionally
63
+ # (`assert`).
64
+ # - `:if_truthy_return` — refines `target` only when the
65
+ # call's return value is observed
66
+ # as truthy (currently: as the
67
+ # predicate of a subsequent
68
+ # `if` / `unless`).
69
+ # - `:if_falsey_return` — symmetric for falsey.
70
+ #
71
+ # `negative` mirrors `PredicateEffect`: true when the
72
+ # directive uses `~ClassName` syntax.
73
+ AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative) do
74
+ def always? = condition == :always
75
+ def if_truthy_return? = condition == :if_truthy_return
76
+ def if_falsey_return? = condition == :if_falsey_return
77
+ def negative? = negative == true
44
78
  end
45
79
 
46
80
  module_function
@@ -71,6 +105,7 @@ module Rigor
71
105
  \s+
72
106
  (?<target>self|[a-z_][a-zA-Z0-9_]*)
73
107
  \s+is\s+
108
+ (?<negation>~?)
74
109
  (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
75
110
  \s*
76
111
  \z
@@ -91,7 +126,68 @@ module Rigor
91
126
  edge: edge,
92
127
  target_kind: target_kind,
93
128
  target_name: target_name,
94
- class_name: class_name
129
+ class_name: class_name,
130
+ negative: match[:negation].to_s == "~"
131
+ )
132
+ end
133
+
134
+ # Reads RBS::Extended assertion effects (`assert`,
135
+ # `assert-if-true`, `assert-if-false`) off
136
+ # `RBS::Definition::Method#annotations`. Returns an empty
137
+ # array when no recognised assertion directives are
138
+ # attached to the method.
139
+ def read_assert_effects(method_def)
140
+ return [] if method_def.nil?
141
+
142
+ annotations = method_def.annotations
143
+ return [] if annotations.nil? || annotations.empty?
144
+
145
+ effects = []
146
+ annotations.each do |annotation|
147
+ effect = parse_assert_annotation(annotation.string)
148
+ effects << effect if effect
149
+ end
150
+ effects.uniq
151
+ end
152
+
153
+ ASSERT_DIRECTIVE_PATTERN = /
154
+ \A
155
+ rigor:v1:(?<directive>assert(?:-if-(?:true|false))?)
156
+ \s+
157
+ (?<target>self|[a-z_][a-zA-Z0-9_]*)
158
+ \s+is\s+
159
+ (?<negation>~?)
160
+ (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
161
+ \s*
162
+ \z
163
+ /x
164
+ private_constant :ASSERT_DIRECTIVE_PATTERN
165
+
166
+ ASSERT_CONDITIONS = {
167
+ "assert" => :always,
168
+ "assert-if-true" => :if_truthy_return,
169
+ "assert-if-false" => :if_falsey_return
170
+ }.freeze
171
+ private_constant :ASSERT_CONDITIONS
172
+
173
+ def parse_assert_annotation(string)
174
+ match = ASSERT_DIRECTIVE_PATTERN.match(string)
175
+ return nil if match.nil?
176
+
177
+ directive = match[:directive].to_s
178
+ condition = ASSERT_CONDITIONS[directive]
179
+ return nil if condition.nil?
180
+
181
+ 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
185
+ AssertEffect.new(
186
+ condition: condition,
187
+ target_kind: target_kind,
188
+ target_name: target_name,
189
+ class_name: class_name,
190
+ negative: match[:negation].to_s == "~"
95
191
  )
96
192
  end
97
193
  end