rigortype 0.1.18 → 0.1.19
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 +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +266 -63
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
- data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
- data/lib/rigor/analysis/runner.rb +58 -21
- data/lib/rigor/analysis/worker_session.rb +21 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +3 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +57 -1
- data/lib/rigor/environment/rbs_loader.rb +25 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1052 -43
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +72 -1
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +270 -37
- data/lib/rigor/inference/scope_indexer.rb +696 -25
- data/lib/rigor/inference/statement_evaluator.rb +963 -16
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/base.rb +235 -79
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +12 -11
- data/lib/rigor/scope/discovery_index.rb +2 -0
- data/lib/rigor/scope.rb +132 -6
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +10 -1
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +7 -2
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -17,7 +17,7 @@ module Rigor
|
|
|
17
17
|
# `MyApp.get(...)` call);
|
|
18
18
|
# - the underlying class `X` equals or inherits from the
|
|
19
19
|
# entry's `receiver_constraint`;
|
|
20
|
-
# - the call's method name is in the entry's `
|
|
20
|
+
# - the call's method name is in the entry's `method_names`.
|
|
21
21
|
#
|
|
22
22
|
# On a match the helper returns the **instance** type of
|
|
23
23
|
# the receiver class (`Nominal[X]`) — the narrowed
|
|
@@ -55,7 +55,7 @@ module Rigor
|
|
|
55
55
|
# replaces the per-call plugins × block_as_methods linear scan.
|
|
56
56
|
# Entries arrive in (plugin registration, declaration) order, so
|
|
57
57
|
# the first ancestry match below is the same entry the previous
|
|
58
|
-
# walk returned; the
|
|
58
|
+
# walk returned; the method-name membership the old `matches?` checked
|
|
59
59
|
# is guaranteed by the table key.
|
|
60
60
|
entries = registry.contribution_index.block_entries_for(call_node.name)
|
|
61
61
|
entries.each do |entry|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../type"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Inference
|
|
7
|
+
module MethodDispatcher
|
|
8
|
+
# `Array#to_h { |x| [k, v] }` (and the no-block-pair tuple form's
|
|
9
|
+
# block sibling) return-type fold.
|
|
10
|
+
#
|
|
11
|
+
# `Enumerable#to_h` with a block maps every element to a
|
|
12
|
+
# `[key, value]` pair and collects them into a Hash. When the
|
|
13
|
+
# block's inferred return type is a recognizable 2-element
|
|
14
|
+
# `Tuple` (`[K, V]`), this tier projects the pair into a
|
|
15
|
+
# `Hash[K, V]` nominal whose key/value parameters are the
|
|
16
|
+
# widened pair types. Without this fold the call hits the RBS
|
|
17
|
+
# generic and the block's `[K, V]` return is dropped, typing as
|
|
18
|
+
# `Hash[Dynamic[top], Dynamic[top]]`.
|
|
19
|
+
#
|
|
20
|
+
# Value-pinned constants in the pair (`Constant[2]`) are widened
|
|
21
|
+
# to their nominal (`Integer`) for the Hash parameters: the built
|
|
22
|
+
# hash holds many keys, so pinning the parameter to a single
|
|
23
|
+
# element's literal would be unsound for the aggregate. The same
|
|
24
|
+
# widening the loop-body fixpoint applies (`Combinator#
|
|
25
|
+
# widen_value_pinned`) is reused.
|
|
26
|
+
#
|
|
27
|
+
# Declines (returns `nil`, leaving today's RBS answer) when:
|
|
28
|
+
#
|
|
29
|
+
# - there is no block at the call site,
|
|
30
|
+
# - the block return type is not a 2-element `Tuple`, or
|
|
31
|
+
# - the receiver is not an Array-shaped carrier (`Tuple` or
|
|
32
|
+
# `Array` nominal). Hash receivers keep their existing
|
|
33
|
+
# `ShapeDispatch#hash_to_h` identity fold.
|
|
34
|
+
module ArrayToHFolding
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def try_dispatch(context)
|
|
38
|
+
return nil unless context.method_name == :to_h
|
|
39
|
+
|
|
40
|
+
block_type = context.block_type
|
|
41
|
+
return nil unless block_type.is_a?(Type::Tuple)
|
|
42
|
+
return nil unless block_type.elements.size == 2
|
|
43
|
+
return nil unless array_shaped?(context.receiver)
|
|
44
|
+
|
|
45
|
+
key = Type::Combinator.widen_value_pinned(block_type.elements[0])
|
|
46
|
+
value = Type::Combinator.widen_value_pinned(block_type.elements[1])
|
|
47
|
+
Type::Combinator.nominal_of("Hash", type_args: [key, value])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def array_shaped?(receiver)
|
|
51
|
+
case receiver
|
|
52
|
+
when Type::Tuple then true
|
|
53
|
+
when Type::Nominal then receiver.class_name == "Array"
|
|
54
|
+
else false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -226,13 +226,54 @@ module Rigor
|
|
|
226
226
|
case type
|
|
227
227
|
when Type::Constant then [type.value]
|
|
228
228
|
when Type::Union
|
|
229
|
-
return
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
return type.members.map(&:value) if type.members.all?(Type::Constant)
|
|
230
|
+
|
|
231
|
+
# A union that mixes `Constant<Integer>` and `IntegerRange`
|
|
232
|
+
# members (e.g. an accumulator's running fixpoint assumption
|
|
233
|
+
# `1 | int<1, 6>`) folds as the bounding interval. The
|
|
234
|
+
# range-arithmetic path (`try_fold_binary_range`) then keeps
|
|
235
|
+
# the result an `IntegerRange` instead of bailing to Dynamic.
|
|
236
|
+
union_integer_bounds(type)
|
|
232
237
|
when Type::IntegerRange then type
|
|
233
238
|
end
|
|
234
239
|
end
|
|
235
240
|
|
|
241
|
+
# Returns the bounding `IntegerRange` over a union whose members
|
|
242
|
+
# are each an Integer `Constant` or an `IntegerRange`; `nil`
|
|
243
|
+
# otherwise (a Float constant or any non-numeric member declines,
|
|
244
|
+
# so precision is never silently lost).
|
|
245
|
+
def union_integer_bounds(union)
|
|
246
|
+
lowers = []
|
|
247
|
+
uppers = []
|
|
248
|
+
union.members.each do |member|
|
|
249
|
+
case member
|
|
250
|
+
when Type::Constant
|
|
251
|
+
return nil unless member.value.is_a?(Integer)
|
|
252
|
+
|
|
253
|
+
lowers << member.value
|
|
254
|
+
uppers << member.value
|
|
255
|
+
when Type::IntegerRange
|
|
256
|
+
lowers << member.lower
|
|
257
|
+
uppers << member.upper
|
|
258
|
+
else
|
|
259
|
+
return nil
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
# `IntegerRange#lower`/`#upper` surface an unbounded edge as
|
|
263
|
+
# `±Float::INFINITY`; `integer_range` wants the `±∞` *sentinel*,
|
|
264
|
+
# so map the extremum back.
|
|
265
|
+
Type::Combinator.integer_range(infinity_to_sentinel(lowers.min),
|
|
266
|
+
infinity_to_sentinel(uppers.max))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def infinity_to_sentinel(bound)
|
|
270
|
+
case bound
|
|
271
|
+
when -Float::INFINITY then Type::IntegerRange::NEG_INFINITY
|
|
272
|
+
when Float::INFINITY then Type::IntegerRange::POS_INFINITY
|
|
273
|
+
else bound
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
236
277
|
def try_fold_unary(set, method_name)
|
|
237
278
|
case set
|
|
238
279
|
when Array then try_fold_unary_set(set, method_name)
|
|
@@ -1265,17 +1306,16 @@ module Rigor
|
|
|
1265
1306
|
end
|
|
1266
1307
|
end
|
|
1267
1308
|
|
|
1268
|
-
# `String#reverse` / `#swapcase` etc. produce a
|
|
1269
|
-
#
|
|
1270
|
-
#
|
|
1271
|
-
#
|
|
1272
|
-
#
|
|
1273
|
-
#
|
|
1274
|
-
#
|
|
1275
|
-
#
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
false
|
|
1309
|
+
# `String#reverse` / `#swapcase` / `#succ` etc. produce a string
|
|
1310
|
+
# at least as large as the receiver. The binary `:+` / `:*` paths
|
|
1311
|
+
# have their own `string_blow_up?` output guard; this is the unary
|
|
1312
|
+
# analogue — decline to fold a unary String op whose receiver is
|
|
1313
|
+
# already at or beyond `STRING_FOLD_BYTE_LIMIT`, since the folded
|
|
1314
|
+
# output would be just as large and constant-materialising it buys
|
|
1315
|
+
# no precision worth the bytes. Non-String receivers never blow up
|
|
1316
|
+
# through a unary op, so they pass.
|
|
1317
|
+
def string_unary_blow_up?(receiver_value, _method_name)
|
|
1318
|
+
receiver_value.is_a?(String) && receiver_value.bytesize >= STRING_FOLD_BYTE_LIMIT
|
|
1279
1319
|
end
|
|
1280
1320
|
|
|
1281
1321
|
# Scalar / String / Symbol values fold; everything
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../type"
|
|
4
|
+
require_relative "call_context"
|
|
5
|
+
require_relative "iterator_dispatch"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Inference
|
|
9
|
+
module MethodDispatcher
|
|
10
|
+
# Symbol-form `reduce` / `inject` return-type tier.
|
|
11
|
+
#
|
|
12
|
+
# `IteratorDispatch.inject_block_params` deliberately declines the
|
|
13
|
+
# Symbol-call shapes (`(1..n).reduce(1, :*)`, `[1,2,3].reduce(:+)`)
|
|
14
|
+
# because they carry no block to bind parameters for — the decline
|
|
15
|
+
# of *block-param typing* is correct. What that decline leaves on
|
|
16
|
+
# the floor is the *return type*: with no block and no precise tier,
|
|
17
|
+
# the call falls to `Enumerable#reduce`'s RBS overload
|
|
18
|
+
# `(untyped, Symbol) -> untyped`, so the whole fold widens to
|
|
19
|
+
# `Dynamic[top]`.
|
|
20
|
+
#
|
|
21
|
+
# This tier recovers a precise return type for the Symbol-operand
|
|
22
|
+
# forms by dispatching the named operator on the accumulated type:
|
|
23
|
+
#
|
|
24
|
+
# - `(seed, :op)` (2-arg) — operand starts at the seed type `S`;
|
|
25
|
+
# the result type is `dispatch(:op, widen(S) ∪ widen(E), widen(E))`
|
|
26
|
+
# joined with the seed (the seed is returned unchanged when the
|
|
27
|
+
# collection is empty). `E` is the receiver's element type.
|
|
28
|
+
# - `(:op)` (1-arg) — no seed: the first element seeds the memo, so
|
|
29
|
+
# the operand type is `E` and the result is
|
|
30
|
+
# `dispatch(:op, widen(E), widen(E))`. RBS models this overload as
|
|
31
|
+
# `() { (E, E) -> E } -> E` (no nil), so the tier returns the
|
|
32
|
+
# operator result without manufacturing a nil — staying consistent
|
|
33
|
+
# with the declared type and Rigor's false-positive discipline
|
|
34
|
+
# (the empty-collection `nil` runtime case is not modelled by RBS
|
|
35
|
+
# and adding it here would only pressure callers into defensive
|
|
36
|
+
# nil-handling for code that works).
|
|
37
|
+
#
|
|
38
|
+
# The tier is precision-additive: it declines (returns nil, today's
|
|
39
|
+
# `Dynamic[top]` behaviour) for every shape it cannot prove —
|
|
40
|
+
# unknown element type, Dynamic / Top receiver, a non-`Constant`
|
|
41
|
+
# Symbol operand, or an operator the engine cannot dispatch on the
|
|
42
|
+
# widened operand types.
|
|
43
|
+
#
|
|
44
|
+
# `widen_value_pinned` (ADR-55/56) collapses `Constant`/`IntegerRange`
|
|
45
|
+
# operands to their nominal base before dispatch so the result is the
|
|
46
|
+
# operator's nominal return (`Integer`) rather than a constant-folded
|
|
47
|
+
# `Constant[120]` — full constant folding of the reduction is out of
|
|
48
|
+
# scope, the precision target is the carrier (`Integer`), not the value.
|
|
49
|
+
module ReduceFolding
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
REDUCE_METHODS = %i[reduce inject].freeze
|
|
53
|
+
private_constant :REDUCE_METHODS
|
|
54
|
+
|
|
55
|
+
# Allow-listed pure, side-effect-free fold operators. Each is
|
|
56
|
+
# safe on the foldable constant operand classes (Integer / Float
|
|
57
|
+
# / Rational); division / modulo by zero is caught by the rescue
|
|
58
|
+
# harness in `execute_constant_reduce` and declines to the
|
|
59
|
+
# nominal fold. `:gcd` / `:lcm` are valid binary Integer methods
|
|
60
|
+
# (`acc.public_send(:gcd, elem)`); `:min` / `:max` are *not* (no
|
|
61
|
+
# `Integer#min`/`#max` — they would raise and decline), so they
|
|
62
|
+
# are deliberately omitted.
|
|
63
|
+
CONSTANT_FOLD_OPERATORS = %i[+ * - / % & | ^ gcd lcm].freeze
|
|
64
|
+
private_constant :CONSTANT_FOLD_OPERATORS
|
|
65
|
+
|
|
66
|
+
# Hard caps (ADR-41 WD4 style). The element count is checked
|
|
67
|
+
# BEFORE the receiver is enumerated, so a `(1..1_000_000)` range
|
|
68
|
+
# never materialises. The bit-length cap rejects factorial-style
|
|
69
|
+
# blow-up (`(1..64).reduce(:*)` is a ~296-bit Integer) so the
|
|
70
|
+
# folded value can never become an analyzer-heavy bignum.
|
|
71
|
+
CONSTANT_FOLD_ELEMENT_CAP = 64
|
|
72
|
+
CONSTANT_FOLD_BIT_CAP = 256
|
|
73
|
+
private_constant :CONSTANT_FOLD_ELEMENT_CAP, :CONSTANT_FOLD_BIT_CAP
|
|
74
|
+
|
|
75
|
+
# @return [Rigor::Type, nil]
|
|
76
|
+
def try_dispatch(context)
|
|
77
|
+
return nil unless REDUCE_METHODS.include?(context.method_name)
|
|
78
|
+
return nil if context.block_type
|
|
79
|
+
|
|
80
|
+
args = context.args
|
|
81
|
+
operator, seed = operator_and_seed(args)
|
|
82
|
+
return nil if operator.nil?
|
|
83
|
+
|
|
84
|
+
# Precision pre-check: when the receiver is a fully-constant
|
|
85
|
+
# collection (a `Constant[Range]` with foldable endpoints or a
|
|
86
|
+
# `Tuple` of `Constant` elements) and the operator is an
|
|
87
|
+
# allow-listed pure op, treat the reduction as a pure function
|
|
88
|
+
# and execute it on the real values, capped. Any decline (size
|
|
89
|
+
# cap, non-constant member, magnitude, exception) falls through
|
|
90
|
+
# to the carrier-precise nominal fold below — byte-identical to
|
|
91
|
+
# pre-fold behaviour.
|
|
92
|
+
folded = try_constant_reduce(context.receiver, operator, seed)
|
|
93
|
+
return folded if folded
|
|
94
|
+
|
|
95
|
+
element = IteratorDispatch.element_type_of(context.receiver)
|
|
96
|
+
return nil if element.nil?
|
|
97
|
+
|
|
98
|
+
fold_result(operator, seed, element, context.environment)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Executes the reduction on real constant values, or returns nil
|
|
102
|
+
# to decline. The caller falls through to the nominal fold on a
|
|
103
|
+
# nil return, so every guard here is precision-additive only.
|
|
104
|
+
#
|
|
105
|
+
# @param seed [Rigor::Type, nil] the optional seed type; only a
|
|
106
|
+
# foldable `Constant` seed is honoured, any other seed declines.
|
|
107
|
+
# @return [Rigor::Type::Constant, nil]
|
|
108
|
+
def try_constant_reduce(receiver, operator, seed)
|
|
109
|
+
return nil unless CONSTANT_FOLD_OPERATORS.include?(operator)
|
|
110
|
+
|
|
111
|
+
members = constant_members(receiver)
|
|
112
|
+
return nil if members.nil?
|
|
113
|
+
|
|
114
|
+
seed_value, seed_present = constant_seed(seed)
|
|
115
|
+
return nil if !seed.nil? && !seed_present
|
|
116
|
+
|
|
117
|
+
execute_constant_reduce(members, operator, seed_value, seed_present)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Extracts the receiver's elements as a Ruby Array of foldable
|
|
121
|
+
# constant values, or nil to decline. Checks the size cap BEFORE
|
|
122
|
+
# enumerating so an unbounded / huge `Constant[Range]` (e.g.
|
|
123
|
+
# `(1..1_000_000)`) never calls `to_a`.
|
|
124
|
+
#
|
|
125
|
+
# @return [Array, nil]
|
|
126
|
+
def constant_members(receiver)
|
|
127
|
+
case receiver
|
|
128
|
+
when Type::Constant
|
|
129
|
+
constant_range_members(receiver.value)
|
|
130
|
+
when Type::Tuple
|
|
131
|
+
tuple_members(receiver.elements)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def constant_range_members(value)
|
|
136
|
+
return nil unless value.is_a?(Range)
|
|
137
|
+
|
|
138
|
+
first = value.begin
|
|
139
|
+
last = value.end
|
|
140
|
+
return nil unless foldable?(first) && foldable?(last)
|
|
141
|
+
# Reject endless / beginless ranges (`(1..)`, `(..5)`).
|
|
142
|
+
return nil if first.nil? || last.nil?
|
|
143
|
+
|
|
144
|
+
# Size BEFORE enumeration — `Range#size` is O(1) for numeric
|
|
145
|
+
# ranges and never materialises the elements.
|
|
146
|
+
size = value.size
|
|
147
|
+
return nil unless size.is_a?(Integer)
|
|
148
|
+
return nil if size > CONSTANT_FOLD_ELEMENT_CAP
|
|
149
|
+
|
|
150
|
+
value.to_a
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def tuple_members(elements)
|
|
156
|
+
return nil if elements.size > CONSTANT_FOLD_ELEMENT_CAP
|
|
157
|
+
return nil unless elements.all? { |e| e.is_a?(Type::Constant) && foldable?(e.value) }
|
|
158
|
+
|
|
159
|
+
elements.map(&:value)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [Array(Object, Boolean)] `[seed_value, present?]`. A nil
|
|
163
|
+
# seed type yields `[nil, false]` (no-seed form). A foldable
|
|
164
|
+
# `Constant` seed yields `[value, true]`. Any other seed yields
|
|
165
|
+
# `[nil, false]` so the caller can decline.
|
|
166
|
+
def constant_seed(seed)
|
|
167
|
+
return [nil, false] if seed.nil?
|
|
168
|
+
return [nil, false] unless seed.is_a?(Type::Constant) && foldable?(seed.value)
|
|
169
|
+
|
|
170
|
+
[seed.value, true]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Runs the actual reduction, guarded by the rescue harness and
|
|
174
|
+
# the magnitude cap. Empty-collection semantics match Ruby:
|
|
175
|
+
# `[].reduce(s, :op) == s` (seed form) and `[].reduce(:op) == nil`
|
|
176
|
+
# (no-seed form → declines so the nominal fold's no-nil contract
|
|
177
|
+
# stands; see `fold_result`).
|
|
178
|
+
#
|
|
179
|
+
# @return [Rigor::Type::Constant, nil]
|
|
180
|
+
def execute_constant_reduce(members, operator, seed_value, seed_present)
|
|
181
|
+
result =
|
|
182
|
+
if seed_present
|
|
183
|
+
members.reduce(seed_value) { |acc, e| acc.public_send(operator, e) }
|
|
184
|
+
else
|
|
185
|
+
# No-seed empty collection reduces to nil; decline so the
|
|
186
|
+
# nominal no-nil contract is preserved rather than folding
|
|
187
|
+
# to `Constant[nil]`.
|
|
188
|
+
return nil if members.empty?
|
|
189
|
+
|
|
190
|
+
members.reduce { |acc, e| acc.public_send(operator, e) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
return nil unless foldable?(result)
|
|
194
|
+
return nil if magnitude_too_large?(result)
|
|
195
|
+
|
|
196
|
+
Type::Combinator.constant_of(result)
|
|
197
|
+
rescue StandardError
|
|
198
|
+
# Division by zero, type mismatch, or any operator error: decline.
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
FOLDABLE_FOLD_CLASSES = [Integer, Float, Rational].freeze
|
|
203
|
+
private_constant :FOLDABLE_FOLD_CLASSES
|
|
204
|
+
|
|
205
|
+
def foldable?(value)
|
|
206
|
+
FOLDABLE_FOLD_CLASSES.any? { |klass| value.is_a?(klass) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Rejects bignum blow-up. A folded Integer wider than the bit cap
|
|
210
|
+
# (factorial, repeated multiplication) declines to the nominal
|
|
211
|
+
# `Integer` carrier rather than parking a heavy literal in the
|
|
212
|
+
# type graph. Float / Rational carry no comparable blow-up risk.
|
|
213
|
+
def magnitude_too_large?(result)
|
|
214
|
+
result.is_a?(Integer) && result.bit_length > CONSTANT_FOLD_BIT_CAP
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Splits the call's positional arguments into the operator Symbol
|
|
218
|
+
# and the optional seed. Returns `[nil, nil]` for any non-Symbol-
|
|
219
|
+
# operand shape so `try_dispatch` declines.
|
|
220
|
+
#
|
|
221
|
+
# - `[:op]` -> operator `:op`, no seed
|
|
222
|
+
# - `[seed, :op]` -> operator `:op`, seed `seed`
|
|
223
|
+
def operator_and_seed(args)
|
|
224
|
+
case args.size
|
|
225
|
+
when 1
|
|
226
|
+
sym = symbol_value(args[0])
|
|
227
|
+
sym ? [sym, nil] : [nil, nil]
|
|
228
|
+
when 2
|
|
229
|
+
sym = symbol_value(args[1])
|
|
230
|
+
sym ? [sym, args[0]] : [nil, nil]
|
|
231
|
+
else
|
|
232
|
+
[nil, nil]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def symbol_value(type)
|
|
237
|
+
return nil unless type.is_a?(Type::Constant)
|
|
238
|
+
|
|
239
|
+
type.value.is_a?(Symbol) ? type.value : nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Dispatches the operator on the widened operand types. With a
|
|
243
|
+
# seed the memo type spans `seed | element` (the first iteration's
|
|
244
|
+
# memo is the seed, every later iteration's memo is a previous
|
|
245
|
+
# operator result); without a seed the memo and operand are both
|
|
246
|
+
# the element type.
|
|
247
|
+
def fold_result(operator, seed, element, environment)
|
|
248
|
+
widened_element = Type::Combinator.widen_value_pinned(element)
|
|
249
|
+
memo = if seed.nil?
|
|
250
|
+
widened_element
|
|
251
|
+
else
|
|
252
|
+
Type::Combinator.union(
|
|
253
|
+
Type::Combinator.widen_value_pinned(seed), widened_element
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
result = dispatch_operator(memo, operator, widened_element, environment)
|
|
258
|
+
return nil if result.nil?
|
|
259
|
+
|
|
260
|
+
# The seed itself is the result when the collection is empty
|
|
261
|
+
# (`[].reduce(s, :op) == s`), so a 2-arg fold's static type is
|
|
262
|
+
# the operator result joined with the seed. The seed is widened
|
|
263
|
+
# (`Constant[0]` -> `Integer`) for the join so the carrier stays
|
|
264
|
+
# the precision target rather than leaking a value-pinned member
|
|
265
|
+
# (`0 | Integer`) — full constant folding of the fold is out of
|
|
266
|
+
# scope.
|
|
267
|
+
return result if seed.nil?
|
|
268
|
+
|
|
269
|
+
Type::Combinator.union(Type::Combinator.widen_value_pinned(seed), result)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def dispatch_operator(memo, operator, operand, environment)
|
|
273
|
+
MethodDispatcher.dispatch(
|
|
274
|
+
receiver_type: memo, method_name: operator,
|
|
275
|
+
arg_types: [operand], environment: environment
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -38,10 +38,81 @@ module Rigor
|
|
|
38
38
|
return nil unless SingletonFolding.receiver?(receiver, "Regexp")
|
|
39
39
|
return fold_escape(args) if REGEXP_ESCAPE_METHODS.include?(method_name)
|
|
40
40
|
return fold_new(args) if method_name == :new
|
|
41
|
+
return fold_last_match(context) if method_name == :last_match
|
|
41
42
|
|
|
42
43
|
nil
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
# `Regexp.last_match` reads the same match-data slot the perlish
|
|
47
|
+
# `$~` global tracks. On a proven-match edge — the truthy branch
|
|
48
|
+
# of `if str =~ /re/`, the surviving path of `unless /re/ =~ s;
|
|
49
|
+
# raise; end`, a `case`/`when` regex arm — the flow engine has
|
|
50
|
+
# already narrowed `$~` to a non-nil `MatchData` (see
|
|
51
|
+
# `Narrowing#regex_match_predicate_scopes`). Upstream RBS types
|
|
52
|
+
# `Regexp.last_match` as `() -> MatchData?` / `(int) -> String?`,
|
|
53
|
+
# so without this consult the call drops back to the nilable
|
|
54
|
+
# return even where the surrounding flow has *proven* the match
|
|
55
|
+
# succeeded — re-introducing the `possible nil receiver` the `$~`
|
|
56
|
+
# narrowing exists to remove the moment the code reaches for the
|
|
57
|
+
# match through the method rather than the global.
|
|
58
|
+
#
|
|
59
|
+
# Mirrors the global's narrowing exactly, so it rides the same
|
|
60
|
+
# `Scope#forget_match_globals` invalidation (an intervening call
|
|
61
|
+
# that can re-run a match clears `$~`, and this consult clears
|
|
62
|
+
# with it):
|
|
63
|
+
#
|
|
64
|
+
# * 0-arg -> `MatchData` (the narrowed `$~`)
|
|
65
|
+
# * `(N)` -> `String` when capture group N is unconditional on
|
|
66
|
+
# every successful match (the same set the `$N`
|
|
67
|
+
# globals narrow), else `String?` (an optional /
|
|
68
|
+
# alternation-reachable group is nil at runtime even
|
|
69
|
+
# on a successful match).
|
|
70
|
+
# * `(:name)` / `(0)` and other forms defer to RBS.
|
|
71
|
+
#
|
|
72
|
+
# Declines (returns nil -> RBS) whenever `$~` is NOT proven
|
|
73
|
+
# non-nil: no match edge established it, or a falsey edge bound it
|
|
74
|
+
# to `Constant[nil]`. Narrowing only on the proven edge keeps the
|
|
75
|
+
# bare `m = Regexp.last_match; m[...]` (no preceding match) firing
|
|
76
|
+
# exactly as today — this never invents a match.
|
|
77
|
+
def fold_last_match(context)
|
|
78
|
+
scope = context.scope
|
|
79
|
+
return nil if scope.nil?
|
|
80
|
+
|
|
81
|
+
match_data = scope.global(:$~)
|
|
82
|
+
return nil unless proven_match?(match_data)
|
|
83
|
+
|
|
84
|
+
args = context.args
|
|
85
|
+
return Type::Combinator.nominal_of("MatchData") if args.empty?
|
|
86
|
+
return nil unless args.size == 1
|
|
87
|
+
|
|
88
|
+
fold_last_match_group(args.first, scope)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Narrowed `$~` is a non-nil `Nominal[MatchData]`. A bare
|
|
92
|
+
# `MatchData?` / `nil` / Dynamic binding is NOT a proven match.
|
|
93
|
+
def proven_match?(match_data)
|
|
94
|
+
match_data.is_a?(Type::Nominal) && match_data.class_name == "MatchData"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# `Regexp.last_match(N)` for a value-pinned positive Integer N.
|
|
98
|
+
# Track the matching `$N` global the predicate edge already
|
|
99
|
+
# narrowed: present (`String`) means the group is unconditional,
|
|
100
|
+
# absent means it is optional / alternation-reachable (nil at
|
|
101
|
+
# runtime on a success), so we surface `String?`. A non-constant
|
|
102
|
+
# or non-positive index defers to RBS.
|
|
103
|
+
def fold_last_match_group(arg, scope)
|
|
104
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
105
|
+
|
|
106
|
+
index = arg.value
|
|
107
|
+
return nil unless index.is_a?(Integer) && index.positive?
|
|
108
|
+
|
|
109
|
+
string_t = Type::Combinator.nominal_of("String")
|
|
110
|
+
group_global = scope.global(:"$#{index}")
|
|
111
|
+
return string_t if group_global.is_a?(Type::Nominal) && group_global.class_name == "String"
|
|
112
|
+
|
|
113
|
+
Type::Combinator.union(string_t, Type::Combinator.constant_of(nil))
|
|
114
|
+
end
|
|
115
|
+
|
|
45
116
|
# `Regexp.escape(str)` / `.quote(str)` — one String arg.
|
|
46
117
|
def fold_escape(args)
|
|
47
118
|
return nil unless args.size == 1
|