rigortype 0.1.0 → 0.1.1
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 +7 -2
- data/data/builtins/ruby_core/array.yml +6 -6
- data/data/builtins/ruby_core/hash.yml +1 -1
- data/data/builtins/ruby_core/io.yml +3 -3
- data/data/builtins/ruby_core/numeric.yml +1 -1
- data/data/builtins/ruby_core/pathname.yml +100 -100
- data/data/builtins/ruby_core/proc.yml +1 -1
- data/data/builtins/ruby_core/time.yml +3 -3
- data/lib/rigor/analysis/runner.rb +88 -5
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- data/lib/rigor/cli/type_of_command.rb +3 -3
- data/lib/rigor/cli/type_scan_command.rb +4 -4
- data/lib/rigor/cli.rb +11 -4
- data/lib/rigor/configuration.rb +177 -10
- data/lib/rigor/environment.rb +12 -4
- data/lib/rigor/inference/expression_typer.rb +3 -1
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +43 -2
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/narrowing.rb +150 -6
- data/lib/rigor/inference/scope_indexer.rb +49 -15
- data/lib/rigor/inference/statement_evaluator.rb +29 -0
- data/lib/rigor/plugin/base.rb +43 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/load_error.rb +14 -2
- data/lib/rigor/plugin/loader.rb +116 -0
- data/lib/rigor/plugin/manifest.rb +75 -6
- data/lib/rigor/plugin/services.rb +14 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor.rbs +8 -2
- metadata +3 -1
|
@@ -42,14 +42,45 @@ module Rigor
|
|
|
42
42
|
}.freeze
|
|
43
43
|
private_constant :NUMERIC_CONSTRUCTORS
|
|
44
44
|
|
|
45
|
+
# `Kernel#Integer(s)` predicate-aware refinement set
|
|
46
|
+
# (v0.1.1 Track 1 slice 2b). Both `decimal-int-string` and
|
|
47
|
+
# `numeric-string` describe digit-only ASCII strings, so
|
|
48
|
+
# `Integer(s)` is total over the carrier domain and the
|
|
49
|
+
# result is `>= 0`. The default `base: 10` invocation
|
|
50
|
+
# accepts the same shape `String#to_i` does for these
|
|
51
|
+
# predicates; the `Integer(s, base)` overload is left for
|
|
52
|
+
# a later slice.
|
|
53
|
+
INTEGER_REFINEMENT_PREDICATES = Set[:decimal_int, :numeric].freeze
|
|
54
|
+
private_constant :INTEGER_REFINEMENT_PREDICATES
|
|
55
|
+
|
|
45
56
|
def try_dispatch(receiver:, method_name:, args:)
|
|
46
57
|
return nil if receiver.nil?
|
|
47
58
|
return try_array(args) if method_name == :Array
|
|
48
59
|
return try_numeric_constructor(method_name, args) if NUMERIC_CONSTRUCTORS.key?(method_name)
|
|
60
|
+
return try_integer_from_refinement(args) if method_name == :Integer
|
|
49
61
|
|
|
50
62
|
nil
|
|
51
63
|
end
|
|
52
64
|
|
|
65
|
+
# `Kernel#Integer(s)` over a `Refined[String, predicate]`
|
|
66
|
+
# whose predicate is in {INTEGER_REFINEMENT_PREDICATES}.
|
|
67
|
+
# Mirrors the `String#to_i` projection in `ShapeDispatch`
|
|
68
|
+
# (v0.1.1 slice 2a) — the result is always
|
|
69
|
+
# `non-negative-int`. Returns nil for any other arg shape
|
|
70
|
+
# so the RBS tier handles the generic `Integer(arg)` case.
|
|
71
|
+
def try_integer_from_refinement(args)
|
|
72
|
+
return nil unless args.size == 1
|
|
73
|
+
|
|
74
|
+
arg = args.first
|
|
75
|
+
return nil unless arg.is_a?(Type::Refined)
|
|
76
|
+
|
|
77
|
+
base = arg.base
|
|
78
|
+
return nil unless base.is_a?(Type::Nominal) && base.class_name == "String"
|
|
79
|
+
return nil unless INTEGER_REFINEMENT_PREDICATES.include?(arg.predicate_id)
|
|
80
|
+
|
|
81
|
+
Type::Combinator.non_negative_int
|
|
82
|
+
end
|
|
83
|
+
|
|
53
84
|
def try_array(args)
|
|
54
85
|
return nil if args.length != 1
|
|
55
86
|
|
|
@@ -52,7 +52,27 @@ module Rigor
|
|
|
52
52
|
|
|
53
53
|
CONCAT_METHODS = %i[+ << concat].freeze
|
|
54
54
|
FORMAT_METHODS = %i[format sprintf].freeze
|
|
55
|
-
|
|
55
|
+
# v0.1.1 Track 1 slice 5a — methods that, called with no
|
|
56
|
+
# arguments on a literal-bearing receiver, return a value
|
|
57
|
+
# that is also literal-bearing. `#strip` / `#lstrip` /
|
|
58
|
+
# `#rstrip` / `#chomp` (no-arg) / `#chop` strip a known
|
|
59
|
+
# subset of characters from the ends, so the survivors
|
|
60
|
+
# are always a substring of an already-literal value.
|
|
61
|
+
# `#scrub` (no-arg) replaces invalid bytes; a literal-string
|
|
62
|
+
# value comes from source code and is always valid UTF-8,
|
|
63
|
+
# so the result is identical to the receiver. None of
|
|
64
|
+
# these preserve `non-empty-string`-ness (e.g. `" ".strip
|
|
65
|
+
# == ""`); the carrier collapses from `non-empty-literal-string`
|
|
66
|
+
# down to plain `literal-string`.
|
|
67
|
+
LITERAL_PRESERVING_METHODS = %i[strip lstrip rstrip chomp chop scrub].freeze
|
|
68
|
+
# v0.1.1 Track 1 slice 5c — width-padding methods. `center`
|
|
69
|
+
# / `ljust` / `rjust` take a `width` Integer plus an
|
|
70
|
+
# optional literal padding `String`. When the receiver
|
|
71
|
+
# and the (default or supplied) padding are both
|
|
72
|
+
# literal-bearing, the result is literal-bearing too.
|
|
73
|
+
WIDTH_PADDING_METHODS = %i[center ljust rjust].freeze
|
|
74
|
+
private_constant :CONCAT_METHODS, :FORMAT_METHODS,
|
|
75
|
+
:LITERAL_PRESERVING_METHODS, :WIDTH_PADDING_METHODS
|
|
56
76
|
|
|
57
77
|
def try_dispatch(receiver:, method_name:, args:, **) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
58
78
|
return fold_array_join(receiver, args) if method_name == :join
|
|
@@ -61,6 +81,10 @@ module Rigor
|
|
|
61
81
|
return nil unless Type::Combinator.literal_string_compatible?(receiver)
|
|
62
82
|
|
|
63
83
|
return fold_string_percent(args) if method_name == :%
|
|
84
|
+
if args.empty?
|
|
85
|
+
return LITERAL_PRESERVING_METHODS.include?(method_name) ? Type::Combinator.literal_string : nil
|
|
86
|
+
end
|
|
87
|
+
return fold_width_pad(args) if WIDTH_PADDING_METHODS.include?(method_name)
|
|
64
88
|
return nil unless args.size == 1
|
|
65
89
|
|
|
66
90
|
if CONCAT_METHODS.include?(method_name)
|
|
@@ -70,6 +94,23 @@ module Rigor
|
|
|
70
94
|
end
|
|
71
95
|
end
|
|
72
96
|
|
|
97
|
+
# `String#center` / `#ljust` / `#rjust` — first argument is
|
|
98
|
+
# the target width (Integer-typed), optional second
|
|
99
|
+
# argument is the padding string (must be literal-bearing
|
|
100
|
+
# for the result to stay literal). The default padding
|
|
101
|
+
# (a space) is always literal so the no-second-arg form
|
|
102
|
+
# passes through. Width is allowed to be any Integer
|
|
103
|
+
# because Ruby's runtime accepts negative widths and
|
|
104
|
+
# widths smaller than the receiver's length without
|
|
105
|
+
# raising.
|
|
106
|
+
def fold_width_pad(args)
|
|
107
|
+
return nil unless [1, 2].include?(args.size)
|
|
108
|
+
return nil unless integer_typed?(args[0])
|
|
109
|
+
return nil if args.size == 2 && !Type::Combinator.literal_string_compatible?(args[1])
|
|
110
|
+
|
|
111
|
+
Type::Combinator.literal_string
|
|
112
|
+
end
|
|
113
|
+
|
|
73
114
|
def fold_concat(arg_type)
|
|
74
115
|
return nil unless Type::Combinator.literal_string_compatible?(arg_type)
|
|
75
116
|
|
|
@@ -165,7 +206,7 @@ module Rigor
|
|
|
165
206
|
end
|
|
166
207
|
|
|
167
208
|
private_class_method :fold_concat, :fold_repeat, :fold_array_join,
|
|
168
|
-
:fold_format, :fold_string_percent,
|
|
209
|
+
:fold_format, :fold_string_percent, :fold_width_pad,
|
|
169
210
|
:literal_or_constant?, :integer_typed?
|
|
170
211
|
end
|
|
171
212
|
end
|
|
@@ -122,10 +122,25 @@ module Rigor
|
|
|
122
122
|
Type::Nominal => :dispatch_nominal_size,
|
|
123
123
|
Type::Difference => :dispatch_difference,
|
|
124
124
|
Type::Refined => :dispatch_refined,
|
|
125
|
-
Type::Intersection => :dispatch_intersection
|
|
125
|
+
Type::Intersection => :dispatch_intersection,
|
|
126
|
+
Type::IntegerRange => :dispatch_integer_range
|
|
126
127
|
}.freeze
|
|
127
128
|
private_constant :RECEIVER_HANDLERS
|
|
128
129
|
|
|
130
|
+
# v0.1.1 Track 1 slice 5b — `Integer#to_s(base)` on a
|
|
131
|
+
# non-negative `IntegerRange` receiver. The output of
|
|
132
|
+
# `n.to_s(b)` for `n >= 0` is digit-string-only (no
|
|
133
|
+
# leading sign), so when the base is in this table the
|
|
134
|
+
# result lifts to the matching imported refinement.
|
|
135
|
+
# Bases not listed (2, 36, ...) keep the v0.1.0 baseline
|
|
136
|
+
# since Rigor has no carrier for the resulting alphabet.
|
|
137
|
+
TO_S_BASE_REFINEMENTS = {
|
|
138
|
+
10 => :decimal_int_string,
|
|
139
|
+
8 => :octal_int_string,
|
|
140
|
+
16 => :hex_int_string
|
|
141
|
+
}.freeze
|
|
142
|
+
private_constant :TO_S_BASE_REFINEMENTS
|
|
143
|
+
|
|
129
144
|
def try_dispatch(receiver:, method_name:, args:)
|
|
130
145
|
args ||= []
|
|
131
146
|
handler = RECEIVER_HANDLERS[receiver.class]
|
|
@@ -184,6 +199,42 @@ module Rigor
|
|
|
184
199
|
Type::Combinator.non_negative_int
|
|
185
200
|
end
|
|
186
201
|
|
|
202
|
+
# `IntegerRange#to_s` precision (v0.1.1 Track 1 slice 5b).
|
|
203
|
+
# When the range's lower bound is `>= 0`, every member is
|
|
204
|
+
# a non-negative integer and `to_s(base)` returns a
|
|
205
|
+
# digit-string with no leading sign. The result lifts to
|
|
206
|
+
# the matching imported refinement (`decimal-int-string`
|
|
207
|
+
# for base 10, `octal-int-string` for 8, `hex-int-string`
|
|
208
|
+
# for 16). Signed ranges fall through (the result could
|
|
209
|
+
# carry a `-` sign that no Rigor refinement currently
|
|
210
|
+
# captures), as do bases without a digit-only refinement.
|
|
211
|
+
def dispatch_integer_range(range, method_name, args)
|
|
212
|
+
return nil unless method_name == :to_s
|
|
213
|
+
return nil unless range.lower >= 0
|
|
214
|
+
|
|
215
|
+
base = base_argument(args)
|
|
216
|
+
return nil if base.nil?
|
|
217
|
+
|
|
218
|
+
refinement = TO_S_BASE_REFINEMENTS[base]
|
|
219
|
+
return nil if refinement.nil?
|
|
220
|
+
|
|
221
|
+
Type::Combinator.public_send(refinement)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# `to_s` with no argument defaults to base 10. With one
|
|
225
|
+
# argument, the value MUST be a `Constant<Integer>` to
|
|
226
|
+
# be statically known. Anything else (Nominal[Integer]
|
|
227
|
+
# arg, multi-arg, etc.) declines.
|
|
228
|
+
def base_argument(args)
|
|
229
|
+
return 10 if args.empty?
|
|
230
|
+
return nil unless args.size == 1
|
|
231
|
+
|
|
232
|
+
arg = args.first
|
|
233
|
+
return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Integer)
|
|
234
|
+
|
|
235
|
+
arg.value
|
|
236
|
+
end
|
|
237
|
+
|
|
187
238
|
# Refinement-aware projections over a `Difference[base,
|
|
188
239
|
# removed]` receiver. When the removed value is the
|
|
189
240
|
# empty witness of the base (`Constant[""]` for
|
|
@@ -289,7 +340,21 @@ module Rigor
|
|
|
289
340
|
%i[octal_int downcase] => :refined_self,
|
|
290
341
|
%i[octal_int upcase] => :refined_self,
|
|
291
342
|
%i[hex_int downcase] => :refined_self,
|
|
292
|
-
%i[hex_int upcase] => :refined_self
|
|
343
|
+
%i[hex_int upcase] => :refined_self,
|
|
344
|
+
# v0.1.1 Track 1 slice 2 — `to_i` / `to_int` on a
|
|
345
|
+
# known digit-only string. `decimal-int-string`
|
|
346
|
+
# (`/\A\d+\z/`) and `numeric-string` (Rigor's
|
|
347
|
+
# numeric-string predicate, ASCII digits) are
|
|
348
|
+
# predicates over digit-only strings, so the parse
|
|
349
|
+
# is total over the carrier domain and the result
|
|
350
|
+
# is always `>= 0`. `non-negative-int` is the
|
|
351
|
+
# tightest carrier that captures both the lower
|
|
352
|
+
# bound and the integer-ness without inventing a
|
|
353
|
+
# narrower carrier.
|
|
354
|
+
%i[decimal_int to_i] => :non_negative_int,
|
|
355
|
+
%i[decimal_int to_int] => :non_negative_int,
|
|
356
|
+
%i[numeric to_i] => :non_negative_int,
|
|
357
|
+
%i[numeric to_int] => :non_negative_int
|
|
293
358
|
}.freeze
|
|
294
359
|
private_constant :REFINED_STRING_PROJECTIONS
|
|
295
360
|
|
|
@@ -313,6 +378,7 @@ module Rigor
|
|
|
313
378
|
when :refined_self then refined
|
|
314
379
|
when :uppercase_string then Type::Combinator.uppercase_string
|
|
315
380
|
when :lowercase_string then Type::Combinator.lowercase_string
|
|
381
|
+
when :non_negative_int then Type::Combinator.non_negative_int
|
|
316
382
|
end
|
|
317
383
|
end
|
|
318
384
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../reflection"
|
|
4
4
|
require_relative "../type"
|
|
5
|
+
require_relative "../flow_contribution"
|
|
6
|
+
require_relative "../flow_contribution/merger"
|
|
5
7
|
require_relative "method_dispatcher/constant_folding"
|
|
6
8
|
require_relative "method_dispatcher/literal_string_folding"
|
|
7
9
|
require_relative "method_dispatcher/shape_dispatch"
|
|
@@ -59,12 +61,25 @@ module Rigor
|
|
|
59
61
|
# @param environment [Rigor::Environment, nil] required for
|
|
60
62
|
# RBS-backed dispatch; when nil only constant folding can fire.
|
|
61
63
|
# @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
|
|
62
|
-
def dispatch(receiver_type:, method_name:, arg_types:,
|
|
64
|
+
def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/ParameterLists
|
|
65
|
+
block_type: nil, environment: nil,
|
|
66
|
+
call_node: nil, scope: nil)
|
|
63
67
|
return nil if receiver_type.nil?
|
|
64
68
|
|
|
65
69
|
precise = dispatch_precise_tiers(receiver_type, method_name, arg_types, block_type)
|
|
66
70
|
return precise if precise
|
|
67
71
|
|
|
72
|
+
# v0.1.1 Track 2 slice 7 — plugin return-type contribution
|
|
73
|
+
# tier. Sits ahead of `RbsDispatch` so a plugin that
|
|
74
|
+
# understands a domain-specific dispatch (e.g. an
|
|
75
|
+
# `ActiveRecord::Base.find` returning `Nominal[<resolved
|
|
76
|
+
# model>]`) wins over the RBS-projected envelope. Only
|
|
77
|
+
# consults the registry when both `call_node` and `scope`
|
|
78
|
+
# are supplied — the dispatcher's own internal callers
|
|
79
|
+
# (per-element block fold, etc.) skip this tier.
|
|
80
|
+
plugin_result = try_plugin_contribution(call_node, scope)
|
|
81
|
+
return plugin_result if plugin_result
|
|
82
|
+
|
|
68
83
|
rbs_result = RbsDispatch.try_dispatch(
|
|
69
84
|
receiver: receiver_type, method_name: method_name, args: arg_types,
|
|
70
85
|
environment: environment, block_type: block_type
|
|
@@ -85,6 +100,40 @@ module Rigor
|
|
|
85
100
|
try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
|
|
86
101
|
end
|
|
87
102
|
|
|
103
|
+
# ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
|
|
104
|
+
# slice 7. Walks every loaded plugin's
|
|
105
|
+
# `#flow_contribution_for(call_node:, scope:)` hook,
|
|
106
|
+
# collects the non-nil `FlowContribution` bundles, merges
|
|
107
|
+
# them through `FlowContribution::Merger`, and returns
|
|
108
|
+
# the merged `return_type` slot (or nil when no plugin
|
|
109
|
+
# contributed a return type).
|
|
110
|
+
#
|
|
111
|
+
# Plugins whose hook raises have their contribution
|
|
112
|
+
# silently dropped for this call so the dispatch chain
|
|
113
|
+
# keeps moving — the run-level diagnostic envelope (per
|
|
114
|
+
# ADR-2 § "Plugin Trust and I/O Policy") is owned by
|
|
115
|
+
# `Analysis::Runner#plugin_emitted_diagnostics`.
|
|
116
|
+
def try_plugin_contribution(call_node, scope)
|
|
117
|
+
return nil if call_node.nil? || scope.nil?
|
|
118
|
+
|
|
119
|
+
registry = scope.environment&.plugin_registry
|
|
120
|
+
return nil if registry.nil? || registry.empty?
|
|
121
|
+
|
|
122
|
+
contributions = collect_plugin_contributions(registry, call_node, scope)
|
|
123
|
+
return nil if contributions.empty?
|
|
124
|
+
|
|
125
|
+
FlowContribution::Merger.merge(contributions).return_type
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def collect_plugin_contributions(registry, call_node, scope)
|
|
129
|
+
registry.plugins.filter_map do |plugin|
|
|
130
|
+
contribution = plugin.flow_contribution_for(call_node: call_node, scope: scope)
|
|
131
|
+
contribution.is_a?(FlowContribution) ? contribution : nil
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
88
137
|
# Runs the precision tiers (constant fold, shape dispatch,
|
|
89
138
|
# file-path fold, block fold) in order and returns the first
|
|
90
139
|
# non-nil answer. Each tier owns its own receiver/argument
|
|
@@ -7,6 +7,7 @@ require_relative "../type"
|
|
|
7
7
|
require_relative "../environment"
|
|
8
8
|
require_relative "../rbs_extended"
|
|
9
9
|
require_relative "../analysis/fact_store"
|
|
10
|
+
require_relative "../builtins/regex_refinement"
|
|
10
11
|
|
|
11
12
|
module Rigor
|
|
12
13
|
module Inference
|
|
@@ -746,20 +747,51 @@ module Rigor
|
|
|
746
747
|
# `nil`. Subtract the dead half on each edge so callers
|
|
747
748
|
# like `year.upcase` inside the truthy branch no longer
|
|
748
749
|
# fire `possible-nil-receiver`.
|
|
750
|
+
#
|
|
751
|
+
# v0.1.1 Track 1 slice 1 — when the regex source is a
|
|
752
|
+
# statically known `RegularExpressionNode` and a named
|
|
753
|
+
# capture's body matches one of the curated shapes in
|
|
754
|
+
# {Rigor::Builtins::RegexRefinement::RULES}, the truthy
|
|
755
|
+
# branch narrows further than `String` to the matching
|
|
756
|
+
# imported refinement (e.g. `decimal-int-string` for
|
|
757
|
+
# `\d+`). Bodies outside the table fall back to the
|
|
758
|
+
# v0.1.0 baseline (plain `String`).
|
|
749
759
|
def analyse_match_write(node, scope)
|
|
750
760
|
string_t = Type::Combinator.nominal_of("String")
|
|
751
761
|
nil_t = Type::Combinator.constant_of(nil)
|
|
762
|
+
refinements = match_write_capture_refinements(node)
|
|
752
763
|
truthy = scope
|
|
753
764
|
falsey = scope
|
|
754
765
|
node.targets.each do |target|
|
|
755
766
|
next unless target.is_a?(Prism::LocalVariableTargetNode)
|
|
756
767
|
|
|
757
|
-
truthy = truthy.with_local(target.name, string_t)
|
|
768
|
+
truthy = truthy.with_local(target.name, refinements[target.name] || string_t)
|
|
758
769
|
falsey = falsey.with_local(target.name, nil_t)
|
|
759
770
|
end
|
|
760
771
|
[truthy, falsey]
|
|
761
772
|
end
|
|
762
773
|
|
|
774
|
+
# Extracts `{ capture_name => Refinement }` for every named
|
|
775
|
+
# capture group in the `MatchWriteNode`'s wrapped `=~` call
|
|
776
|
+
# whose body the recogniser table accepts. Bodies that
|
|
777
|
+
# contain nested groups, anchors, alternation, or anything
|
|
778
|
+
# else outside the curated forms drop out and the caller
|
|
779
|
+
# falls back to plain `String`.
|
|
780
|
+
NAMED_CAPTURE_BODY_RE = /\(\?<([A-Za-z_][A-Za-z0-9_]*)>([^()|]*)\)/
|
|
781
|
+
private_constant :NAMED_CAPTURE_BODY_RE
|
|
782
|
+
|
|
783
|
+
def match_write_capture_refinements(node)
|
|
784
|
+
regex = node.call.is_a?(Prism::CallNode) ? node.call.receiver : nil
|
|
785
|
+
return {} unless regex.is_a?(Prism::RegularExpressionNode)
|
|
786
|
+
|
|
787
|
+
refinements = {}
|
|
788
|
+
regex.unescaped.scan(NAMED_CAPTURE_BODY_RE) do |name, body|
|
|
789
|
+
type = ::Rigor::Builtins::RegexRefinement.for_capture_body(body)
|
|
790
|
+
refinements[name.to_sym] = type if type
|
|
791
|
+
end
|
|
792
|
+
refinements
|
|
793
|
+
end
|
|
794
|
+
|
|
763
795
|
# Recognised CallNode predicates:
|
|
764
796
|
# - `recv.nil?` (Slice 6 phase 1, no args, no block)
|
|
765
797
|
# - unary `!recv` (`name == :!`, no args, no block)
|
|
@@ -772,19 +804,82 @@ module Rigor
|
|
|
772
804
|
# through to the no-narrowing fallback.
|
|
773
805
|
def analyse_call(node, scope)
|
|
774
806
|
return nil if node.block
|
|
775
|
-
return nil if node.receiver.nil?
|
|
776
807
|
|
|
777
|
-
|
|
778
|
-
|
|
808
|
+
unless node.receiver.nil?
|
|
809
|
+
shape_result = dispatch_call(node, scope, node.name)
|
|
810
|
+
return shape_result if shape_result
|
|
811
|
+
|
|
812
|
+
# v0.1.1 Track 1 slice 4 — String predicate flow facts.
|
|
813
|
+
string_predicate_result = analyse_string_predicate(node, scope)
|
|
814
|
+
return string_predicate_result if string_predicate_result
|
|
815
|
+
end
|
|
779
816
|
|
|
780
817
|
# Slice 7 phase 15 — RBS::Extended predicate
|
|
781
818
|
# effects. When the method's RBS signature carries
|
|
782
819
|
# `rigor:v1:predicate-if-true` / `predicate-if-false`
|
|
783
820
|
# annotations, apply them to narrow the corresponding
|
|
784
|
-
# local-variable arguments on each edge.
|
|
821
|
+
# local-variable arguments on each edge. v0.1.1 Track
|
|
822
|
+
# 1 slice 3 — implicit-self calls (`recv == nil`,
|
|
823
|
+
# e.g. `admin?` inside an instance method body) flow
|
|
824
|
+
# through here too so `self`-targeted facts can edit
|
|
825
|
+
# `scope.self_type`.
|
|
785
826
|
analyse_rbs_extended_contribution(node, scope)
|
|
786
827
|
end
|
|
787
828
|
|
|
829
|
+
# v0.1.1 Track 1 slice 4 — `String#start_with?` /
|
|
830
|
+
# `#end_with?` / `#include?` against a `Constant<String>`
|
|
831
|
+
# needle attaches a relational `FactStore::Fact` to the
|
|
832
|
+
# local on each edge. The receiver's type does NOT
|
|
833
|
+
# change — Rigor has no "starts-with-X" carrier today —
|
|
834
|
+
# so the fact carries the predicate semantics for any
|
|
835
|
+
# downstream consumer that wants to read it (e.g. a
|
|
836
|
+
# plugin's `prepare(services)` hook in v0.1.x).
|
|
837
|
+
# Truthy edge: positive polarity. Falsey edge: negative
|
|
838
|
+
# polarity. Mirrors the equality-predicate fact pattern
|
|
839
|
+
# already used by `analyse_equality_predicate`.
|
|
840
|
+
STRING_PREDICATE_NAMES = %i[start_with? end_with? include?].freeze
|
|
841
|
+
private_constant :STRING_PREDICATE_NAMES
|
|
842
|
+
|
|
843
|
+
def analyse_string_predicate(node, scope)
|
|
844
|
+
return nil unless string_predicate_call?(node)
|
|
845
|
+
|
|
846
|
+
needle = string_predicate_needle(node, scope)
|
|
847
|
+
return nil if needle.nil?
|
|
848
|
+
|
|
849
|
+
local_name = node.receiver.name
|
|
850
|
+
return nil if scope.local(local_name).nil?
|
|
851
|
+
|
|
852
|
+
[
|
|
853
|
+
scope.with_fact(string_predicate_fact(local_name, node.name, needle, polarity: :positive)),
|
|
854
|
+
scope.with_fact(string_predicate_fact(local_name, node.name, needle, polarity: :negative))
|
|
855
|
+
]
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def string_predicate_call?(node)
|
|
859
|
+
STRING_PREDICATE_NAMES.include?(node.name) &&
|
|
860
|
+
node.receiver.is_a?(Prism::LocalVariableReadNode) &&
|
|
861
|
+
!node.arguments.nil? &&
|
|
862
|
+
node.arguments.arguments.size == 1
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def string_predicate_needle(node, scope)
|
|
866
|
+
needle_type = static_literal_type(node.arguments.arguments.first, scope)
|
|
867
|
+
return nil unless needle_type.is_a?(Type::Constant) && needle_type.value.is_a?(String)
|
|
868
|
+
|
|
869
|
+
needle_type.value
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
def string_predicate_fact(name, predicate, needle, polarity:)
|
|
873
|
+
Analysis::FactStore::Fact.new(
|
|
874
|
+
bucket: :relational,
|
|
875
|
+
target: Analysis::FactStore::Target.local(name),
|
|
876
|
+
predicate: predicate,
|
|
877
|
+
payload: needle,
|
|
878
|
+
polarity: polarity,
|
|
879
|
+
stability: :local_binding
|
|
880
|
+
)
|
|
881
|
+
end
|
|
882
|
+
|
|
788
883
|
ZERO_CLASS_PREDICATES = %i[positive? negative? zero? nonzero?].freeze
|
|
789
884
|
COMPARISON_OPERATORS = %i[< <= > >=].freeze
|
|
790
885
|
private_constant :ZERO_CLASS_PREDICATES, :COMPARISON_OPERATORS
|
|
@@ -1267,6 +1362,7 @@ module Rigor
|
|
|
1267
1362
|
|
|
1268
1363
|
def apply_fact_to_scope(fact, call_node, entry_scope, target_scope, method_def)
|
|
1269
1364
|
target_node = fact_target_node(fact, call_node, method_def)
|
|
1365
|
+
return apply_self_fact(fact, target_node, entry_scope, target_scope) if fact.target_kind == :self
|
|
1270
1366
|
return target_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
|
|
1271
1367
|
|
|
1272
1368
|
local_name = target_node.name
|
|
@@ -1277,6 +1373,45 @@ module Rigor
|
|
|
1277
1373
|
target_scope.with_local(local_name, narrowed)
|
|
1278
1374
|
end
|
|
1279
1375
|
|
|
1376
|
+
# v0.1.1 Track 1 slice 3 — `target_kind == :self` facts
|
|
1377
|
+
# (`predicate-if-true self is T`, `predicate-if-false
|
|
1378
|
+
# self is T`) narrow whichever scope binding is bound to
|
|
1379
|
+
# the call's receiver. Four receiver shapes participate:
|
|
1380
|
+
#
|
|
1381
|
+
# `recv.method?` where recv is...
|
|
1382
|
+
# - `LocalVariableReadNode` -> narrow that local
|
|
1383
|
+
# - `InstanceVariableReadNode` -> narrow that ivar
|
|
1384
|
+
# - `Prism::SelfNode` (explicit self) -> narrow `self_type`
|
|
1385
|
+
# - nil (implicit self call inside a method body)
|
|
1386
|
+
# -> narrow `self_type`
|
|
1387
|
+
#
|
|
1388
|
+
# Other receiver shapes (method chains, expressions) have
|
|
1389
|
+
# no scope binding to narrow against and fall through.
|
|
1390
|
+
def apply_self_fact(fact, receiver_node, entry_scope, target_scope)
|
|
1391
|
+
case receiver_node
|
|
1392
|
+
when nil, Prism::SelfNode
|
|
1393
|
+
current = entry_scope.self_type
|
|
1394
|
+
return target_scope if current.nil?
|
|
1395
|
+
|
|
1396
|
+
narrowed = narrow_for_fact(current, fact, entry_scope.environment)
|
|
1397
|
+
target_scope.with_self_type(narrowed)
|
|
1398
|
+
when Prism::LocalVariableReadNode
|
|
1399
|
+
current = entry_scope.local(receiver_node.name)
|
|
1400
|
+
return target_scope if current.nil?
|
|
1401
|
+
|
|
1402
|
+
narrowed = narrow_for_fact(current, fact, entry_scope.environment)
|
|
1403
|
+
target_scope.with_local(receiver_node.name, narrowed)
|
|
1404
|
+
when Prism::InstanceVariableReadNode
|
|
1405
|
+
current = entry_scope.ivar(receiver_node.name)
|
|
1406
|
+
return target_scope if current.nil?
|
|
1407
|
+
|
|
1408
|
+
narrowed = narrow_for_fact(current, fact, entry_scope.environment)
|
|
1409
|
+
target_scope.with_ivar(receiver_node.name, narrowed)
|
|
1410
|
+
else
|
|
1411
|
+
target_scope
|
|
1412
|
+
end
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1280
1415
|
# Resolves a Fact's target node. Mirrors the earlier
|
|
1281
1416
|
# `effect_target_node` helper but reads from the
|
|
1282
1417
|
# canonical Fact carrier; `:self` routes to the call
|
|
@@ -1291,7 +1426,9 @@ module Rigor
|
|
|
1291
1426
|
end
|
|
1292
1427
|
|
|
1293
1428
|
def resolve_rbs_extended_method(node, scope)
|
|
1294
|
-
receiver_type =
|
|
1429
|
+
receiver_type = receiver_type_for_resolve(node, scope)
|
|
1430
|
+
return nil if receiver_type.nil?
|
|
1431
|
+
|
|
1295
1432
|
class_name = rbs_extended_class_name(receiver_type)
|
|
1296
1433
|
return nil if class_name.nil?
|
|
1297
1434
|
return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
|
|
@@ -1305,6 +1442,13 @@ module Rigor
|
|
|
1305
1442
|
nil
|
|
1306
1443
|
end
|
|
1307
1444
|
|
|
1445
|
+
# Implicit self call (`admin?` with no receiver inside an
|
|
1446
|
+
# instance method body) — read the method definition from
|
|
1447
|
+
# `scope.self_type` per v0.1.1 Track 1 slice 3.
|
|
1448
|
+
def receiver_type_for_resolve(node, scope)
|
|
1449
|
+
node.receiver.nil? ? scope.self_type : scope.type_of(node.receiver)
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1308
1452
|
def rbs_extended_class_name(receiver_type)
|
|
1309
1453
|
case receiver_type
|
|
1310
1454
|
when Type::Nominal, Type::Singleton then receiver_type.class_name
|
|
@@ -552,7 +552,7 @@ module Rigor
|
|
|
552
552
|
when Prism::ModuleNode, Prism::ClassNode
|
|
553
553
|
return if record_class_or_module?(node, qualified_prefix, identity_table, discovered)
|
|
554
554
|
when Prism::ConstantWriteNode
|
|
555
|
-
return if
|
|
555
|
+
return if record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
|
|
556
556
|
end
|
|
557
557
|
|
|
558
558
|
node.compact_child_nodes.each do |child|
|
|
@@ -573,17 +573,23 @@ module Rigor
|
|
|
573
573
|
true
|
|
574
574
|
end
|
|
575
575
|
|
|
576
|
-
# Recognises
|
|
577
|
-
# `Const` (qualified by the surrounding
|
|
578
|
-
# discovered class. `Const.new(...)`
|
|
579
|
-
# `Nominal[Const]` via `meta_new`,
|
|
580
|
-
# `Dynamic[top]` returned by the
|
|
576
|
+
# Recognises class-creating meta calls at constant-write rvalue
|
|
577
|
+
# position and registers `Const` (qualified by the surrounding
|
|
578
|
+
# class/module path) as a discovered class. `Const.new(...)`
|
|
579
|
+
# then resolves to a fresh `Nominal[Const]` via `meta_new`,
|
|
580
|
+
# instead of the un-narrowed `Dynamic[top]` returned by the
|
|
581
|
+
# default `Class#new` envelope.
|
|
581
582
|
#
|
|
582
|
-
#
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
|
|
586
|
-
|
|
583
|
+
# Two recognised meta forms:
|
|
584
|
+
#
|
|
585
|
+
# - `Const = Data.define(*Symbol) [do ... end]`
|
|
586
|
+
# - `Const = Struct.new(*Symbol [, keyword_init: ...]) [do ... end]`
|
|
587
|
+
#
|
|
588
|
+
# The block body, if present, is recursed into so any nested
|
|
589
|
+
# class/module declarations in the override block (rare but legal)
|
|
590
|
+
# still feed the discovered table.
|
|
591
|
+
def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
|
|
592
|
+
return false unless data_define_call?(node.value) || struct_new_call?(node.value)
|
|
587
593
|
|
|
588
594
|
full = (qualified_prefix + [node.name.to_s]).join("::")
|
|
589
595
|
discovered[full] = Type::Combinator.singleton_of(full)
|
|
@@ -599,18 +605,46 @@ module Rigor
|
|
|
599
605
|
def data_define_call?(node)
|
|
600
606
|
return false unless node.is_a?(Prism::CallNode)
|
|
601
607
|
return false unless node.name == :define
|
|
602
|
-
return false unless
|
|
608
|
+
return false unless meta_constant_receiver?(node.receiver, :Data)
|
|
603
609
|
|
|
604
610
|
args = node.arguments&.arguments || []
|
|
605
611
|
args.all?(Prism::SymbolNode)
|
|
606
612
|
end
|
|
607
613
|
|
|
608
|
-
|
|
614
|
+
# Recognises `Struct.new(*Symbol)` and
|
|
615
|
+
# `Struct.new(*Symbol, keyword_init: <expr>)` at constant-write
|
|
616
|
+
# rvalue position. A trailing `KeywordHashNode` (the
|
|
617
|
+
# `keyword_init: ...` form) is accepted but does not contribute
|
|
618
|
+
# to member discovery; every other argument MUST be a
|
|
619
|
+
# `Prism::SymbolNode`. At least one Symbol member is required —
|
|
620
|
+
# `Struct.new()` is a degenerate form callers don't typically use.
|
|
621
|
+
def struct_new_call?(node)
|
|
622
|
+
return false unless meta_call_with_name?(node, :Struct, :new)
|
|
623
|
+
|
|
624
|
+
args = node.arguments&.arguments || []
|
|
625
|
+
positional = struct_new_positionals(args)
|
|
626
|
+
return false if positional.nil? || positional.empty?
|
|
627
|
+
|
|
628
|
+
positional.all?(Prism::SymbolNode)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def meta_call_with_name?(node, receiver_name, method_name)
|
|
632
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
633
|
+
return false unless node.name == method_name
|
|
634
|
+
|
|
635
|
+
meta_constant_receiver?(node.receiver, receiver_name)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def struct_new_positionals(args)
|
|
639
|
+
args.last.is_a?(Prism::KeywordHashNode) ? args[0..-2] : args
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def meta_constant_receiver?(node, expected_name)
|
|
609
643
|
case node
|
|
610
644
|
when Prism::ConstantReadNode
|
|
611
|
-
node.name ==
|
|
645
|
+
node.name == expected_name
|
|
612
646
|
when Prism::ConstantPathNode
|
|
613
|
-
node.parent.nil? && node.name ==
|
|
647
|
+
node.parent.nil? && node.name == expected_name
|
|
614
648
|
end
|
|
615
649
|
end
|
|
616
650
|
|
|
@@ -1015,6 +1015,7 @@ module Rigor
|
|
|
1015
1015
|
# same hierarchy-aware narrowing rules.
|
|
1016
1016
|
def apply_post_return_fact(fact, call_node, current_scope, method_def)
|
|
1017
1017
|
target_node = fact_target_node(fact, call_node, method_def)
|
|
1018
|
+
return apply_self_post_return_fact(fact, target_node, current_scope) if fact.target_kind == :self
|
|
1018
1019
|
return current_scope unless target_node.is_a?(Prism::LocalVariableReadNode)
|
|
1019
1020
|
|
|
1020
1021
|
local_name = target_node.name
|
|
@@ -1025,6 +1026,34 @@ module Rigor
|
|
|
1025
1026
|
current_scope.with_local(local_name, narrowed)
|
|
1026
1027
|
end
|
|
1027
1028
|
|
|
1029
|
+
# v0.1.1 Track 1 slice 3 — `assert self is T` post-return
|
|
1030
|
+
# narrowing for the four supported receiver shapes (mirrors
|
|
1031
|
+
# `Narrowing#apply_self_fact`).
|
|
1032
|
+
def apply_self_post_return_fact(fact, receiver_node, current_scope)
|
|
1033
|
+
case receiver_node
|
|
1034
|
+
when nil, Prism::SelfNode
|
|
1035
|
+
current = current_scope.self_type
|
|
1036
|
+
return current_scope if current.nil?
|
|
1037
|
+
|
|
1038
|
+
narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
|
|
1039
|
+
current_scope.with_self_type(narrowed)
|
|
1040
|
+
when Prism::LocalVariableReadNode
|
|
1041
|
+
current = current_scope.local(receiver_node.name)
|
|
1042
|
+
return current_scope if current.nil?
|
|
1043
|
+
|
|
1044
|
+
narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
|
|
1045
|
+
current_scope.with_local(receiver_node.name, narrowed)
|
|
1046
|
+
when Prism::InstanceVariableReadNode
|
|
1047
|
+
current = current_scope.ivar(receiver_node.name)
|
|
1048
|
+
return current_scope if current.nil?
|
|
1049
|
+
|
|
1050
|
+
narrowed = Narrowing.narrow_for_fact(current, fact, current_scope.environment)
|
|
1051
|
+
current_scope.with_ivar(receiver_node.name, narrowed)
|
|
1052
|
+
else
|
|
1053
|
+
current_scope
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1028
1057
|
# `:self` routes to the call receiver; otherwise we look
|
|
1029
1058
|
# up the matching positional argument by parameter name.
|
|
1030
1059
|
def fact_target_node(fact, call_node, method_def)
|