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
|
@@ -1371,16 +1371,12 @@ module Rigor
|
|
|
1371
1371
|
# can resolve to a qualified class name. Anything else falls
|
|
1372
1372
|
# through to "no narrowing".
|
|
1373
1373
|
def analyse_class_predicate(node, scope, exact:)
|
|
1374
|
-
return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
1375
1374
|
return nil if node.arguments.nil?
|
|
1376
1375
|
return nil unless node.arguments.arguments.size == 1
|
|
1377
1376
|
|
|
1378
1377
|
bare_name = static_class_name(node.arguments.arguments.first)
|
|
1379
1378
|
return nil if bare_name.nil?
|
|
1380
1379
|
|
|
1381
|
-
current = scope.local(node.receiver.name)
|
|
1382
|
-
return nil if current.nil?
|
|
1383
|
-
|
|
1384
1380
|
# Resolve `bare_name` through the lexical-scope chain
|
|
1385
1381
|
# so a name shadowed by the current class / enclosing
|
|
1386
1382
|
# module wins over the top-level constant. Mirrors
|
|
@@ -1392,9 +1388,81 @@ module Rigor
|
|
|
1392
1388
|
# surface as a spurious `undefined-method` on
|
|
1393
1389
|
# subsequent `other.class_name` calls).
|
|
1394
1390
|
class_name = resolve_class_name_lexically(bare_name, scope)
|
|
1391
|
+
|
|
1392
|
+
case node.receiver
|
|
1393
|
+
when Prism::LocalVariableReadNode
|
|
1394
|
+
analyse_class_predicate_on_local(node, scope, class_name, exact)
|
|
1395
|
+
when Prism::CallNode
|
|
1396
|
+
analyse_class_predicate_on_chain(node, scope, class_name, exact)
|
|
1397
|
+
end
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
def analyse_class_predicate_on_local(node, scope, class_name, exact)
|
|
1401
|
+
current = scope.local(node.receiver.name)
|
|
1402
|
+
return nil if current.nil?
|
|
1403
|
+
|
|
1395
1404
|
class_predicate_scopes(scope, node.receiver.name, current, class_name, exact: exact)
|
|
1396
1405
|
end
|
|
1397
1406
|
|
|
1407
|
+
# Stable single-hop method-chain narrowing (ROADMAP §
|
|
1408
|
+
# Future cycles — "Method-call receiver narrowing across
|
|
1409
|
+
# stable receivers"). When the predicate's receiver is
|
|
1410
|
+
# `<local/ivar>.<method>` with no args and no block,
|
|
1411
|
+
# record the truthy / falsey narrowing in
|
|
1412
|
+
# `Scope#method_chain_narrowings` keyed on the chain
|
|
1413
|
+
# address. The dominated body's identical chain reads
|
|
1414
|
+
# then observe the narrowed type through
|
|
1415
|
+
# `ExpressionTyper#call_type_for`'s lookup.
|
|
1416
|
+
#
|
|
1417
|
+
# Heuristic-by-design (ROADMAP § "Soundness gap"): a
|
|
1418
|
+
# second call to `x.last` could in principle return a
|
|
1419
|
+
# different value than the first. The chain is dropped
|
|
1420
|
+
# on (1) receiver variable rebind (handled inside
|
|
1421
|
+
# `Scope#with_local` / `#with_ivar`), and (2) any
|
|
1422
|
+
# intervening call against the same root receiver
|
|
1423
|
+
# (handled by `StatementEvaluator#eval_call`'s
|
|
1424
|
+
# invalidation step). The Law of Demeter justifies the
|
|
1425
|
+
# single-hop restriction: a single-hop chain is the
|
|
1426
|
+
# idiomatic Ruby shape where re-evaluation soundness is
|
|
1427
|
+
# the strongest.
|
|
1428
|
+
def analyse_class_predicate_on_chain(node, scope, class_name, exact)
|
|
1429
|
+
address = stable_chain_address(node.receiver)
|
|
1430
|
+
return nil if address.nil?
|
|
1431
|
+
|
|
1432
|
+
current = scope.type_of(node.receiver)
|
|
1433
|
+
return nil if current.nil?
|
|
1434
|
+
|
|
1435
|
+
truthy_type = narrow_class(current, class_name, exact: exact, environment: scope.environment)
|
|
1436
|
+
falsey_type = narrow_not_class(current, class_name, exact: exact, environment: scope.environment)
|
|
1437
|
+
|
|
1438
|
+
[
|
|
1439
|
+
scope.with_method_chain_narrowing(*address, truthy_type),
|
|
1440
|
+
scope.with_method_chain_narrowing(*address, falsey_type)
|
|
1441
|
+
]
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1444
|
+
# Returns `[receiver_kind, receiver_name, method_name]`
|
|
1445
|
+
# iff `chain_call` is a stable single-hop chain whose
|
|
1446
|
+
# root is a local / ivar read and whose own call shape
|
|
1447
|
+
# has no positional arguments and no block. Other shapes
|
|
1448
|
+
# (multi-hop, args, block, method-defined-on-arbitrary-
|
|
1449
|
+
# receiver) lose stability for one of the reasons
|
|
1450
|
+
# enumerated in the slice's design notes.
|
|
1451
|
+
def stable_chain_address(chain_call)
|
|
1452
|
+
return nil unless chain_call.is_a?(Prism::CallNode)
|
|
1453
|
+
return nil unless chain_call.block.nil?
|
|
1454
|
+
|
|
1455
|
+
args = chain_call.arguments
|
|
1456
|
+
return nil unless args.nil? || args.arguments.empty?
|
|
1457
|
+
|
|
1458
|
+
case chain_call.receiver
|
|
1459
|
+
when Prism::LocalVariableReadNode
|
|
1460
|
+
[:local, chain_call.receiver.name, chain_call.name]
|
|
1461
|
+
when Prism::InstanceVariableReadNode
|
|
1462
|
+
[:ivar, chain_call.receiver.name, chain_call.name]
|
|
1463
|
+
end
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1398
1466
|
# Walks the lexical-nesting chain derived from
|
|
1399
1467
|
# `scope.self_type` and returns the first
|
|
1400
1468
|
# `<prefix>::<bare_name>` (or bare `<bare_name>` at the
|
|
@@ -4,6 +4,7 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../scope"
|
|
6
6
|
require_relative "../type"
|
|
7
|
+
require_relative "mutation_widening"
|
|
7
8
|
require_relative "narrowing"
|
|
8
9
|
require_relative "statement_evaluator"
|
|
9
10
|
|
|
@@ -126,7 +127,16 @@ module Rigor
|
|
|
126
127
|
table = {}.compare_by_identity
|
|
127
128
|
table.default = seeded_scope
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
# Last-visit-wins, not first: when `StatementEvaluator`
|
|
131
|
+
# internally re-evaluates a subtree (notably `eval_begin`'s
|
|
132
|
+
# retry-edge widening pass), the LATER visit carries the
|
|
133
|
+
# corrected entry scope (e.g. a `tries` widened to
|
|
134
|
+
# `Nominal[Integer]` after the rescue body's `tries += 1;
|
|
135
|
+
# retry` is observed). The diagnostic layer reads
|
|
136
|
+
# `table[node]` to type predicates; the second pass's
|
|
137
|
+
# entry is the one that reflects all flow-derived
|
|
138
|
+
# rebinds, so it MUST overwrite the first.
|
|
139
|
+
on_enter = ->(node, scope) { table[node] = scope }
|
|
130
140
|
StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root)
|
|
131
141
|
|
|
132
142
|
propagate(root, table, seeded_scope)
|
|
@@ -172,11 +182,135 @@ module Rigor
|
|
|
172
182
|
# via `Type::Combinator.union`.
|
|
173
183
|
def build_class_ivar_index(root, default_scope)
|
|
174
184
|
accumulator = {}
|
|
175
|
-
|
|
185
|
+
mutated_ivars = {}
|
|
186
|
+
read_before_write = {}
|
|
187
|
+
init_writes = {}
|
|
188
|
+
walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
|
|
189
|
+
read_before_write, init_writes)
|
|
190
|
+
widen_mutated_ivar_entries!(accumulator, mutated_ivars)
|
|
191
|
+
contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
|
|
176
192
|
accumulator.transform_values(&:freeze).freeze
|
|
177
193
|
end
|
|
178
194
|
|
|
179
|
-
|
|
195
|
+
# B2.3 — finalize the read-before-write nil contribution.
|
|
196
|
+
# For each class, for each ivar where SOME method body
|
|
197
|
+
# observed a read-before-write AND no `initialize` write
|
|
198
|
+
# exists for that ivar, contribute `Constant[nil]` to the
|
|
199
|
+
# class-wide accumulator.
|
|
200
|
+
#
|
|
201
|
+
# The `initialize` filter is the soundness gate: Ruby
|
|
202
|
+
# semantics guarantee `initialize` runs first (via
|
|
203
|
+
# `Class.new`), so a write there reaches every other
|
|
204
|
+
# method body's read. Read-before-write in a non-init
|
|
205
|
+
# method is then NOT a nil-at-runtime case — it's just
|
|
206
|
+
# AST-order coincidence. Without this filter a normal
|
|
207
|
+
# `def initialize; @x = ... end` / `def use; @x.foo end`
|
|
208
|
+
# class would have `@x` widened with nil, producing FPs
|
|
209
|
+
# at every `@x.foo` call.
|
|
210
|
+
def contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
|
|
211
|
+
nil_t = Type::Combinator.constant_of(nil)
|
|
212
|
+
read_before_write.each do |class_name, ivar_set|
|
|
213
|
+
init_set = init_writes[class_name] || EMPTY_GUARDED_IVARS
|
|
214
|
+
per_class = accumulator[class_name]
|
|
215
|
+
next if per_class.nil?
|
|
216
|
+
|
|
217
|
+
ivar_set.each do |ivar_name|
|
|
218
|
+
# Soundness gates (in order):
|
|
219
|
+
# (1) `initialize` writes the ivar → it's set
|
|
220
|
+
# before any other method runs, so the
|
|
221
|
+
# read-before-write in a sibling method is
|
|
222
|
+
# NOT a runtime nil case.
|
|
223
|
+
# (2) The accumulator has NO entry for the ivar
|
|
224
|
+
# → some write was deliberately skipped (the
|
|
225
|
+
# falsey-default `@x = nil unless @x` slice's
|
|
226
|
+
# no-seed behaviour). Adding nil here would
|
|
227
|
+
# defeat that skip and re-introduce the
|
|
228
|
+
# `Constant[nil]` FP the skip silenced.
|
|
229
|
+
next if init_set.include?(ivar_name)
|
|
230
|
+
next unless per_class.key?(ivar_name)
|
|
231
|
+
|
|
232
|
+
existing = per_class[ivar_name]
|
|
233
|
+
per_class[ivar_name] = Type::Combinator.union(existing, nil_t)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Walks the post-collected accumulator and widens any Tuple
|
|
239
|
+
# / HashShape entry for an ivar that observed a mutator call
|
|
240
|
+
# anywhere in the same class body. The mutation evidence
|
|
241
|
+
# comes from `gather_ivar_writes` recording every
|
|
242
|
+
# `@ivar.<method>(...)` call whose method is in
|
|
243
|
+
# `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.
|
|
244
|
+
#
|
|
245
|
+
# The widening uses `MutationWidening.widen_for_mutator` —
|
|
246
|
+
# the same primitive `Inference::StatementEvaluator#eval_call`
|
|
247
|
+
# applies for per-method-body widening on a local / ivar
|
|
248
|
+
# receiver. The class-level pass extends that primitive's
|
|
249
|
+
# reach so a `Tuple`-seeded ivar in `initialize` is observed
|
|
250
|
+
# as `Nominal[Array]` at the entry of every OTHER method
|
|
251
|
+
# body in the class — closing the cross-method gap noted in
|
|
252
|
+
# ROADMAP § Future cycles / Type-language / engine
|
|
253
|
+
# ("Tuple / HashShape widening for ivar-seeded literals
|
|
254
|
+
# after mutation"; Redmine 6.1.2
|
|
255
|
+
# `Redmine::Views::Builders::Structure` is the canonical
|
|
256
|
+
# worked site).
|
|
257
|
+
#
|
|
258
|
+
# Always-safe: the widening can only LOSE precision; the
|
|
259
|
+
# underlying nominal (`Array` / `Hash`) and the element
|
|
260
|
+
# union are preserved.
|
|
261
|
+
def widen_mutated_ivar_entries!(accumulator, mutated_ivars)
|
|
262
|
+
accumulator.each do |class_name, ivars|
|
|
263
|
+
observed = mutated_ivars[class_name]
|
|
264
|
+
next if observed.nil? || observed.empty?
|
|
265
|
+
|
|
266
|
+
ivars.each do |ivar_name, type|
|
|
267
|
+
methods = observed[ivar_name]
|
|
268
|
+
next if methods.nil? || methods.empty?
|
|
269
|
+
|
|
270
|
+
ivars[ivar_name] = widen_type_for_observed_mutators(type, methods)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Walks a class-ivar accumulator entry (which may be a
|
|
276
|
+
# `Union` of multiple write rvalues) and widens any
|
|
277
|
+
# `Tuple` or `HashShape` member whose corresponding
|
|
278
|
+
# mutator family was observed against the ivar somewhere
|
|
279
|
+
# in the class. Class-level widening is more aggressive
|
|
280
|
+
# than the per-method-body `MutationWidening` primitive:
|
|
281
|
+
# it widens both the SHAPE carrier (Tuple → Array,
|
|
282
|
+
# HashShape → Hash) AND the element types to
|
|
283
|
+
# `Dynamic[Top]`. The justification — once any method
|
|
284
|
+
# mutates the ivar, its post-mutation contents are
|
|
285
|
+
# statically unknown across method boundaries, so
|
|
286
|
+
# preserving the seed-write's element precision would be
|
|
287
|
+
# an unsound over-claim (e.g. `@struct = [{}]; somewhere:
|
|
288
|
+
# @struct << []` makes the next read's element no longer
|
|
289
|
+
# `Constant[{}]`).
|
|
290
|
+
def widen_type_for_observed_mutators(type, observed_methods)
|
|
291
|
+
members = type.is_a?(Type::Union) ? type.members : [type]
|
|
292
|
+
widened = members.map { |m| widen_member_for_observed_mutators(m, observed_methods) }
|
|
293
|
+
Type::Combinator.union(*widened)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def widen_member_for_observed_mutators(member, observed_methods)
|
|
297
|
+
case member
|
|
298
|
+
when Type::Tuple
|
|
299
|
+
return member unless observed_methods.any? { |m| MutationWidening::ARRAY_MUTATORS.include?(m) }
|
|
300
|
+
|
|
301
|
+
Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
|
|
302
|
+
when Type::HashShape
|
|
303
|
+
return member unless observed_methods.any? { |m| MutationWidening::HASH_MUTATORS.include?(m) }
|
|
304
|
+
|
|
305
|
+
Type::Combinator.nominal_of("Hash",
|
|
306
|
+
type_args: [Type::Combinator.untyped, Type::Combinator.untyped])
|
|
307
|
+
else
|
|
308
|
+
member
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars,
|
|
313
|
+
read_before_write = nil, init_writes = nil)
|
|
180
314
|
return unless node.is_a?(Prism::Node)
|
|
181
315
|
|
|
182
316
|
case node
|
|
@@ -184,20 +318,42 @@ module Rigor
|
|
|
184
318
|
name = qualified_name_for(node.constant_path)
|
|
185
319
|
if name
|
|
186
320
|
child_prefix = qualified_prefix + [name]
|
|
187
|
-
|
|
321
|
+
if node.body
|
|
322
|
+
# Class-body level `@x = nil` writes don't
|
|
323
|
+
# initialise instance ivars at runtime (the
|
|
324
|
+
# class's own singleton ivars and the instance's
|
|
325
|
+
# ivars are separate stores), but they signal
|
|
326
|
+
# "the author KNOWS @x could be nil" and extend
|
|
327
|
+
# the B2.3 soundness gate: an ivar with a
|
|
328
|
+
# class-body write is exempted from the
|
|
329
|
+
# read-before-write nil contribution because the
|
|
330
|
+
# seed already reflects the author's acknowledged
|
|
331
|
+
# nullability via the def-body writes' union.
|
|
332
|
+
# Without this exemption, code that explicitly
|
|
333
|
+
# `@x = nil`s at class-body level then writes
|
|
334
|
+
# `@x = SomeClass.new` inside an instance method
|
|
335
|
+
# gains an unjustified nil widening at every
|
|
336
|
+
# read.
|
|
337
|
+
collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
|
|
338
|
+
walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
|
|
339
|
+
mutated_ivars, read_before_write, init_writes)
|
|
340
|
+
end
|
|
188
341
|
return
|
|
189
342
|
end
|
|
190
343
|
when Prism::DefNode
|
|
191
|
-
collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator
|
|
344
|
+
collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
|
|
345
|
+
mutated_ivars, read_before_write, init_writes)
|
|
192
346
|
return
|
|
193
347
|
end
|
|
194
348
|
|
|
195
349
|
node.compact_child_nodes.each do |child|
|
|
196
|
-
walk_class_ivars(child, qualified_prefix, default_scope, accumulator
|
|
350
|
+
walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
|
|
351
|
+
mutated_ivars, read_before_write, init_writes)
|
|
197
352
|
end
|
|
198
353
|
end
|
|
199
354
|
|
|
200
|
-
def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator
|
|
355
|
+
def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars,
|
|
356
|
+
read_before_write = nil, init_writes = nil)
|
|
201
357
|
return if def_node.body.nil? || qualified_prefix.empty?
|
|
202
358
|
|
|
203
359
|
class_name = qualified_prefix.join("::")
|
|
@@ -211,32 +367,273 @@ module Rigor
|
|
|
211
367
|
end
|
|
212
368
|
body_scope = default_scope.with_self_type(self_type)
|
|
213
369
|
|
|
214
|
-
gather_ivar_writes(def_node.body, body_scope, class_name, accumulator)
|
|
370
|
+
gather_ivar_writes(def_node.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
|
|
371
|
+
|
|
372
|
+
# B2.3 — collect per-method evidence for the read-before-
|
|
373
|
+
# write nil contribution. The accumulator-level decision
|
|
374
|
+
# ("is this ivar truly read-before-write across the
|
|
375
|
+
# class lifetime?") is finalised at
|
|
376
|
+
# `contribute_read_before_write_nil!` after the whole
|
|
377
|
+
# class body has been walked, using `init_writes` as
|
|
378
|
+
# the soundness gate (an ivar written in `initialize`
|
|
379
|
+
# is initialised before any other method body runs).
|
|
380
|
+
collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Walks the method body in AST (== execution) order
|
|
384
|
+
# tracking ivar names whose first reference is a read.
|
|
385
|
+
# The set is unioned into the class-wide
|
|
386
|
+
# `read_before_write` accumulator. For `initialize` def
|
|
387
|
+
# bodies, every write target is unioned into
|
|
388
|
+
# `init_writes` instead — used by the finalisation step
|
|
389
|
+
# to suppress nil contribution for ivars the constructor
|
|
390
|
+
# guarantees are initialised.
|
|
391
|
+
def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
|
|
392
|
+
return if read_before_write.nil? || init_writes.nil?
|
|
393
|
+
|
|
394
|
+
seen_writes = Set.new
|
|
395
|
+
read_first = Set.new
|
|
396
|
+
detect_read_before_write(def_node.body, seen_writes, read_first)
|
|
397
|
+
|
|
398
|
+
if def_node.name == :initialize
|
|
399
|
+
init_set = (init_writes[class_name] ||= Set.new)
|
|
400
|
+
seen_writes.each { |name| init_set << name }
|
|
401
|
+
return
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
return if read_first.empty?
|
|
405
|
+
|
|
406
|
+
rbw_set = (read_before_write[class_name] ||= Set.new)
|
|
407
|
+
read_first.each { |name| rbw_set << name }
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
IVAR_WRITE_NODES = [
|
|
411
|
+
Prism::InstanceVariableWriteNode,
|
|
412
|
+
Prism::InstanceVariableOrWriteNode,
|
|
413
|
+
Prism::InstanceVariableAndWriteNode,
|
|
414
|
+
Prism::InstanceVariableOperatorWriteNode
|
|
415
|
+
].freeze
|
|
416
|
+
private_constant :IVAR_WRITE_NODES
|
|
417
|
+
|
|
418
|
+
# Walks class-body level statements (i.e. NOT inside any
|
|
419
|
+
# nested DefNode / ClassNode / ModuleNode) and records
|
|
420
|
+
# every `@x = …` write target as a class-body init.
|
|
421
|
+
# Consumed by `contribute_read_before_write_nil!` to
|
|
422
|
+
# exempt ivars the author already knows might be nil
|
|
423
|
+
# (the `@x = nil` at class-body level is the canonical
|
|
424
|
+
# nullability acknowledgement; the instance @x is
|
|
425
|
+
# technically a separate store, but the pragmatic intent
|
|
426
|
+
# is unambiguous).
|
|
427
|
+
def collect_class_body_ivar_writes(node, class_name, init_writes)
|
|
428
|
+
return unless node.is_a?(Prism::Node)
|
|
429
|
+
return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
430
|
+
|
|
431
|
+
if node.is_a?(Prism::InstanceVariableWriteNode) ||
|
|
432
|
+
node.is_a?(Prism::InstanceVariableOrWriteNode) ||
|
|
433
|
+
node.is_a?(Prism::InstanceVariableAndWriteNode) ||
|
|
434
|
+
node.is_a?(Prism::InstanceVariableOperatorWriteNode)
|
|
435
|
+
(init_writes[class_name] ||= Set.new) << node.name
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
node.compact_child_nodes.each do |child|
|
|
439
|
+
collect_class_body_ivar_writes(child, class_name, init_writes)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def detect_read_before_write(node, seen_writes, read_first)
|
|
444
|
+
return unless node.is_a?(Prism::Node)
|
|
445
|
+
return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
446
|
+
|
|
447
|
+
read_first << node.name if node.is_a?(Prism::InstanceVariableReadNode) && !seen_writes.include?(node.name)
|
|
448
|
+
|
|
449
|
+
# Descend BEFORE recording a write — `@x = @x + 1`'s
|
|
450
|
+
# RHS is an `InstanceVariableReadNode` that runs before
|
|
451
|
+
# the write is committed; the read is therefore
|
|
452
|
+
# read-before-write semantically. Prism's
|
|
453
|
+
# `compact_child_nodes` returns the value child before
|
|
454
|
+
# the lvalue target, matching this order.
|
|
455
|
+
node.compact_child_nodes.each do |c|
|
|
456
|
+
detect_read_before_write(c, seen_writes, read_first)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
seen_writes << node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
|
|
215
460
|
end
|
|
216
461
|
|
|
217
462
|
IVAR_BARRIER_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode].freeze
|
|
218
463
|
private_constant :IVAR_BARRIER_NODES
|
|
219
464
|
|
|
220
|
-
|
|
465
|
+
EMPTY_GUARDED_IVARS = Set.new.freeze
|
|
466
|
+
private_constant :EMPTY_GUARDED_IVARS
|
|
467
|
+
|
|
468
|
+
def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
|
|
469
|
+
mutated_ivars = nil)
|
|
221
470
|
return unless node.is_a?(Prism::Node)
|
|
222
471
|
|
|
223
|
-
|
|
472
|
+
if node.is_a?(Prism::InstanceVariableWriteNode)
|
|
473
|
+
record_ivar_write(node, scope, class_name, accumulator,
|
|
474
|
+
guarded: guarded_ivars.include?(node.name))
|
|
475
|
+
end
|
|
476
|
+
record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
|
|
224
477
|
|
|
225
478
|
# Don't recurse into nested defs, classes, or modules; their
|
|
226
479
|
# ivars belong to their own enclosing class.
|
|
227
480
|
return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
228
481
|
|
|
229
|
-
node.
|
|
482
|
+
if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
483
|
+
walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
|
|
484
|
+
return
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
node.compact_child_nodes.each do |c|
|
|
488
|
+
gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Records `@ivar.<method>(...)` calls whose method is in
|
|
493
|
+
# `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.
|
|
494
|
+
# The class-ivar pre-pass uses the resulting set to widen
|
|
495
|
+
# the post-collected accumulator entries (see
|
|
496
|
+
# {.widen_mutated_ivar_entries!}). Always-safe to over-
|
|
497
|
+
# collect: any name that the widening primitive declines
|
|
498
|
+
# is ignored at finalization.
|
|
499
|
+
def record_ivar_mutator_call(node, class_name, mutated_ivars)
|
|
500
|
+
receiver = node.receiver
|
|
501
|
+
return unless receiver.is_a?(Prism::InstanceVariableReadNode)
|
|
502
|
+
return unless MutationWidening::ARRAY_MUTATORS.include?(node.name) ||
|
|
503
|
+
MutationWidening::HASH_MUTATORS.include?(node.name)
|
|
504
|
+
|
|
505
|
+
per_class = (mutated_ivars[class_name] ||= {})
|
|
506
|
+
per_ivar = (per_class[receiver.name] ||= Set.new)
|
|
507
|
+
per_ivar << node.name
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Walk an `IfNode` / `UnlessNode` so writes inside the THEN body
|
|
511
|
+
# that look like defensive ivar initialisation gain a `nil` union
|
|
512
|
+
# in the seeded type. Without this, `@x = v unless @x` records
|
|
513
|
+
# `Constant[v]` for `@x`, then the predicate folds to that same
|
|
514
|
+
# constant and `flow.always-truthy-condition` fires against a
|
|
515
|
+
# working program. Mirrors the existing skip for `@x ||= v`
|
|
516
|
+
# (`Prism::InstanceVariableOrWriteNode`, which the pre-pass does
|
|
517
|
+
# not seed at all).
|
|
518
|
+
#
|
|
519
|
+
# Polarity-aware on purpose: only the THEN body picks up the
|
|
520
|
+
# guard. The ELSE branch of `if @x; ...; else; @x = init; end`
|
|
521
|
+
# would otherwise be marked too — but that pattern (write @x in
|
|
522
|
+
# the else of `if @x`) is a separate idiom whose surrounding
|
|
523
|
+
# reads of `@x` would then surface a nil-receiver FP. The
|
|
524
|
+
# ELSE branch is left ungarded so those reads continue to type
|
|
525
|
+
# as they did before this fix.
|
|
526
|
+
def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil)
|
|
527
|
+
then_guards = then_body_guarded_ivars(node)
|
|
528
|
+
then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
|
|
529
|
+
|
|
530
|
+
gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
|
|
531
|
+
if node.statements
|
|
532
|
+
gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded, mutated_ivars)
|
|
533
|
+
end
|
|
534
|
+
branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
|
|
535
|
+
gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars, mutated_ivars) if branch
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Returns the set of ivar names that, in the THEN body of this
|
|
539
|
+
# conditional, are statically known to be in a nil / unset state
|
|
540
|
+
# — i.e. the body really IS the defensive-init half of the
|
|
541
|
+
# idiom. Conservative on purpose: only the shapes that
|
|
542
|
+
# idiomatically express "the ivar is missing" qualify.
|
|
543
|
+
#
|
|
544
|
+
# For `unless P; body; end`, body runs when `P` is falsey:
|
|
545
|
+
# - `P = @x` (or `@x && other` / `@x || other`) → @x is falsey
|
|
546
|
+
# - `P = defined?(@x)` → @x is undefined
|
|
547
|
+
#
|
|
548
|
+
# For `if P; body; ...`, body runs when `P` is truthy:
|
|
549
|
+
# - `P = @x.nil?` → @x is nil
|
|
550
|
+
# - `P = !@x` / `not @x` → @x is falsey
|
|
551
|
+
def then_body_guarded_ivars(node)
|
|
552
|
+
names = Set.new
|
|
553
|
+
if node.is_a?(Prism::UnlessNode)
|
|
554
|
+
collect_truthy_test_ivars(node.predicate, names)
|
|
555
|
+
collect_defined_test_ivars(node.predicate, names)
|
|
556
|
+
else
|
|
557
|
+
collect_nil_test_ivars(node.predicate, names)
|
|
558
|
+
end
|
|
559
|
+
names
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def collect_truthy_test_ivars(node, names)
|
|
563
|
+
return unless node.is_a?(Prism::Node)
|
|
564
|
+
|
|
565
|
+
case node
|
|
566
|
+
when Prism::InstanceVariableReadNode
|
|
567
|
+
names << node.name
|
|
568
|
+
when Prism::AndNode, Prism::OrNode
|
|
569
|
+
collect_truthy_test_ivars(node.left, names)
|
|
570
|
+
collect_truthy_test_ivars(node.right, names)
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def collect_defined_test_ivars(node, names)
|
|
575
|
+
return unless node.is_a?(Prism::Node)
|
|
576
|
+
|
|
577
|
+
case node
|
|
578
|
+
when Prism::DefinedNode
|
|
579
|
+
target = node.value
|
|
580
|
+
names << target.name if target.is_a?(Prism::InstanceVariableReadNode)
|
|
581
|
+
when Prism::AndNode, Prism::OrNode
|
|
582
|
+
collect_defined_test_ivars(node.left, names)
|
|
583
|
+
collect_defined_test_ivars(node.right, names)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def collect_nil_test_ivars(node, names)
|
|
588
|
+
return unless node.is_a?(Prism::Node)
|
|
589
|
+
|
|
590
|
+
case node
|
|
591
|
+
when Prism::CallNode
|
|
592
|
+
receiver = node.receiver
|
|
593
|
+
if receiver.is_a?(Prism::InstanceVariableReadNode) &&
|
|
594
|
+
%i[nil? !].include?(node.name)
|
|
595
|
+
names << receiver.name
|
|
596
|
+
end
|
|
597
|
+
when Prism::AndNode, Prism::OrNode
|
|
598
|
+
collect_nil_test_ivars(node.left, names)
|
|
599
|
+
collect_nil_test_ivars(node.right, names)
|
|
600
|
+
end
|
|
230
601
|
end
|
|
231
602
|
|
|
232
|
-
def record_ivar_write(node, scope, class_name, accumulator)
|
|
603
|
+
def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
|
|
233
604
|
rvalue_type = scope.type_of(node.value)
|
|
605
|
+
|
|
606
|
+
# `@x = nil unless @x` / `@y = false unless @y` —
|
|
607
|
+
# follow-up to the polarity-aware defensive-init guard
|
|
608
|
+
# fix (ROADMAP § Future cycles — "Defensive ivar-init
|
|
609
|
+
# with nil / false rvalue"). When the rvalue is itself a
|
|
610
|
+
# falsey Constant, `union(rvalue, Constant[nil])`
|
|
611
|
+
# collapses (for `nil`) or doesn't widen the type's
|
|
612
|
+
# truthiness profile (for `false`) — the predicate
|
|
613
|
+
# `unless @x` then folds to a single `Constant[nil]` /
|
|
614
|
+
# `Constant[false]` and the
|
|
615
|
+
# `flow.always-truthy-condition` / `-always-falsey-`
|
|
616
|
+
# rule false-fires on the no-op-but-documented-default
|
|
617
|
+
# idiom. Skip the seed contribution for this write
|
|
618
|
+
# (matches the existing skip for `@x ||= v`, which the
|
|
619
|
+
# pre-pass also does not seed). Other writes to the
|
|
620
|
+
# same ivar still contribute; the falsey-default write
|
|
621
|
+
# carries no useful precision the predicate hasn't
|
|
622
|
+
# already given us. See tdiary-core HEAD `ee40c2b`
|
|
623
|
+
# `lib/tdiary/configuration.rb:157` for the worked site.
|
|
624
|
+
return if guarded && falsey_constant?(rvalue_type)
|
|
625
|
+
|
|
626
|
+
rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded
|
|
234
627
|
accumulator[class_name] ||= {}
|
|
235
628
|
existing = accumulator[class_name][node.name]
|
|
236
629
|
accumulator[class_name][node.name] =
|
|
237
630
|
existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
|
|
238
631
|
end
|
|
239
632
|
|
|
633
|
+
def falsey_constant?(type)
|
|
634
|
+
type.is_a?(Type::Constant) && (type.value.nil? || type.value == false)
|
|
635
|
+
end
|
|
636
|
+
|
|
240
637
|
# Slice 7 phase 6 — class-cvar pre-pass. Same shape as the
|
|
241
638
|
# ivar pre-pass but collects `Prism::ClassVariableWriteNode`
|
|
242
639
|
# writes inside ANY def body (instance or singleton) of the
|