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.
- checksums.yaml +4 -4
- data/data/builtins/ruby_core/array.yml +1470 -0
- data/data/builtins/ruby_core/file.yml +501 -0
- data/data/builtins/ruby_core/io.yml +1594 -0
- data/data/builtins/ruby_core/numeric.yml +1809 -0
- data/data/builtins/ruby_core/string.yml +1850 -0
- data/lib/rigor/analysis/check_rules.rb +297 -5
- data/lib/rigor/analysis/diagnostic.rb +13 -2
- data/lib/rigor/analysis/runner.rb +52 -5
- data/lib/rigor/builtins/imported_refinements.rb +69 -0
- data/lib/rigor/cli/type_of_command.rb +11 -5
- data/lib/rigor/cli/type_scan_command.rb +13 -8
- data/lib/rigor/cli.rb +26 -6
- data/lib/rigor/configuration.rb +18 -2
- data/lib/rigor/environment.rb +3 -1
- data/lib/rigor/inference/acceptance.rb +180 -0
- data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
- data/lib/rigor/inference/expression_typer.rb +151 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
- data/lib/rigor/inference/method_dispatcher.rb +28 -21
- data/lib/rigor/inference/narrowing.rb +471 -10
- data/lib/rigor/inference/scope_indexer.rb +66 -0
- data/lib/rigor/inference/statement_evaluator.rb +305 -2
- data/lib/rigor/rbs_extended.rb +174 -14
- data/lib/rigor/scope.rb +44 -5
- data/lib/rigor/type/combinator.rb +69 -1
- data/lib/rigor/type/difference.rb +155 -0
- data/lib/rigor/type/integer_range.rb +137 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +5 -2
- data/sig/rigor/rbs_extended.rbs +25 -1
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor/type.rbs +51 -1
- 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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/rigor/rbs_extended.rb
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
20
|
-
# `
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
-
|
|
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
|