rigortype 0.1.8 → 0.1.10
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/README.md +186 -513
- data/lib/rigor/analysis/check_rules.rb +20 -0
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +274 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli.rb +134 -6
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +168 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +308 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +148 -1
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +29 -3
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +67 -5
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +101 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +87 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/triage/catalogue.rb +2 -2
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +42 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../type"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Inference
|
|
7
|
+
module MethodDispatcher
|
|
8
|
+
# Folds the zone-pinned `Time` class constructors on statically
|
|
9
|
+
# known arguments.
|
|
10
|
+
#
|
|
11
|
+
# Only `Time.utc` / `Time.gm` are folded. They pin the result
|
|
12
|
+
# to UTC, so the literal is machine-independent — every reader
|
|
13
|
+
# (`year`, `hour`, `utc_offset`, `strftime`, …) yields the same
|
|
14
|
+
# answer on any analysis host.
|
|
15
|
+
#
|
|
16
|
+
# `Time.now` (non-deterministic), `Time.at` / `Time.local` /
|
|
17
|
+
# `Time.mktime` / `Time.new` (local-zone — the result's wall
|
|
18
|
+
# clock and `utc_offset` depend on the analysis machine's
|
|
19
|
+
# timezone) are deliberately NOT folded; they keep their
|
|
20
|
+
# `Nominal[Time]` RBS answer. For the same reason `Time#getlocal`
|
|
21
|
+
# is blocklisted in `TIME_CATALOG` so a folded `Constant[Time]`
|
|
22
|
+
# cannot produce a machine-dependent local-zone copy.
|
|
23
|
+
module TimeFolding
|
|
24
|
+
TIME_UTC_METHODS = Set[:utc, :gm].freeze
|
|
25
|
+
private_constant :TIME_UTC_METHODS
|
|
26
|
+
|
|
27
|
+
# `Time.utc` accepts the 1–7 positional (year-first) form and
|
|
28
|
+
# a 10-arg (sec-first) form; cap the arity so a malformed
|
|
29
|
+
# call declines cheaply rather than reaching the constructor.
|
|
30
|
+
MAX_TIME_ARITY = 10
|
|
31
|
+
private_constant :MAX_TIME_ARITY
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# @return [Rigor::Type, nil] folded result, or nil to defer.
|
|
36
|
+
def try_dispatch(receiver:, method_name:, args:)
|
|
37
|
+
return nil unless dispatch_target?(receiver)
|
|
38
|
+
return nil unless TIME_UTC_METHODS.include?(method_name)
|
|
39
|
+
return nil unless args.size.between?(1, MAX_TIME_ARITY)
|
|
40
|
+
return nil unless args.all?(Type::Constant)
|
|
41
|
+
|
|
42
|
+
values = args.map(&:value)
|
|
43
|
+
return nil unless values.all? { |v| v.is_a?(Integer) || v.is_a?(String) }
|
|
44
|
+
|
|
45
|
+
Type::Combinator.constant_of(Time.utc(*values))
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dispatch_target?(receiver)
|
|
51
|
+
receiver.is_a?(Type::Singleton) && receiver.class_name == "Time"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../../type"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Inference
|
|
8
|
+
module MethodDispatcher
|
|
9
|
+
# Folds `URI` module-function calls on statically known
|
|
10
|
+
# string constants.
|
|
11
|
+
#
|
|
12
|
+
# `URI.encode_www_form_component` / `decode_www_form_component`
|
|
13
|
+
# and the newer `encode_uri_component` / `decode_uri_component`
|
|
14
|
+
# are pure, deterministic functions over their string inputs.
|
|
15
|
+
# When the argument is a `Constant[String]`, the analyzer can
|
|
16
|
+
# evaluate the call at inference time and return the concrete
|
|
17
|
+
# `Constant[String]` result.
|
|
18
|
+
#
|
|
19
|
+
# === Supported methods
|
|
20
|
+
#
|
|
21
|
+
# * `encode_www_form_component(str)` / `decode_www_form_component(str)` —
|
|
22
|
+
# RFC 3986 percent-encode / decode. Returns `Constant[String]`.
|
|
23
|
+
# * `encode_uri_component(str)` / `decode_uri_component(str)` —
|
|
24
|
+
# Same encoding but may preserve additional reserved chars
|
|
25
|
+
# (Ruby 3.2+). Returns `Constant[String]`.
|
|
26
|
+
#
|
|
27
|
+
# === Non-constant / unsupported cases
|
|
28
|
+
#
|
|
29
|
+
# Returns `nil` (deferring to the next dispatcher tier) when:
|
|
30
|
+
# - the receiver is not `Singleton[URI]`,
|
|
31
|
+
# - the first argument is not a `Constant[String]`,
|
|
32
|
+
# - the method is not in the supported set.
|
|
33
|
+
module URIFolding
|
|
34
|
+
URI_COMPONENT_METHODS = Set[
|
|
35
|
+
:encode_www_form_component, :decode_www_form_component,
|
|
36
|
+
:encode_uri_component, :decode_uri_component
|
|
37
|
+
].freeze
|
|
38
|
+
private_constant :URI_COMPONENT_METHODS
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# @return [Rigor::Type, nil] folded result, or nil to defer.
|
|
43
|
+
def try_dispatch(receiver:, method_name:, args:)
|
|
44
|
+
return nil unless dispatch_target?(receiver)
|
|
45
|
+
return nil unless URI_COMPONENT_METHODS.include?(method_name)
|
|
46
|
+
|
|
47
|
+
fold_uri_call(method_name, args)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dispatch_target?(receiver)
|
|
51
|
+
receiver.is_a?(Type::Singleton) && receiver.class_name == "URI"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fold_uri_call(method_name, args)
|
|
55
|
+
return nil unless args.size == 1
|
|
56
|
+
|
|
57
|
+
arg = args.first
|
|
58
|
+
return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
|
|
59
|
+
|
|
60
|
+
Type::Combinator.constant_of(URI.public_send(method_name, arg.value))
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -13,6 +13,13 @@ require_relative "method_dispatcher/rbs_dispatch"
|
|
|
13
13
|
require_relative "method_dispatcher/iterator_dispatch"
|
|
14
14
|
require_relative "method_dispatcher/block_folding"
|
|
15
15
|
require_relative "method_dispatcher/file_folding"
|
|
16
|
+
require_relative "method_dispatcher/shellwords_folding"
|
|
17
|
+
require_relative "method_dispatcher/math_folding"
|
|
18
|
+
require_relative "method_dispatcher/time_folding"
|
|
19
|
+
require_relative "method_dispatcher/regexp_folding"
|
|
20
|
+
require_relative "method_dispatcher/cgi_folding"
|
|
21
|
+
require_relative "method_dispatcher/uri_folding"
|
|
22
|
+
require_relative "method_dispatcher/set_folding"
|
|
16
23
|
require_relative "method_dispatcher/kernel_dispatch"
|
|
17
24
|
require_relative "method_dispatcher/method_folding"
|
|
18
25
|
|
|
@@ -643,7 +650,7 @@ module Rigor
|
|
|
643
650
|
ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
644
651
|
LiteralStringFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
645
652
|
ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
646
|
-
|
|
653
|
+
dispatch_stdlib_module_tiers(receiver_type, method_name, arg_types) ||
|
|
647
654
|
KernelDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
648
655
|
MethodFolding.try_forward(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
649
656
|
BlockFolding.try_fold(
|
|
@@ -651,6 +658,21 @@ module Rigor
|
|
|
651
658
|
)
|
|
652
659
|
end
|
|
653
660
|
|
|
661
|
+
# Stdlib module singleton-folding tiers: File, Shellwords, Math,
|
|
662
|
+
# Time, Regexp, CGI, URI, Set. Extracted from
|
|
663
|
+
# `dispatch_precise_tiers` to keep the parent method within the
|
|
664
|
+
# cyclomatic-complexity limit.
|
|
665
|
+
def dispatch_stdlib_module_tiers(receiver_type, method_name, arg_types)
|
|
666
|
+
FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
667
|
+
ShellwordsFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
668
|
+
MathFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
669
|
+
TimeFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
670
|
+
RegexpFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
671
|
+
CGIFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
672
|
+
URIFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
|
|
673
|
+
SetFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types)
|
|
674
|
+
end
|
|
675
|
+
|
|
654
676
|
def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node = nil)
|
|
655
677
|
return nil if environment.nil?
|
|
656
678
|
|
|
@@ -771,6 +793,18 @@ module Rigor
|
|
|
771
793
|
array_lift = array_new_lift(receiver_type.class_name, arg_types)
|
|
772
794
|
return array_lift if array_lift
|
|
773
795
|
|
|
796
|
+
range_lift = range_new_lift(receiver_type.class_name, arg_types)
|
|
797
|
+
return range_lift if range_lift
|
|
798
|
+
|
|
799
|
+
set_lift = set_new_lift(receiver_type.class_name, arg_types)
|
|
800
|
+
return set_lift if set_lift
|
|
801
|
+
|
|
802
|
+
regexp_lift = regexp_new_lift(receiver_type.class_name, arg_types)
|
|
803
|
+
return regexp_lift if regexp_lift
|
|
804
|
+
|
|
805
|
+
date_lift = date_new_lift(receiver_type.class_name, arg_types)
|
|
806
|
+
return date_lift if date_lift
|
|
807
|
+
|
|
774
808
|
Type::Combinator.nominal_of(receiver_type.class_name)
|
|
775
809
|
end
|
|
776
810
|
|
|
@@ -840,6 +874,119 @@ module Rigor
|
|
|
840
874
|
type
|
|
841
875
|
end
|
|
842
876
|
|
|
877
|
+
# `Range.new(b, e)` / `Range.new(b, e, excl)` — folds to
|
|
878
|
+
# `Constant[Range]` when both endpoints are `Constant[Integer]`
|
|
879
|
+
# or both are `Constant[String]`, and the optional third argument
|
|
880
|
+
# is a `Constant[true/false]`. Nil endpoints (beginless /
|
|
881
|
+
# endless ranges) are not folded because the useful instance
|
|
882
|
+
# methods (`to_a`, `first`, `last`) are not defined for them;
|
|
883
|
+
# the RBS tier answers `Nominal[Range]` for those forms.
|
|
884
|
+
def range_new_lift(class_name, arg_types)
|
|
885
|
+
return nil unless class_name == "Range"
|
|
886
|
+
return nil if arg_types.size < 2 || arg_types.size > 3
|
|
887
|
+
|
|
888
|
+
b_type = arg_types[0]
|
|
889
|
+
e_type = arg_types[1]
|
|
890
|
+
|
|
891
|
+
return nil unless b_type.is_a?(Type::Constant) && e_type.is_a?(Type::Constant)
|
|
892
|
+
|
|
893
|
+
b_val = b_type.value
|
|
894
|
+
e_val = e_type.value
|
|
895
|
+
|
|
896
|
+
# Only fold homogeneous Integer or String endpoint pairs.
|
|
897
|
+
return nil unless b_val.instance_of?(e_val.class)
|
|
898
|
+
return nil unless b_val.is_a?(Integer) || b_val.is_a?(String)
|
|
899
|
+
|
|
900
|
+
excl = range_new_excl(arg_types[2])
|
|
901
|
+
return nil if excl.nil?
|
|
902
|
+
|
|
903
|
+
Type::Combinator.constant_of(Range.new(b_val, e_val, excl))
|
|
904
|
+
rescue StandardError
|
|
905
|
+
nil
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# Resolves the optional `exclude_end` argument for `range_new_lift`.
|
|
909
|
+
# Returns `false` (no arg), `true`/`false` (Constant[bool] arg),
|
|
910
|
+
# or `nil` to signal "decline" (wrong type / wrong value class).
|
|
911
|
+
def range_new_excl(excl_type)
|
|
912
|
+
case excl_type
|
|
913
|
+
when nil then false
|
|
914
|
+
when Type::Constant
|
|
915
|
+
excl_type.value if [true, false].include?(excl_type.value)
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# `Set.new` / `Set.new(tuple_of_constants)` — folds to `Constant[Set]`
|
|
920
|
+
# when zero arguments are given or the single argument is a `Tuple`
|
|
921
|
+
# whose every element is a `Constant[T]`. Mirrors `SetFolding#fold_new`
|
|
922
|
+
# but lives here so the `:new` path in `try_meta_introspection` can
|
|
923
|
+
# reach it before the RBS tier answers `Nominal[Set]`.
|
|
924
|
+
def set_new_lift(class_name, arg_types)
|
|
925
|
+
return nil unless class_name == "Set"
|
|
926
|
+
return Type::Combinator.constant_of(::Set.new) if arg_types.empty?
|
|
927
|
+
return nil if arg_types.size > 1
|
|
928
|
+
|
|
929
|
+
arg = arg_types.first
|
|
930
|
+
return nil unless arg.is_a?(Type::Tuple)
|
|
931
|
+
return nil unless arg.elements.all?(Type::Constant)
|
|
932
|
+
|
|
933
|
+
values = arg.elements.map(&:value)
|
|
934
|
+
Type::Combinator.constant_of(::Set.new(values))
|
|
935
|
+
rescue StandardError
|
|
936
|
+
nil
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
# `Regexp.new(pattern)` / `Regexp.new(pattern, opts)` — folds to
|
|
940
|
+
# `Constant[Regexp]` when the pattern is a `Constant[String]`.
|
|
941
|
+
# Mirrors `RegexpFolding#fold_new` but lives here so the `:new` path
|
|
942
|
+
# in `try_meta_introspection` can reach it.
|
|
943
|
+
def regexp_new_lift(class_name, arg_types)
|
|
944
|
+
return nil unless class_name == "Regexp"
|
|
945
|
+
return nil if arg_types.empty? || arg_types.size > 2
|
|
946
|
+
|
|
947
|
+
pattern_arg = arg_types.first
|
|
948
|
+
return nil unless pattern_arg.is_a?(Type::Constant) && pattern_arg.value.is_a?(String)
|
|
949
|
+
|
|
950
|
+
opts = if arg_types.size == 2
|
|
951
|
+
opt_type = arg_types[1]
|
|
952
|
+
return nil unless opt_type.is_a?(Type::Constant)
|
|
953
|
+
|
|
954
|
+
opt_type.value
|
|
955
|
+
else
|
|
956
|
+
0
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
Type::Combinator.constant_of(Regexp.new(pattern_arg.value, opts))
|
|
960
|
+
rescue StandardError
|
|
961
|
+
nil
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# `Date.new(y, m, d)` / `DateTime.new(y, m, d, h, min, s, off)`
|
|
965
|
+
# — folds to `Constant[Date]` / `Constant[DateTime]` when every
|
|
966
|
+
# argument is a `Constant` carrying an Integer (or, for the
|
|
967
|
+
# `DateTime` offset / `start` slots, a Rational or String).
|
|
968
|
+
# `Date.new` carries no timezone, and `DateTime.new`'s offset
|
|
969
|
+
# defaults to UTC, so the literal is machine-independent.
|
|
970
|
+
# `Date.today` / `Date.parse` are not constructors here — they
|
|
971
|
+
# are non-deterministic / string-dependent and stay
|
|
972
|
+
# `Nominal[Date]`.
|
|
973
|
+
DATE_NEW_CLASSES = %w[Date DateTime].freeze
|
|
974
|
+
private_constant :DATE_NEW_CLASSES
|
|
975
|
+
|
|
976
|
+
def date_new_lift(class_name, arg_types)
|
|
977
|
+
return nil unless DATE_NEW_CLASSES.include?(class_name)
|
|
978
|
+
return nil unless arg_types.size.between?(1, 8)
|
|
979
|
+
return nil unless arg_types.all?(Type::Constant)
|
|
980
|
+
|
|
981
|
+
values = arg_types.map(&:value)
|
|
982
|
+
return nil unless values.all? { |v| v.is_a?(Integer) || v.is_a?(Rational) || v.is_a?(String) }
|
|
983
|
+
|
|
984
|
+
klass = class_name == "Date" ? Date : DateTime
|
|
985
|
+
Type::Combinator.constant_of(klass.new(*values))
|
|
986
|
+
rescue StandardError
|
|
987
|
+
nil
|
|
988
|
+
end
|
|
989
|
+
|
|
843
990
|
CONSTANT_METACLASSES = {
|
|
844
991
|
Integer => "Integer", Float => "Float", String => "String",
|
|
845
992
|
Symbol => "Symbol", Range => "Range",
|
|
@@ -43,10 +43,15 @@ module Rigor
|
|
|
43
43
|
# method (either `def self.foo` or a `def foo` inside
|
|
44
44
|
# `class << self`); routes the lookup through
|
|
45
45
|
# `RbsLoader#singleton_method`.
|
|
46
|
-
|
|
46
|
+
# @param source_path [String, nil] the project-relative path of
|
|
47
|
+
# the file the method is defined in. Used to match ADR-28
|
|
48
|
+
# path-scoped protocol contracts; `nil` (the default for
|
|
49
|
+
# synthetic / probe scopes) disables the contract tier.
|
|
50
|
+
def initialize(environment:, class_path:, singleton:, source_path: nil)
|
|
47
51
|
@environment = environment
|
|
48
52
|
@class_path = class_path
|
|
49
53
|
@singleton = singleton
|
|
54
|
+
@source_path = source_path
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
# @param def_node [Prism::DefNode]
|
|
@@ -58,15 +63,22 @@ module Rigor
|
|
|
58
63
|
types = default_types_for(slots)
|
|
59
64
|
|
|
60
65
|
rbs_method = lookup_rbs_method(def_node)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
if rbs_method
|
|
67
|
+
apply_rbs_overloads(types, slots, rbs_method.method_types) unless rbs_method.method_types.empty?
|
|
68
|
+
# `rigor:v1:param: <name> <refinement>` annotations
|
|
69
|
+
# tighten the bound type for matching slots. Applied
|
|
70
|
+
# after the RBS-overload pass so the override is the
|
|
71
|
+
# authoritative answer regardless of what the RBS
|
|
72
|
+
# signature declared.
|
|
73
|
+
apply_param_overrides(types, slots, rbs_method)
|
|
74
|
+
end
|
|
75
|
+
# ADR-28 — a path-scoped protocol contract supplies the
|
|
76
|
+
# parameter type for a matching `def`. Applied last (most
|
|
77
|
+
# authoritative) and regardless of RBS presence: the
|
|
78
|
+
# methods a contract targets — controller actions and the
|
|
79
|
+
# like — typically have no RBS signature at all, so this
|
|
80
|
+
# tier must run even when `rbs_method` is nil.
|
|
81
|
+
apply_protocol_contract(types, slots, def_node)
|
|
70
82
|
types
|
|
71
83
|
end
|
|
72
84
|
|
|
@@ -75,6 +87,10 @@ module Rigor
|
|
|
75
87
|
ParamSlot = Data.define(:kind, :name, :index)
|
|
76
88
|
private_constant :ParamSlot
|
|
77
89
|
|
|
90
|
+
# Slot kinds a contract's positional `index` can address.
|
|
91
|
+
POSITIONAL_KINDS = %i[required_positional optional_positional].freeze
|
|
92
|
+
private_constant :POSITIONAL_KINDS
|
|
93
|
+
|
|
78
94
|
# Walk the Prism `ParametersNode` and emit one slot per named
|
|
79
95
|
# parameter, in declaration order. Anonymous slots (rest /
|
|
80
96
|
# keyword-rest with no name) are skipped because we have no
|
|
@@ -187,6 +203,47 @@ module Rigor
|
|
|
187
203
|
end
|
|
188
204
|
end
|
|
189
205
|
|
|
206
|
+
# ADR-28 — when a path-scoped protocol contract targets this
|
|
207
|
+
# `def` (file path matches the contract's `path_glob`, method
|
|
208
|
+
# name + singleton-ness match), replace each contracted
|
|
209
|
+
# positional slot's binding with the contract's declared type.
|
|
210
|
+
# The type name resolves against the environment lazily here;
|
|
211
|
+
# an unresolvable name (the protocol's RBS not loaded) falls
|
|
212
|
+
# through to whatever the prior tiers bound, fail-soft.
|
|
213
|
+
def apply_protocol_contract(types, slots, def_node)
|
|
214
|
+
return if @source_path.nil?
|
|
215
|
+
|
|
216
|
+
registry = @environment.respond_to?(:plugin_registry) ? @environment.plugin_registry : nil
|
|
217
|
+
return if registry.nil?
|
|
218
|
+
|
|
219
|
+
contract = matching_contract(registry, def_node)
|
|
220
|
+
return if contract.nil?
|
|
221
|
+
|
|
222
|
+
contract.param_types.each do |param_type|
|
|
223
|
+
slot = positional_slot_at(slots, param_type.index)
|
|
224
|
+
next if slot.nil? || slot.name.nil?
|
|
225
|
+
|
|
226
|
+
resolved = @environment.nominal_for_name(param_type.type_name)
|
|
227
|
+
next if resolved.nil?
|
|
228
|
+
|
|
229
|
+
types[slot.name] = resolved
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def matching_contract(registry, def_node)
|
|
234
|
+
contracts = registry.contracts_for_path(@source_path)
|
|
235
|
+
return nil if contracts.empty?
|
|
236
|
+
|
|
237
|
+
singleton = def_node.receiver.is_a?(Prism::SelfNode) || @singleton
|
|
238
|
+
contracts.find do |contract|
|
|
239
|
+
contract.method_name == def_node.name && contract.singleton == singleton
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def positional_slot_at(slots, index)
|
|
244
|
+
slots.find { |slot| POSITIONAL_KINDS.include?(slot.kind) && slot.index == index }
|
|
245
|
+
end
|
|
246
|
+
|
|
190
247
|
def collect_translated_types(method_types, slot)
|
|
191
248
|
rbs_types = method_types.flat_map do |mt|
|
|
192
249
|
t = rbs_type_for_slot(mt.type, slot)
|
|
@@ -386,8 +386,8 @@ module Rigor
|
|
|
386
386
|
analyse_local_read(node, scope)
|
|
387
387
|
when Prism::LocalVariableWriteNode
|
|
388
388
|
analyse_local_write(node, scope)
|
|
389
|
-
when Prism::InstanceVariableWriteNode
|
|
390
|
-
|
|
389
|
+
when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
|
|
390
|
+
analyse_ivar(node, scope)
|
|
391
391
|
when Prism::ClassVariableWriteNode
|
|
392
392
|
analyse_cvar_write(node, scope)
|
|
393
393
|
when Prism::GlobalVariableWriteNode
|
|
@@ -767,7 +767,17 @@ module Rigor
|
|
|
767
767
|
]
|
|
768
768
|
end
|
|
769
769
|
|
|
770
|
-
|
|
770
|
+
# Truthy guard on an instance variable. Covers both the bare
|
|
771
|
+
# read — `if @ivar`, the left arm of `@ivar && @ivar.foo`,
|
|
772
|
+
# the receiver of `unless @ivar.nil?` once `!` reverses it —
|
|
773
|
+
# and the assignment-in-condition write `if @ivar = expr`.
|
|
774
|
+
# Mirrors `analyse_local_read` / `analyse_local_write`: the
|
|
775
|
+
# truthy edge narrows the ivar by `narrow_truthy` (dropping
|
|
776
|
+
# `nil` / `false`), the falsey edge by `narrow_falsey`. The
|
|
777
|
+
# narrowing is scoped to the guarded branch only, so it is
|
|
778
|
+
# purely additive — it never widens or re-narrows the ivar
|
|
779
|
+
# binding seen elsewhere.
|
|
780
|
+
def analyse_ivar(node, scope)
|
|
771
781
|
current = scope.ivar(node.name)
|
|
772
782
|
return nil if current.nil?
|
|
773
783
|
|
|
@@ -2024,15 +2034,24 @@ module Rigor
|
|
|
2024
2034
|
end
|
|
2025
2035
|
|
|
2026
2036
|
def analyse_nil_predicate(receiver, scope)
|
|
2027
|
-
|
|
2037
|
+
case receiver
|
|
2038
|
+
when Prism::LocalVariableReadNode
|
|
2039
|
+
current = scope.local(receiver.name)
|
|
2040
|
+
return nil if current.nil?
|
|
2028
2041
|
|
|
2029
|
-
|
|
2030
|
-
|
|
2042
|
+
[
|
|
2043
|
+
scope.with_local(receiver.name, narrow_nil(current)),
|
|
2044
|
+
scope.with_local(receiver.name, narrow_non_nil(current))
|
|
2045
|
+
]
|
|
2046
|
+
when Prism::InstanceVariableReadNode
|
|
2047
|
+
current = scope.ivar(receiver.name)
|
|
2048
|
+
return nil if current.nil?
|
|
2031
2049
|
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2050
|
+
[
|
|
2051
|
+
scope.with_ivar(receiver.name, narrow_nil(current)),
|
|
2052
|
+
scope.with_ivar(receiver.name, narrow_non_nil(current))
|
|
2053
|
+
]
|
|
2054
|
+
end
|
|
2036
2055
|
end
|
|
2037
2056
|
|
|
2038
2057
|
# `a && b` short-circuits: the truthy edge is the truthy edge
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../source/node_walker"
|
|
4
|
+
require_relative "../scope"
|
|
5
|
+
require_relative "scope_indexer"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Inference
|
|
9
|
+
# Measures the *type quality* of inferred expressions — not whether the
|
|
10
|
+
# engine recognises an AST node class (that is `CoverageScanner`'s job),
|
|
11
|
+
# but whether the type it produces carries useful static information.
|
|
12
|
+
#
|
|
13
|
+
# Each visited node is classified into one of eight precision tiers:
|
|
14
|
+
#
|
|
15
|
+
# :constant — Constant[T]: literal value known exactly
|
|
16
|
+
# :nominal — Nominal/Singleton: class identity known
|
|
17
|
+
# :shaped — Tuple/HashShape/IntegerRange/App: structure known
|
|
18
|
+
# :refined — Refined: narrowed by a predicate/assertion
|
|
19
|
+
# :bot — Bot: unreachable branch (definitively precise)
|
|
20
|
+
# :dynamic_specific — Dynamic[X] where X is not Top: origin partial
|
|
21
|
+
# :dynamic_top — Dynamic[Top]: completely opaque (the "untyped" hole)
|
|
22
|
+
# :top — Top: universal supertype (no information)
|
|
23
|
+
#
|
|
24
|
+
# The summary exposes `precision_ratio` (constant+nominal+shaped+refined+bot
|
|
25
|
+
# over total) and `opaque_ratio` (dynamic_top+top over total).
|
|
26
|
+
#
|
|
27
|
+
# For Union types the *worst* member tier is used, since the union is only
|
|
28
|
+
# as precise as its least-precise constituent. Intersection uses the *best*
|
|
29
|
+
# member (the most specific side wins). Difference follows its base type.
|
|
30
|
+
class PrecisionScanner
|
|
31
|
+
TIERS = %i[
|
|
32
|
+
constant nominal shaped refined bot
|
|
33
|
+
dynamic_specific dynamic_top top
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
TIER_RANK = TIERS.each_with_index.to_h.freeze
|
|
37
|
+
private_constant :TIER_RANK
|
|
38
|
+
|
|
39
|
+
PRECISE_TIERS = %i[constant nominal shaped refined bot].to_set.freeze
|
|
40
|
+
private_constant :PRECISE_TIERS
|
|
41
|
+
|
|
42
|
+
# Per-file result. Immutable value object.
|
|
43
|
+
class FileResult < Data.define(:total, :tier_counts)
|
|
44
|
+
def precise_count
|
|
45
|
+
PRECISE_TIERS.sum { |t| tier_counts.fetch(t, 0) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dynamic_top_count
|
|
49
|
+
tier_counts.fetch(:dynamic_top, 0)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dynamic_specific_count
|
|
53
|
+
tier_counts.fetch(:dynamic_specific, 0)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dynamic_count
|
|
57
|
+
dynamic_top_count + dynamic_specific_count
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def opaque_count
|
|
61
|
+
tier_counts.fetch(:dynamic_top, 0) + tier_counts.fetch(:top, 0)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def precision_ratio
|
|
65
|
+
return 1.0 if total.zero?
|
|
66
|
+
|
|
67
|
+
precise_count.fdiv(total)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def opaque_ratio
|
|
71
|
+
return 0.0 if total.zero?
|
|
72
|
+
|
|
73
|
+
opaque_count.fdiv(total)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param scope [Rigor::Scope] base scope for type inference.
|
|
78
|
+
def initialize(scope: nil)
|
|
79
|
+
@scope = scope || Scope.empty
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param root [Prism::Node] the parsed AST
|
|
83
|
+
# @return [FileResult]
|
|
84
|
+
def scan(root)
|
|
85
|
+
scope_index = ScopeIndexer.index(root, default_scope: @scope)
|
|
86
|
+
tier_counts = TIERS.to_h { |t| [t, 0] }
|
|
87
|
+
total = 0
|
|
88
|
+
|
|
89
|
+
Source::NodeWalker.each(root) do |node|
|
|
90
|
+
type = scope_index[node].type_of(node)
|
|
91
|
+
tier = classify(type)
|
|
92
|
+
tier_counts[tier] += 1
|
|
93
|
+
total += 1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
FileResult.new(total: total, tier_counts: tier_counts)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def classify(type)
|
|
102
|
+
case type
|
|
103
|
+
when Type::Bot then :bot
|
|
104
|
+
when Type::Top then :top
|
|
105
|
+
when Type::Constant then :constant
|
|
106
|
+
when Type::Nominal, Type::Singleton then :nominal
|
|
107
|
+
when Type::Tuple, Type::HashShape,
|
|
108
|
+
Type::IntegerRange, Type::App then :shaped
|
|
109
|
+
when Type::Refined then :refined
|
|
110
|
+
when Type::Dynamic then classify_dynamic(type)
|
|
111
|
+
when Type::Union then worst_of(type.members)
|
|
112
|
+
when Type::Intersection then best_of(type.members)
|
|
113
|
+
when Type::Difference then classify(type.base)
|
|
114
|
+
else :dynamic_top
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def classify_dynamic(type)
|
|
119
|
+
type.static_facet.is_a?(Type::Top) ? :dynamic_top : :dynamic_specific
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def worst_of(members)
|
|
123
|
+
members.map { |m| classify(m) }.max_by { |t| TIER_RANK[t] } || :dynamic_top
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def best_of(members)
|
|
127
|
+
members.map { |m| classify(m) }.min_by { |t| TIER_RANK[t] } || :dynamic_top
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -347,7 +347,20 @@ module Rigor
|
|
|
347
347
|
# result type is precise (`Constant[:even]` instead of the
|
|
348
348
|
# joined `Constant[:even] | Constant[:odd]`).
|
|
349
349
|
live = live_branch_for_if(node, pred_type, post_pred)
|
|
350
|
-
|
|
350
|
+
if live
|
|
351
|
+
live_type, _live_scope = live
|
|
352
|
+
# When the provably-live then-branch terminates and there is no
|
|
353
|
+
# else, apply the same falsey-scope narrowing as the standard
|
|
354
|
+
# early-return path below. Without this, `return if @ivar.nil?`
|
|
355
|
+
# with an ivar seeded as Constant[nil] (making nil? = Constant[true]
|
|
356
|
+
# and the then-branch "provably live") propagates the un-narrowed
|
|
357
|
+
# nil scope past the guard instead of Bot.
|
|
358
|
+
if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
|
|
359
|
+
_, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
360
|
+
return [live_type, falsey_scope]
|
|
361
|
+
end
|
|
362
|
+
return live
|
|
363
|
+
end
|
|
351
364
|
|
|
352
365
|
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
353
366
|
then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
|
|
@@ -376,7 +389,18 @@ module Rigor
|
|
|
376
389
|
pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
377
390
|
|
|
378
391
|
live = live_branch_for_unless(node, pred_type, post_pred)
|
|
379
|
-
|
|
392
|
+
if live
|
|
393
|
+
live_type, _live_scope = live
|
|
394
|
+
# Mirror of the eval_if fix: when the provably-live unless-body
|
|
395
|
+
# terminates and there is no else, apply the truthy-scope narrowing
|
|
396
|
+
# so `return unless @ivar` with a nil-seeded ivar doesn't propagate
|
|
397
|
+
# the nil scope past the guard.
|
|
398
|
+
if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
|
|
399
|
+
truthy_scope, = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
400
|
+
return [live_type, truthy_scope]
|
|
401
|
+
end
|
|
402
|
+
return live
|
|
403
|
+
end
|
|
380
404
|
|
|
381
405
|
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
382
406
|
then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
|
|
@@ -1421,7 +1445,8 @@ module Rigor
|
|
|
1421
1445
|
binder = MethodParameterBinder.new(
|
|
1422
1446
|
environment: scope.environment,
|
|
1423
1447
|
class_path: current_class_path,
|
|
1424
|
-
singleton: singleton
|
|
1448
|
+
singleton: singleton,
|
|
1449
|
+
source_path: scope.source_path
|
|
1425
1450
|
)
|
|
1426
1451
|
bindings = binder.bind(def_node)
|
|
1427
1452
|
|
|
@@ -1486,6 +1511,7 @@ module Rigor
|
|
|
1486
1511
|
# remain reachable from inside nested bodies.
|
|
1487
1512
|
def build_fresh_body_scope # rubocop:disable Metrics/AbcSize
|
|
1488
1513
|
Scope.empty(environment: scope.environment)
|
|
1514
|
+
.with_source_path(scope.source_path)
|
|
1489
1515
|
.with_declared_types(scope.declared_types)
|
|
1490
1516
|
.with_discovered_classes(scope.discovered_classes)
|
|
1491
1517
|
.with_in_source_constants(scope.in_source_constants)
|