rigortype 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +28 -0
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +181 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
- data/sig/rigor/scope.rbs +22 -0
- metadata +9 -1
|
@@ -6,6 +6,7 @@ require_relative "../type"
|
|
|
6
6
|
require_relative "../ast"
|
|
7
7
|
require_relative "block_parameter_binder"
|
|
8
8
|
require_relative "fallback"
|
|
9
|
+
require_relative "indexed_narrowing"
|
|
9
10
|
require_relative "macro_block_self_type"
|
|
10
11
|
require_relative "method_dispatcher"
|
|
11
12
|
require_relative "narrowing"
|
|
@@ -1133,6 +1134,69 @@ module Rigor
|
|
|
1133
1134
|
type_of(body.last)
|
|
1134
1135
|
end
|
|
1135
1136
|
|
|
1137
|
+
# Indexed-collection narrowing — `receiver[key]` after a
|
|
1138
|
+
# prior `receiver[key] ||= default` reads the post-`||=`
|
|
1139
|
+
# type when the receiver and key are stable enough to
|
|
1140
|
+
# address. Sits ahead of `MethodDispatcher.dispatch` so
|
|
1141
|
+
# the standard `Hash#[]` / `Array#[]` answer (which would
|
|
1142
|
+
# fold to `Constant[nil]` for an empty `HashShape{}` or
|
|
1143
|
+
# `Tuple[]`) does not override the narrowing. See
|
|
1144
|
+
# {Inference::IndexedNarrowing}.
|
|
1145
|
+
def indexed_narrowing_for(node)
|
|
1146
|
+
IndexedNarrowing.lookup_for_call(node, scope) || method_chain_narrowing_for(node)
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# Stable single-hop chain narrowing — `receiver.method`
|
|
1150
|
+
# after an `is_a?` / `kind_of?` / `instance_of?` predicate
|
|
1151
|
+
# established the narrowing on the dominated edge. The
|
|
1152
|
+
# call MUST be no-arg + no-block + rooted at a local-var /
|
|
1153
|
+
# ivar read; everything else falls through to the
|
|
1154
|
+
# standard dispatcher. ROADMAP § Future cycles —
|
|
1155
|
+
# "Method-call receiver narrowing across stable
|
|
1156
|
+
# receivers" — Law-of-Demeter-justified single-hop scope.
|
|
1157
|
+
def method_chain_narrowing_for(node)
|
|
1158
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
1159
|
+
return nil unless node.block.nil?
|
|
1160
|
+
return nil unless node.arguments.nil? || node.arguments.arguments.empty?
|
|
1161
|
+
|
|
1162
|
+
case node.receiver
|
|
1163
|
+
when Prism::LocalVariableReadNode
|
|
1164
|
+
scope.method_chain_narrowing(:local, node.receiver.name, node.name)
|
|
1165
|
+
when Prism::InstanceVariableReadNode
|
|
1166
|
+
scope.method_chain_narrowing(:ivar, node.receiver.name, node.name)
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# v0.0.3 A — implicit-self calls prefer a same-named
|
|
1171
|
+
# top-level `def` over RBS dispatch. Without this,
|
|
1172
|
+
# a helper like `def select(...)` defined inside an
|
|
1173
|
+
# `RSpec.describe ... do ... end` block mis-routes
|
|
1174
|
+
# through `Enumerable#select` / `Object#select` and
|
|
1175
|
+
# the caller observes `Array[Elem]` instead of the
|
|
1176
|
+
# helper's actual return type. The check fires only
|
|
1177
|
+
# for `node.receiver.nil?` (true implicit self), so
|
|
1178
|
+
# explicit-receiver dispatch is unaffected.
|
|
1179
|
+
def try_local_def_dispatch(node, receiver, arg_types)
|
|
1180
|
+
local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
|
|
1181
|
+
return nil unless local_def
|
|
1182
|
+
|
|
1183
|
+
local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
|
|
1184
|
+
return local_inference if local_inference && adoptable_self_call_result?(local_inference)
|
|
1185
|
+
|
|
1186
|
+
# The local def matches by name but the inference was
|
|
1187
|
+
# disqualified — either the parameter shape is too complex
|
|
1188
|
+
# for the first-iteration binder (kwargs / optionals /
|
|
1189
|
+
# rest), or ADR-24 slice 1's conservative gate declined
|
|
1190
|
+
# the resolved return type inside a class body (see
|
|
1191
|
+
# `adoptable_self_call_result?`). `Dynamic[Top]` is the
|
|
1192
|
+
# safest answer: RBS dispatch would be wrong (the method
|
|
1193
|
+
# is user-defined and shadows whatever ancestor method the
|
|
1194
|
+
# dispatch would find), and `Dynamic[Top]` propagates
|
|
1195
|
+
# correctly through downstream call chains without
|
|
1196
|
+
# surfacing misleading false-positive diagnostics.
|
|
1197
|
+
dynamic_top
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1136
1200
|
# Slice 2 routes call expressions through `MethodDispatcher`. The
|
|
1137
1201
|
# receiver and every argument are typed first, then the dispatcher is
|
|
1138
1202
|
# asked for a result type. A nil result triggers the fail-soft fallback
|
|
@@ -1140,40 +1204,15 @@ module Rigor
|
|
|
1140
1204
|
# their own fallbacks for unrecognised receivers/args, so the tracer
|
|
1141
1205
|
# captures both the immediate dispatch miss and the deeper cause).
|
|
1142
1206
|
def call_type_for(node)
|
|
1207
|
+
narrowed = indexed_narrowing_for(node)
|
|
1208
|
+
return narrowed if narrowed
|
|
1209
|
+
|
|
1143
1210
|
receiver = call_receiver_type_for(node)
|
|
1144
1211
|
arg_types = call_arg_types(node)
|
|
1145
1212
|
block_type = block_return_type_for(node, receiver, arg_types)
|
|
1146
1213
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
# a helper like `def select(...)` defined inside an
|
|
1150
|
-
# `RSpec.describe ... do ... end` block mis-routes
|
|
1151
|
-
# through `Enumerable#select` / `Object#select` and
|
|
1152
|
-
# the caller observes `Array[Elem]` instead of the
|
|
1153
|
-
# helper's actual return type. The check fires only
|
|
1154
|
-
# for `node.receiver.nil?` (true implicit self), so
|
|
1155
|
-
# explicit-receiver dispatch is unaffected.
|
|
1156
|
-
local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
|
|
1157
|
-
if local_def
|
|
1158
|
-
local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
|
|
1159
|
-
return local_inference if local_inference && adoptable_self_call_result?(local_inference)
|
|
1160
|
-
|
|
1161
|
-
# The local def matches by name but the inference
|
|
1162
|
-
# was disqualified — either the parameter shape is
|
|
1163
|
-
# too complex for the first-iteration binder
|
|
1164
|
-
# (kwargs / optionals / rest), or ADR-24 slice 1's
|
|
1165
|
-
# conservative gate declined the resolved return
|
|
1166
|
-
# type inside a class body (see
|
|
1167
|
-
# `adoptable_self_call_result?`).
|
|
1168
|
-
# Returning `Dynamic[Top]` is the safest answer:
|
|
1169
|
-
# we know RBS dispatch would be wrong (the
|
|
1170
|
-
# method is user-defined and shadows whatever
|
|
1171
|
-
# ancestor method the dispatch would find), and
|
|
1172
|
-
# `Dynamic[Top]` propagates correctly through
|
|
1173
|
-
# downstream call chains without surfacing
|
|
1174
|
-
# misleading false-positive diagnostics.
|
|
1175
|
-
return dynamic_top
|
|
1176
|
-
end
|
|
1214
|
+
local_def_result = try_local_def_dispatch(node, receiver, arg_types)
|
|
1215
|
+
return local_def_result if local_def_result
|
|
1177
1216
|
|
|
1178
1217
|
# v0.0.6 phase 2 — per-element block fold for Tuple
|
|
1179
1218
|
# receivers. When `[a, b, c].map { |x| f(x) }` and the
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../type"
|
|
6
|
+
require_relative "mutation_widening"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Inference
|
|
10
|
+
# Closes the "`params[:f] ||= []; params[:f] << x`" precision
|
|
11
|
+
# gap surfaced by the Redmine 6.1.2 `Query#as_params` survey
|
|
12
|
+
# (ROADMAP § Future cycles / Type-language / engine —
|
|
13
|
+
# "Indexed-collection narrowing through `Hash[k] ||= default`").
|
|
14
|
+
#
|
|
15
|
+
# After `receiver[key] ||= default` the next read at
|
|
16
|
+
# `receiver[key]` is known non-nil, but Rigor types each
|
|
17
|
+
# `Hash#[]` independently and the subsequent `<<` / `[]=` /
|
|
18
|
+
# other mutator dispatches against the un-narrowed result —
|
|
19
|
+
# which on a `HashShape{}` carrier folds to `Constant[nil]`.
|
|
20
|
+
#
|
|
21
|
+
# This module is the address-recogniser + invalidator shared
|
|
22
|
+
# by {Inference::StatementEvaluator}'s `eval_index_or_write`
|
|
23
|
+
# handler (which RECORDS the narrowing) and `eval_call`
|
|
24
|
+
# (which INVALIDATES on intervening writes / mutators) and
|
|
25
|
+
# by {Inference::ExpressionTyper}'s `call_type_for` (which
|
|
26
|
+
# CONSUMES the narrowing when typing a follow-up `[]` read).
|
|
27
|
+
#
|
|
28
|
+
# **Stable receivers.** A receiver is "stable" iff it is a
|
|
29
|
+
# `LocalVariableReadNode` or `InstanceVariableReadNode`.
|
|
30
|
+
# Method-call chains (`foo.bar[:k]`) and other shapes are
|
|
31
|
+
# rejected because a follow-up read against an identical-
|
|
32
|
+
# looking AST chain has no guarantee of resolving to the
|
|
33
|
+
# same runtime value — narrowing it would invent a fact.
|
|
34
|
+
#
|
|
35
|
+
# **Stable keys.** A key is "stable" iff it is a literal
|
|
36
|
+
# `SymbolNode` / `StringNode` / `IntegerNode`. Local-variable
|
|
37
|
+
# keys (`params[field]`) are excluded for the same
|
|
38
|
+
# invent-a-fact reason: the local could be rebound between
|
|
39
|
+
# the `||=` and the read.
|
|
40
|
+
#
|
|
41
|
+
# **Invalidation.** Three conditions drop a recorded
|
|
42
|
+
# narrowing:
|
|
43
|
+
# - The receiver variable is rebound (handled inside
|
|
44
|
+
# `Scope#with_local` / `Scope#with_ivar`).
|
|
45
|
+
# - An intervening `receiver[key] = value` writes the same
|
|
46
|
+
# slot — `:[]=` could rebind the slot to nil; conservative
|
|
47
|
+
# drop.
|
|
48
|
+
# - An intervening mutator from {MutationWidening::HASH_MUTATORS}
|
|
49
|
+
# or {MutationWidening::ARRAY_MUTATORS} runs against the
|
|
50
|
+
# receiver (e.g. `params.delete(:f)`, `params.clear`).
|
|
51
|
+
#
|
|
52
|
+
# All three are implemented in `StatementEvaluator#eval_call`'s
|
|
53
|
+
# post-dispatch path through {.invalidate_after_call}.
|
|
54
|
+
module IndexedNarrowing
|
|
55
|
+
# Literal Prism nodes whose Ruby value the analyzer trusts
|
|
56
|
+
# as a stable address. Symbol / String are the dominant
|
|
57
|
+
# Hash key shapes; Integer covers numerically-keyed Hashes
|
|
58
|
+
# and Array indices.
|
|
59
|
+
STABLE_KEY_NODES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
|
|
60
|
+
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
# Returns `[receiver_kind, receiver_name]` when `node` is a
|
|
64
|
+
# `LocalVariableReadNode` or `InstanceVariableReadNode`,
|
|
65
|
+
# otherwise nil.
|
|
66
|
+
def stable_receiver(node)
|
|
67
|
+
case node
|
|
68
|
+
when Prism::LocalVariableReadNode then [:local, node.name]
|
|
69
|
+
when Prism::InstanceVariableReadNode then [:ivar, node.name]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns the literal Ruby value when `node` is a stable
|
|
74
|
+
# key shape, otherwise nil. Symbols → `Symbol`,
|
|
75
|
+
# Strings → `String` (unescaped), Integers → `Integer`.
|
|
76
|
+
def stable_key(node)
|
|
77
|
+
case node
|
|
78
|
+
when Prism::SymbolNode then node.unescaped.to_sym
|
|
79
|
+
when Prism::StringNode then node.unescaped
|
|
80
|
+
when Prism::IntegerNode then node.value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns `[receiver_kind, receiver_name, key]` when the
|
|
85
|
+
# CallNode is a `receiver[key]` read or write whose
|
|
86
|
+
# receiver and key are both stable, otherwise nil. Used
|
|
87
|
+
# by both the recorder (for `IndexOrWriteNode`'s
|
|
88
|
+
# receiver/arguments triplet) and the invalidator (for
|
|
89
|
+
# `CallNode :[]=` / mutator calls). Treats only the FIRST
|
|
90
|
+
# argument as the key; `:[]=`'s second argument is the
|
|
91
|
+
# rvalue and is not part of the address.
|
|
92
|
+
def stable_address(receiver_node, key_node)
|
|
93
|
+
receiver = stable_receiver(receiver_node)
|
|
94
|
+
return nil if receiver.nil?
|
|
95
|
+
|
|
96
|
+
key = stable_key(key_node)
|
|
97
|
+
return nil if key.nil?
|
|
98
|
+
|
|
99
|
+
[receiver.first, receiver.last, key]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Looks up a recorded narrowing for `receiver[key]` against
|
|
103
|
+
# `scope`, returning the narrowed type or nil when no
|
|
104
|
+
# entry applies. Used by ExpressionTyper's `[]` dispatch
|
|
105
|
+
# to refine the result of a stable indexed read.
|
|
106
|
+
def lookup_for_call(node, scope)
|
|
107
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
108
|
+
return nil unless node.name == :[]
|
|
109
|
+
return nil if node.arguments.nil?
|
|
110
|
+
return nil unless node.arguments.arguments.size == 1
|
|
111
|
+
|
|
112
|
+
address = stable_address(node.receiver, node.arguments.arguments.first)
|
|
113
|
+
return nil if address.nil?
|
|
114
|
+
|
|
115
|
+
scope.indexed_narrowing(*address)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Removes recorded narrowings invalidated by `call_node`.
|
|
119
|
+
# Two patterns:
|
|
120
|
+
#
|
|
121
|
+
# - `receiver[key] = value` (a `:[]=` against a stable
|
|
122
|
+
# address): drop the specific `(receiver, key)` entry.
|
|
123
|
+
# - Any mutator from `HASH_MUTATORS` / `ARRAY_MUTATORS`
|
|
124
|
+
# against a stable receiver: drop EVERY entry rooted
|
|
125
|
+
# at that receiver, because the mutator could rebind
|
|
126
|
+
# any slot.
|
|
127
|
+
#
|
|
128
|
+
# Returns the updated scope. Always-safe (only forgets;
|
|
129
|
+
# never invents).
|
|
130
|
+
def invalidate_after_call(call_node:, current_scope:)
|
|
131
|
+
return current_scope unless call_node.is_a?(Prism::CallNode)
|
|
132
|
+
|
|
133
|
+
if call_node.name == :[]=
|
|
134
|
+
invalidate_indexed_write(call_node, current_scope)
|
|
135
|
+
elsif mutator?(call_node.name)
|
|
136
|
+
invalidate_mutator(call_node, current_scope)
|
|
137
|
+
else
|
|
138
|
+
current_scope
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def mutator?(method_name)
|
|
143
|
+
MutationWidening::HASH_MUTATORS.include?(method_name) ||
|
|
144
|
+
MutationWidening::ARRAY_MUTATORS.include?(method_name)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def invalidate_indexed_write(call_node, current_scope)
|
|
148
|
+
args = call_node.arguments&.arguments
|
|
149
|
+
return current_scope if args.nil? || args.empty?
|
|
150
|
+
|
|
151
|
+
address = stable_address(call_node.receiver, args.first)
|
|
152
|
+
return current_scope if address.nil?
|
|
153
|
+
|
|
154
|
+
current_scope.without_indexed_narrowing(*address)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def invalidate_mutator(call_node, current_scope)
|
|
158
|
+
receiver = stable_receiver(call_node.receiver)
|
|
159
|
+
return current_scope if receiver.nil?
|
|
160
|
+
|
|
161
|
+
current_scope.without_indexed_narrowings_for(*receiver)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Companion invalidator for single-hop method-chain
|
|
165
|
+
# narrowings (ROADMAP § Future cycles — "Method-call
|
|
166
|
+
# receiver narrowing across stable receivers", B2 from
|
|
167
|
+
# the slice's design notes). Drops every
|
|
168
|
+
# `(receiver, *)` chain narrowing rooted at the call's
|
|
169
|
+
# OUTER stable receiver — matching the ROADMAP's "any
|
|
170
|
+
# intervening method call against the same receiver"
|
|
171
|
+
# criterion. A call against `x.last` (the OUTER receiver
|
|
172
|
+
# is a `CallNode`, not a stable root) does NOT drop
|
|
173
|
+
# narrowings keyed on `x`, so the worked-site
|
|
174
|
+
# `x.last << y` pattern correctly preserves the chain
|
|
175
|
+
# narrowing for any further `x.last` read in the same
|
|
176
|
+
# body. Always-safe (only forgets; never invents).
|
|
177
|
+
def invalidate_chain_after_call(call_node:, current_scope:)
|
|
178
|
+
return current_scope unless call_node.is_a?(Prism::CallNode)
|
|
179
|
+
|
|
180
|
+
receiver = stable_receiver(call_node.receiver)
|
|
181
|
+
return current_scope if receiver.nil?
|
|
182
|
+
|
|
183
|
+
current_scope.without_method_chain_narrowings_for(*receiver)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -42,9 +42,33 @@ module Rigor
|
|
|
42
42
|
when :inject, :reduce then inject_block_params(receiver, args)
|
|
43
43
|
when :group_by, :partition then single_element_block_params(receiver)
|
|
44
44
|
when :each_slice, :each_cons then slice_block_params(receiver)
|
|
45
|
+
when :new then class_new_block_params(receiver, args)
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
# `Class.new { |c| … }` and `Class.new(Parent) { |c| … }`
|
|
50
|
+
# — the block parameter is the freshly-created anonymous
|
|
51
|
+
# class, statically representable as the parent's singleton
|
|
52
|
+
# type (the new class inherits every singleton method the
|
|
53
|
+
# parent exposes, which is what callers use this form to
|
|
54
|
+
# configure: `c.table_name = …`, `c.attribute :foo`, etc.).
|
|
55
|
+
# No parent → `singleton(Object)`. RBS would otherwise widen
|
|
56
|
+
# the block param to bare `Nominal[Class]`, dropping access
|
|
57
|
+
# to the parent's class-side surface.
|
|
58
|
+
def class_new_block_params(receiver, args)
|
|
59
|
+
return nil unless class_metaclass_receiver?(receiver)
|
|
60
|
+
|
|
61
|
+
parent = args.first
|
|
62
|
+
return [Type::Combinator.singleton_of("Object")] if parent.nil?
|
|
63
|
+
return [parent] if parent.is_a?(Type::Singleton)
|
|
64
|
+
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def class_metaclass_receiver?(type)
|
|
69
|
+
type.is_a?(Type::Singleton) && type.class_name == "Class"
|
|
70
|
+
end
|
|
71
|
+
|
|
48
72
|
def times_block_params(receiver)
|
|
49
73
|
return nil unless integer_rooted?(receiver)
|
|
50
74
|
|
|
@@ -805,9 +805,32 @@ module Rigor
|
|
|
805
805
|
date_lift = date_new_lift(receiver_type.class_name, arg_types)
|
|
806
806
|
return date_lift if date_lift
|
|
807
807
|
|
|
808
|
+
class_new_lift = class_new_lift(receiver_type.class_name, arg_types)
|
|
809
|
+
return class_new_lift if class_new_lift
|
|
810
|
+
|
|
808
811
|
Type::Combinator.nominal_of(receiver_type.class_name)
|
|
809
812
|
end
|
|
810
813
|
|
|
814
|
+
# `Class.new` and `Class.new(Parent)` create a brand-new
|
|
815
|
+
# anonymous class. Statically that class is representable as
|
|
816
|
+
# the parent's singleton type — its singleton-method surface
|
|
817
|
+
# is the parent's (plus whatever the block defines, which we
|
|
818
|
+
# do not statically track here), so `Singleton[Parent]` lets
|
|
819
|
+
# downstream `klass.some_class_method` resolve. No parent →
|
|
820
|
+
# `singleton(Object)`. Anything else (dynamic parent, more
|
|
821
|
+
# than one positional, …) falls back to `Nominal[Class]` via
|
|
822
|
+
# the surrounding `meta_new` tail.
|
|
823
|
+
def class_new_lift(class_name, arg_types)
|
|
824
|
+
return nil unless class_name == "Class"
|
|
825
|
+
return Type::Combinator.singleton_of("Object") if arg_types.empty?
|
|
826
|
+
return nil unless arg_types.size == 1
|
|
827
|
+
|
|
828
|
+
parent = arg_types.first
|
|
829
|
+
return parent if parent.is_a?(Type::Singleton)
|
|
830
|
+
|
|
831
|
+
nil
|
|
832
|
+
end
|
|
833
|
+
|
|
811
834
|
# ADR-15 Phase 4b.x — `Ractor.make_shareable` on both the
|
|
812
835
|
# outer Hash and each lambda value. A plain `.freeze` leaves
|
|
813
836
|
# the Procs unshareable; reading `CONSTANT_CONSTRUCTORS[class]`
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../type"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Inference
|
|
7
|
+
# Widens a local- or instance-variable binding after a call
|
|
8
|
+
# whose receiver is that variable AND whose method is a known
|
|
9
|
+
# in-place mutator.
|
|
10
|
+
#
|
|
11
|
+
# Closes the **G1 / G2** flow-folding gaps documented at
|
|
12
|
+
# `docs/notes/20260521-mastodon-cluster4-flow-folding-triage.md`
|
|
13
|
+
# and queued in [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
|
|
14
|
+
# § "Flow-folding". The user-visible symptom they shared was a
|
|
15
|
+
# spurious `flow.always-truthy-condition` on a `arr.size == N`
|
|
16
|
+
# / `arr.empty?` / `@arr.empty?` check that follows a loop
|
|
17
|
+
# body or sibling method that mutates `arr` / `@arr` in place.
|
|
18
|
+
#
|
|
19
|
+
# **The mechanism.** When source like
|
|
20
|
+
#
|
|
21
|
+
# arms = [first] # arms : Tuple[T] (size=1)
|
|
22
|
+
# while peek_pipe?
|
|
23
|
+
# arms << next_arm # mutator call on a local
|
|
24
|
+
# end
|
|
25
|
+
# return arms.first if arms.size == 1
|
|
26
|
+
#
|
|
27
|
+
# runs through inference today, the literal `[first]` writes
|
|
28
|
+
# `arms` as `Tuple[T]`. The shape carrier's `size` folds to
|
|
29
|
+
# `Constant[1]`. The body's `arms << next_arm` returns a type
|
|
30
|
+
# for the call expression but does NOT rebind `arms`, so after
|
|
31
|
+
# the loop `arms` still carries the `Tuple[T]` binding —
|
|
32
|
+
# `arms.size == 1` constant-folds to `true` and the user sees
|
|
33
|
+
# a false `flow.always-truthy-condition`.
|
|
34
|
+
#
|
|
35
|
+
# The narrowest correct fix is to **widen the receiver binding
|
|
36
|
+
# at the mutator call site**: replace `arms`'s binding with
|
|
37
|
+
# `Nominal[Array, [union(elements)]]` so the carrier no longer
|
|
38
|
+
# carries the literal arity. Inside a loop body, the post-call
|
|
39
|
+
# body scope then joins with the pre-loop scope through
|
|
40
|
+
# `join_with_nil_injection` → `Scope#join` (which unions per
|
|
41
|
+
# name); the resulting union loses size precision, so the
|
|
42
|
+
# `arms.size` fold returns `Integer` (not `Constant[1]`) and
|
|
43
|
+
# the diagnostic correctly stays silent.
|
|
44
|
+
#
|
|
45
|
+
# The widening is **always type-safe**: it never introduces a
|
|
46
|
+
# new fact, only forgets a literal-shape fact that is no longer
|
|
47
|
+
# justified once mutation occurred. It costs only the precise
|
|
48
|
+
# arity / pair-set the shape carrier was tracking; the underlying
|
|
49
|
+
# nominal stays exact (`Array` / `Hash`) and element types
|
|
50
|
+
# stay as a union of what was there.
|
|
51
|
+
#
|
|
52
|
+
# **Scope.** This slice addresses:
|
|
53
|
+
#
|
|
54
|
+
# - `arr.<mutator>(...)` where `arr` is a local variable.
|
|
55
|
+
# - `@arr.<mutator>(...)` where `@arr` is an instance variable.
|
|
56
|
+
#
|
|
57
|
+
# Out of scope (left for a separate cycle):
|
|
58
|
+
#
|
|
59
|
+
# - **`retry` flow edge** (e.g. `tries += 1; retry`). The
|
|
60
|
+
# `tries` rebind across `retry` is a flow-edge issue, not a
|
|
61
|
+
# call-site mutation issue.
|
|
62
|
+
# - **Intervening method call invalidates the ivar binding**
|
|
63
|
+
# (e.g. `if @performed; perform!; if @performed`). The
|
|
64
|
+
# intra-procedural call effect on ivars is a separate
|
|
65
|
+
# mutation-effect feature.
|
|
66
|
+
# - **Read-before-write nil** (e.g. `unless @warning_issued;
|
|
67
|
+
# ...; @warning_issued = true`). Requires tracking the
|
|
68
|
+
# first-write position; flow-sensitive but orthogonal.
|
|
69
|
+
# - **Local-variable mutation inside a block body** (e.g.
|
|
70
|
+
# `arr = []; xs.each { |x| arr << x }`). Block bodies
|
|
71
|
+
# create a child scope; the existing closure-escape model
|
|
72
|
+
# only widens outer locals when the block ESCAPES the
|
|
73
|
+
# call. An in-place mutator inside a non-escaping block on
|
|
74
|
+
# an outer LOCAL does not yet flow back. **Ivar mutations
|
|
75
|
+
# inside a block ARE handled** (ivars live in the
|
|
76
|
+
# method-body scope, not the block-local scope) — the
|
|
77
|
+
# widening fires from inside the block and the new ivar
|
|
78
|
+
# binding is visible to the outer scope.
|
|
79
|
+
#
|
|
80
|
+
# Those four are documented as "G2 remaining" in
|
|
81
|
+
# `docs/CURRENT_WORK.md` and are intentionally deferred.
|
|
82
|
+
module MutationWidening
|
|
83
|
+
# Array mutators that change either the size or the element
|
|
84
|
+
# set of a literal-shape carrier (Tuple). Receiver-mutating
|
|
85
|
+
# methods only — non-mutating siblings (`map` vs `map!`,
|
|
86
|
+
# `select` vs `select!`) stay precise.
|
|
87
|
+
#
|
|
88
|
+
# `<<` and `[]=` are the dominant survey cases; the bang
|
|
89
|
+
# variants and the size-mutators cover the rest of the
|
|
90
|
+
# Mastodon cluster-4 G1 catalogue.
|
|
91
|
+
ARRAY_MUTATORS = %i[
|
|
92
|
+
<< push append prepend unshift concat insert
|
|
93
|
+
pop shift
|
|
94
|
+
delete delete_at delete_if reject!
|
|
95
|
+
clear compact!
|
|
96
|
+
replace fill []=
|
|
97
|
+
map! collect! select! filter! keep_if uniq!
|
|
98
|
+
flatten! sort! sort_by! reverse! rotate! shuffle! slice!
|
|
99
|
+
].to_set.freeze
|
|
100
|
+
|
|
101
|
+
# Hash mutators that invalidate a `HashShape` carrier. Same
|
|
102
|
+
# principle as `ARRAY_MUTATORS`: only the receiver-mutating
|
|
103
|
+
# methods are listed.
|
|
104
|
+
HASH_MUTATORS = %i[
|
|
105
|
+
[]= store
|
|
106
|
+
delete delete_if reject! select! filter! keep_if
|
|
107
|
+
clear compact! merge! update transform_keys! transform_values!
|
|
108
|
+
replace
|
|
109
|
+
].to_set.freeze
|
|
110
|
+
|
|
111
|
+
module_function
|
|
112
|
+
|
|
113
|
+
# Returns a scope with the call's receiver widened, when the
|
|
114
|
+
# receiver is a local-/instance-variable read whose current
|
|
115
|
+
# binding is a literal-shape carrier (`Tuple` / `HashShape`)
|
|
116
|
+
# AND the call name is a known in-place mutator for that
|
|
117
|
+
# shape. Returns `current_scope` unchanged otherwise.
|
|
118
|
+
#
|
|
119
|
+
# @param call_node [Prism::CallNode]
|
|
120
|
+
# @param current_scope [Rigor::Scope]
|
|
121
|
+
# @return [Rigor::Scope]
|
|
122
|
+
def widen_after_call(call_node:, current_scope:)
|
|
123
|
+
receiver = call_node.receiver
|
|
124
|
+
return current_scope if receiver.nil?
|
|
125
|
+
|
|
126
|
+
case receiver
|
|
127
|
+
when Prism::LocalVariableReadNode
|
|
128
|
+
widen_local(call_node.name, receiver.name, current_scope)
|
|
129
|
+
when Prism::InstanceVariableReadNode
|
|
130
|
+
widen_ivar(call_node.name, receiver.name, current_scope)
|
|
131
|
+
else
|
|
132
|
+
current_scope
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Propagate block-body mutations of outer-scope variables
|
|
137
|
+
# back into `outer_scope`. Block bodies live in a child
|
|
138
|
+
# scope; mutations the block body performs on captured
|
|
139
|
+
# outer LOCALS are otherwise invisible to the post-call
|
|
140
|
+
# outer scope (ivars are handled correctly already because
|
|
141
|
+
# they live in the method-body scope, not the block-local
|
|
142
|
+
# scope).
|
|
143
|
+
#
|
|
144
|
+
# Walks the block AST for `<receiver>.<method>(...)` calls
|
|
145
|
+
# whose receiver is either a `LocalVariableReadNode` with
|
|
146
|
+
# `depth > 0` (a captured outer local — Prism's `depth`
|
|
147
|
+
# counts scope hops outward; `depth == 0` means a
|
|
148
|
+
# block-local) or an `InstanceVariableReadNode` (always
|
|
149
|
+
# method-scope), and applies `widen_after_call` for each
|
|
150
|
+
# one against the outer scope. The widening is always safe
|
|
151
|
+
# — it can only LOSE precision — so blindly propagating is
|
|
152
|
+
# sound regardless of whether the block actually runs.
|
|
153
|
+
#
|
|
154
|
+
# Recurses into nested expression nodes so chained / nested
|
|
155
|
+
# forms (`arr << f(x); arr << g(y)`, `arr.push(x) if cond`)
|
|
156
|
+
# are all caught. Does NOT recurse into nested
|
|
157
|
+
# `Prism::BlockNode`s — each block is processed by its own
|
|
158
|
+
# `eval_call`.
|
|
159
|
+
def widen_after_block(call_node:, outer_scope:)
|
|
160
|
+
block = call_node.block
|
|
161
|
+
return outer_scope unless block.is_a?(Prism::BlockNode)
|
|
162
|
+
|
|
163
|
+
body = block.body
|
|
164
|
+
return outer_scope if body.nil?
|
|
165
|
+
|
|
166
|
+
walk_for_outer_mutations(body, outer_scope)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def walk_for_outer_mutations(node, scope)
|
|
170
|
+
return scope if node.nil?
|
|
171
|
+
|
|
172
|
+
scope = widen_for_outer_receiver(node, scope) if node.is_a?(Prism::CallNode)
|
|
173
|
+
|
|
174
|
+
# Descend into every child, including nested blocks. The
|
|
175
|
+
# `LocalVariableReadNode#depth` check inside
|
|
176
|
+
# `widen_for_outer_receiver` keeps nested-block-locals
|
|
177
|
+
# from being widened in the outer scope — only references
|
|
178
|
+
# with `depth >= 1` (true captures of the outer scope's
|
|
179
|
+
# locals) trigger widening, so descending into nested
|
|
180
|
+
# blocks is safe and necessary for the hkt_registry-shape
|
|
181
|
+
# case (an outer collection mutated inside an iterator
|
|
182
|
+
# block whose body is itself inside another block).
|
|
183
|
+
node.compact_child_nodes.each do |child|
|
|
184
|
+
scope = walk_for_outer_mutations(child, scope)
|
|
185
|
+
end
|
|
186
|
+
scope
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def widen_for_outer_receiver(call_node, scope)
|
|
190
|
+
receiver = call_node.receiver
|
|
191
|
+
return scope if receiver.nil?
|
|
192
|
+
|
|
193
|
+
case receiver
|
|
194
|
+
when Prism::LocalVariableReadNode
|
|
195
|
+
return scope if receiver.depth.zero?
|
|
196
|
+
|
|
197
|
+
widen_local(call_node.name, receiver.name, scope)
|
|
198
|
+
when Prism::InstanceVariableReadNode
|
|
199
|
+
widen_ivar(call_node.name, receiver.name, scope)
|
|
200
|
+
else
|
|
201
|
+
scope
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def widen_local(method_name, var_name, current_scope)
|
|
206
|
+
current = current_scope.local(var_name)
|
|
207
|
+
widened = widen_for_mutator(current, method_name)
|
|
208
|
+
return current_scope if widened.nil?
|
|
209
|
+
|
|
210
|
+
current_scope.with_local(var_name, widened)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def widen_ivar(method_name, var_name, current_scope)
|
|
214
|
+
current = current_scope.ivar(var_name)
|
|
215
|
+
widened = widen_for_mutator(current, method_name)
|
|
216
|
+
return current_scope if widened.nil?
|
|
217
|
+
|
|
218
|
+
current_scope.with_ivar(var_name, widened)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Returns the widened type for a binding whose receiver is
|
|
222
|
+
# about to be mutated by `method_name`, or `nil` when no
|
|
223
|
+
# widening applies (binding is not a literal-shape carrier,
|
|
224
|
+
# OR the method is not a mutator for that shape, OR the
|
|
225
|
+
# binding is already a nominal — no precision to lose).
|
|
226
|
+
def widen_for_mutator(type, method_name)
|
|
227
|
+
return nil if type.nil?
|
|
228
|
+
|
|
229
|
+
case type
|
|
230
|
+
when Type::Tuple
|
|
231
|
+
return nil unless ARRAY_MUTATORS.include?(method_name)
|
|
232
|
+
|
|
233
|
+
widen_tuple(type)
|
|
234
|
+
when Type::HashShape
|
|
235
|
+
return nil unless HASH_MUTATORS.include?(method_name)
|
|
236
|
+
|
|
237
|
+
widen_hash_shape(type)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# `Tuple[A, B, C]` → `Nominal[Array, [union(A, B, C)]]`.
|
|
242
|
+
# An empty tuple has no element evidence, so the widened
|
|
243
|
+
# form carries `untyped` element bound — matches the
|
|
244
|
+
# `tuple_to_array` widening already used by `BlockFolding`.
|
|
245
|
+
def widen_tuple(tuple)
|
|
246
|
+
element_type =
|
|
247
|
+
if tuple.elements.empty?
|
|
248
|
+
Type::Combinator.untyped
|
|
249
|
+
elsif tuple.elements.size == 1
|
|
250
|
+
tuple.elements.first
|
|
251
|
+
else
|
|
252
|
+
Type::Combinator.union(*tuple.elements)
|
|
253
|
+
end
|
|
254
|
+
Type::Combinator.nominal_of("Array", type_args: [element_type])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# `HashShape` (closed or open) → `Nominal[Hash, [Kunion,
|
|
258
|
+
# Vunion]]`. Empty / extra-keys-only shapes degrade to a
|
|
259
|
+
# fully-untyped Hash.
|
|
260
|
+
def widen_hash_shape(shape)
|
|
261
|
+
if shape.pairs.empty?
|
|
262
|
+
return Type::Combinator.nominal_of("Hash",
|
|
263
|
+
type_args: [Type::Combinator.untyped,
|
|
264
|
+
Type::Combinator.untyped])
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
key_type = key_union_for(shape.pairs.keys)
|
|
268
|
+
value_type = Type::Combinator.union(*shape.pairs.values)
|
|
269
|
+
Type::Combinator.nominal_of("Hash", type_args: [key_type, value_type])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Maps the literal Ruby key set (`Symbol` / `String`) to a
|
|
273
|
+
# union of the corresponding type carriers. We deliberately
|
|
274
|
+
# do NOT fold to a `Constant<:k1> | Constant<:k2>` union —
|
|
275
|
+
# that would be a precision improvement that complicates the
|
|
276
|
+
# widening contract; the goal here is to LOSE precision, not
|
|
277
|
+
# to record a new fact set.
|
|
278
|
+
def key_union_for(keys)
|
|
279
|
+
kinds = keys.map { |k| k.is_a?(Symbol) ? "Symbol" : "String" }.uniq
|
|
280
|
+
carriers = kinds.map { |name| Type::Combinator.nominal_of(name) }
|
|
281
|
+
carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|