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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/check_rules.rb +212 -5
- data/lib/rigor/analysis/diagnostic.rb +13 -2
- data/lib/rigor/analysis/runner.rb +48 -5
- 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 +13 -2
- data/lib/rigor/environment.rb +3 -1
- data/lib/rigor/inference/acceptance.rb +31 -0
- data/lib/rigor/inference/expression_typer.rb +104 -0
- data/lib/rigor/inference/narrowing.rb +97 -6
- data/lib/rigor/inference/scope_indexer.rb +58 -0
- data/lib/rigor/inference/statement_evaluator.rb +94 -0
- data/lib/rigor/rbs_extended.rb +109 -13
- data/lib/rigor/scope.rb +30 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +5 -2
- data/sig/rigor/rbs_extended.rbs +22 -1
- data/sig/rigor/scope.rbs +3 -0
- metadata +1 -1
data/lib/rigor/configuration.rb
CHANGED
|
@@ -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
|
}
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
713
|
-
return [truthy_scope, falsey_scope]
|
|
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 =
|
|
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 =
|
|
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)
|
data/lib/rigor/rbs_extended.rb
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
20
|
-
# `
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
-
|
|
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
|