rigortype 0.1.17 → 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 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- 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 +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -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 +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- 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/overload_selector.rb +33 -1
- 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 +187 -55
- 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 +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- 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 +192 -0
- data/lib/rigor/plugin/registry.rb +264 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -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/analyzer.rb +1 -2
- 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/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- 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/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
- 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/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- 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-ci-setup/SKILL.md +319 -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 +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -7,6 +7,7 @@ require_relative "../type"
|
|
|
7
7
|
require_relative "../analysis/fact_store"
|
|
8
8
|
require_relative "../source/node_walker"
|
|
9
9
|
require_relative "block_parameter_binder"
|
|
10
|
+
require_relative "body_fixpoint"
|
|
10
11
|
require_relative "closure_escape_analyzer"
|
|
11
12
|
require_relative "indexed_narrowing"
|
|
12
13
|
require_relative "method_dispatcher"
|
|
@@ -98,10 +99,22 @@ module Rigor
|
|
|
98
99
|
Prism::SingletonClassNode => :eval_singleton_class,
|
|
99
100
|
Prism::CallNode => :eval_call,
|
|
100
101
|
Prism::BlockNode => :eval_block,
|
|
102
|
+
Prism::ReturnNode => :eval_return,
|
|
101
103
|
Prism::MatchWriteNode => :eval_match_write
|
|
102
104
|
}.freeze
|
|
103
105
|
private_constant :HANDLERS
|
|
104
106
|
|
|
107
|
+
# Thread-local sink (an Array) collecting the value types of explicit
|
|
108
|
+
# `return value` nodes reached while evaluating a method body, so
|
|
109
|
+
# `ExpressionTyper#infer_user_method_return` can join them into the
|
|
110
|
+
# method's inferred return type. The flow value of a `return` is still
|
|
111
|
+
# `Bot` (it transfers control rather than producing a value); the sink
|
|
112
|
+
# only records what the method *returns* through that edge. nil means
|
|
113
|
+
# "not collecting" — a top-level / DSL-block walk, or inside a nested
|
|
114
|
+
# `def` barrier (whose returns belong to the inner method).
|
|
115
|
+
RETURN_SINK_KEY = :rigor_return_sink
|
|
116
|
+
private_constant :RETURN_SINK_KEY
|
|
117
|
+
|
|
105
118
|
# Lexical class frame: the `name:` field is the qualified class
|
|
106
119
|
# name as it would render in Ruby (e.g., `"Foo::Bar"`); the
|
|
107
120
|
# `singleton:` field is `true` for `class << self` frames so
|
|
@@ -121,11 +134,40 @@ module Rigor
|
|
|
121
134
|
# by {#eval_def} to look up the method's RBS signature. Each
|
|
122
135
|
# `ClassNode`/`ModuleNode` entry pushes a frame; `SingletonClassNode`
|
|
123
136
|
# over `self` flips the innermost frame to singleton mode.
|
|
124
|
-
|
|
137
|
+
# @param converged_loop_recording [Boolean] when true (and an
|
|
138
|
+
# `on_enter` recorder is installed), {#eval_loop} re-evaluates a
|
|
139
|
+
# fixpoint-tracked loop body ONE extra time from the CONVERGED
|
|
140
|
+
# bindings so the last-visit-wins per-node scope index reflects
|
|
141
|
+
# the post-writeback state instead of the cap-N intermediate
|
|
142
|
+
# assumption (`result *= i` annotating `1 | 2` rather than
|
|
143
|
+
# `Integer`). Display-path only — `rigor check` leaves it off,
|
|
144
|
+
# keeping its diagnostics and wall-clock unchanged.
|
|
145
|
+
def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze,
|
|
146
|
+
converged_loop_recording: false)
|
|
125
147
|
@scope = scope
|
|
126
148
|
@tracer = tracer
|
|
127
149
|
@on_enter = on_enter
|
|
128
150
|
@class_context = class_context.freeze
|
|
151
|
+
@converged_loop_recording = converged_loop_recording
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Runs `block` with a fresh return sink installed, then yields the
|
|
155
|
+
# collected explicit-`return` value types to the caller. The sink is
|
|
156
|
+
# an array of `Rigor::Type`. Nested invocations stack: the previous
|
|
157
|
+
# sink is restored on exit so a `def` evaluated inside another method's
|
|
158
|
+
# body (which itself installed a sink) does not corrupt the outer one.
|
|
159
|
+
# Used by `ExpressionTyper#infer_user_method_return` to join the
|
|
160
|
+
# explicit returns into the inferred method-return type.
|
|
161
|
+
def self.with_return_sink
|
|
162
|
+
previous = Thread.current[RETURN_SINK_KEY]
|
|
163
|
+
sink = []
|
|
164
|
+
Thread.current[RETURN_SINK_KEY] = sink
|
|
165
|
+
begin
|
|
166
|
+
result = yield
|
|
167
|
+
ensure
|
|
168
|
+
Thread.current[RETURN_SINK_KEY] = previous
|
|
169
|
+
end
|
|
170
|
+
[result, sink]
|
|
129
171
|
end
|
|
130
172
|
|
|
131
173
|
# Evaluate `node` under the receiver scope. Returns `[type, scope']`
|
|
@@ -178,9 +220,28 @@ module Rigor
|
|
|
178
220
|
# default branch in {#evaluate}.
|
|
179
221
|
def eval_local_write(node)
|
|
180
222
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
223
|
+
# ADR-58 WD1 — `r = @right` where `@right`'s optionality is purely
|
|
224
|
+
# declaration-sourced makes `r` declaration-sourced too (the survey's
|
|
225
|
+
# exact rotation/traversal shape `r = @right; r.key`). The mark is
|
|
226
|
+
# computed on the RHS *value*'s provenance — a pure ivar read of a
|
|
227
|
+
# currently declaration-sourced ivar — so it survives the local copy.
|
|
228
|
+
# Any other RHS (a call result, a method-local-nil-bearing value)
|
|
229
|
+
# leaves the local flow-live and the diagnostic fires as before.
|
|
230
|
+
if declaration_sourced_ivar_read?(node.value, post_rhs)
|
|
231
|
+
return [rhs_type, post_rhs.with_declaration_sourced_local(node.name, rhs_type)]
|
|
232
|
+
end
|
|
233
|
+
|
|
181
234
|
[rhs_type, post_rhs.with_local(node.name, rhs_type)]
|
|
182
235
|
end
|
|
183
236
|
|
|
237
|
+
# True when `value_node` is a bare instance-variable read whose binding
|
|
238
|
+
# in `scope_at_read` is currently marked declaration-sourced.
|
|
239
|
+
def declaration_sourced_ivar_read?(value_node, scope_at_read)
|
|
240
|
+
return false unless value_node.is_a?(Prism::InstanceVariableReadNode)
|
|
241
|
+
|
|
242
|
+
scope_at_read.declaration_sourced?(:ivar, value_node.name)
|
|
243
|
+
end
|
|
244
|
+
|
|
184
245
|
# Slice 7 phase 1 — instance/class/global variable
|
|
185
246
|
# writes. Each handler evaluates the rvalue under the
|
|
186
247
|
# entry scope and binds the named variable into the
|
|
@@ -476,31 +537,19 @@ module Rigor
|
|
|
476
537
|
# carriers like `Nominal[Integer]` (Integer is always truthy
|
|
477
538
|
# in Ruby — including 0) also collapse the dead else.
|
|
478
539
|
def live_branch_for_if(node, pred_type, post_pred)
|
|
479
|
-
case predicate_certainty(pred_type)
|
|
480
|
-
when :
|
|
481
|
-
when :
|
|
540
|
+
case Narrowing.predicate_certainty(pred_type)
|
|
541
|
+
when :truthy then eval_branch_or_nil(node.statements, post_pred)
|
|
542
|
+
when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
|
|
482
543
|
end
|
|
483
544
|
end
|
|
484
545
|
|
|
485
546
|
def live_branch_for_unless(node, pred_type, post_pred)
|
|
486
|
-
case predicate_certainty(pred_type)
|
|
487
|
-
when :
|
|
488
|
-
when :
|
|
547
|
+
case Narrowing.predicate_certainty(pred_type)
|
|
548
|
+
when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
|
|
549
|
+
when :falsey then eval_branch_or_nil(node.statements, post_pred)
|
|
489
550
|
end
|
|
490
551
|
end
|
|
491
552
|
|
|
492
|
-
def predicate_certainty(pred_type)
|
|
493
|
-
return nil if pred_type.nil? || pred_type.is_a?(Type::Bot)
|
|
494
|
-
|
|
495
|
-
truthy_bot = Narrowing.narrow_truthy(pred_type).is_a?(Type::Bot)
|
|
496
|
-
falsey_bot = Narrowing.narrow_falsey(pred_type).is_a?(Type::Bot)
|
|
497
|
-
|
|
498
|
-
return :always_falsey if truthy_bot && !falsey_bot
|
|
499
|
-
return :always_truthy if !truthy_bot && falsey_bot
|
|
500
|
-
|
|
501
|
-
nil
|
|
502
|
-
end
|
|
503
|
-
|
|
504
553
|
def eval_else(node)
|
|
505
554
|
return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
|
|
506
555
|
|
|
@@ -518,12 +567,33 @@ module Rigor
|
|
|
518
567
|
else_result = eval_case_else(node.else_clause, falsey_scope)
|
|
519
568
|
|
|
520
569
|
all_results = [*branch_results, else_result]
|
|
570
|
+
branch_nodes = [*node.conditions, node.else_clause]
|
|
521
571
|
[
|
|
522
572
|
Type::Combinator.union(*all_results.map(&:first)),
|
|
523
|
-
|
|
573
|
+
join_case_branch_scopes(all_results, branch_nodes)
|
|
524
574
|
]
|
|
525
575
|
end
|
|
526
576
|
|
|
577
|
+
# Joins the post-scopes of every `when`/`in`/`else` branch, dropping
|
|
578
|
+
# the scope of any branch that terminates (raises / returns / throws /
|
|
579
|
+
# types to `Bot`) before the merge — control never falls through such
|
|
580
|
+
# a branch, so its half-bound locals must not nil-inject the names a
|
|
581
|
+
# live sibling branch assigned. Mirrors the `branch_terminates?` rule
|
|
582
|
+
# `eval_if`/`eval_unless` already apply to the if/else merge: e.g.
|
|
583
|
+
# `case x; when 1 then v="a"; when 2 then v="b"; else raise; end`
|
|
584
|
+
# keeps `v: "a" | "b"` instead of `... | nil`. When every branch
|
|
585
|
+
# terminates the merge is itself unreachable; fall back to the full
|
|
586
|
+
# join so the continuation scope stays well-formed.
|
|
587
|
+
def join_case_branch_scopes(results, nodes)
|
|
588
|
+
live = []
|
|
589
|
+
results.each_with_index do |(type, branch_scope), i|
|
|
590
|
+
live << branch_scope unless branch_terminates?(nodes[i], type)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
live = results.map(&:last) if live.empty?
|
|
594
|
+
reduce_scopes_with_nil_injection(live)
|
|
595
|
+
end
|
|
596
|
+
|
|
527
597
|
def eval_case_when_branches(subject, conditions, entry_scope)
|
|
528
598
|
results = []
|
|
529
599
|
falsey_scope = entry_scope
|
|
@@ -822,13 +892,204 @@ module Rigor
|
|
|
822
892
|
# common case where no `break VALUE` is observed.
|
|
823
893
|
def eval_loop(node)
|
|
824
894
|
_pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
825
|
-
return [Type::Combinator.constant_of(nil), post_pred] if node.statements.nil?
|
|
826
|
-
|
|
895
|
+
return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
|
|
896
|
+
|
|
897
|
+
# The historical single body pass joined with the pre-loop scope.
|
|
898
|
+
# This continues to carry everything the fixpoint does NOT track:
|
|
899
|
+
# receiver-mutation widening of non-rebound locals (`buf.push(i)`
|
|
900
|
+
# widens `buf`'s Tuple), body-introduced locals' nil-injection, and
|
|
901
|
+
# the loop value itself. The fixpoint then OVERLAYS only the
|
|
902
|
+
# rebound-local bindings it corrects.
|
|
827
903
|
_body_type, body_scope = sub_eval(node.statements, post_pred)
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
904
|
+
base_scope = join_with_nil_injection(post_pred, body_scope)
|
|
905
|
+
|
|
906
|
+
rebound, body_first = loop_body_local_writes(node.statements, post_pred)
|
|
907
|
+
names = rebound + body_first
|
|
908
|
+
|
|
909
|
+
# Fast path: a loop whose body rebinds no local skips the rebind
|
|
910
|
+
# fixpoint, but still needs the slice-C content writeback (a loop may
|
|
911
|
+
# content-mutate a collection without rebinding any local — `acc <<
|
|
912
|
+
# x`), so apply it to the single-pass join before returning.
|
|
913
|
+
if names.empty?
|
|
914
|
+
fast = loop_content_writeback(node.statements, base_scope)
|
|
915
|
+
return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and
|
|
919
|
+
# may compound (`d *= 2`), so the historical single body pass joined
|
|
920
|
+
# with the pre-loop scope kept stale folded constants
|
|
921
|
+
# (`d = 1; while …; d *= 2; end` → `1 | 2`, never reaching `4, 8`).
|
|
922
|
+
# Fold each body-written local's continuation binding through the
|
|
923
|
+
# same capped fixpoint slice A uses for non-escaping block captures.
|
|
924
|
+
#
|
|
925
|
+
# Seed: a pre-existing local seeds with its post-predicate binding;
|
|
926
|
+
# a local FIRST assigned inside the body seeds with `nil` so the
|
|
927
|
+
# 0-iteration path (the body may never run) degrades it to
|
|
928
|
+
# `T | nil`, matching the historical nil-injection treatment.
|
|
929
|
+
result = loop_rebind_fixpoint(node, post_pred, names, body_first)
|
|
930
|
+
# Display-path re-record: the fixpoint's body re-evaluations fire
|
|
931
|
+
# `on_enter` with the cap-N INTERMEDIATE assumptions, so the
|
|
932
|
+
# last-visit-wins scope index would annotate loop-body lines with
|
|
933
|
+
# stale pre-convergence constants. One extra pass from the
|
|
934
|
+
# converged bindings (result discarded) re-records the body's
|
|
935
|
+
# entry scopes post-writeback.
|
|
936
|
+
record_converged_loop_body(node, post_pred, result, names, body_first)
|
|
937
|
+
post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
938
|
+
# ADR-56 slice C — loop-body receiver-content element-type join. A
|
|
939
|
+
# loop that content-mutates a collection (`acc << n`) keeps only the
|
|
940
|
+
# seed's element types after the single-pass widen (B1 unsoundness:
|
|
941
|
+
# `acc = [0]; while …; acc << n; end` → `Array[0]`, runtime
|
|
942
|
+
# `[0, n, …]`). Join the appended/stored types into the continuation
|
|
943
|
+
# collection. Pre-state is read from `post_loop` so a local both
|
|
944
|
+
# rebound and content-mutated composes.
|
|
945
|
+
post_loop = loop_content_writeback(node.statements, post_loop)
|
|
946
|
+
post_loop = narrow_loop_exit_edge(node, post_loop)
|
|
947
|
+
[Type::Combinator.constant_of(nil), post_loop]
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred`
|
|
951
|
+
# loop exits PRECISELY on the predicate's exit edge: `while` exits when
|
|
952
|
+
# `pred` is falsey, `until` when `pred` is truthy. So after the loop the
|
|
953
|
+
# predicate-assignment target carries the exit polarity — `until line =
|
|
954
|
+
# io.gets; …; end; line.foo` reads `line` non-nil because the loop ran
|
|
955
|
+
# until `gets` returned a truthy (non-nil) line. Apply the exit edge of
|
|
956
|
+
# `Narrowing.predicate_scopes` to the continuation scope.
|
|
957
|
+
#
|
|
958
|
+
# Guarded against `break`: a `break` exits the loop WITHOUT the predicate
|
|
959
|
+
# ever going false (`while line = gets; break if done; end` can leave
|
|
960
|
+
# `line` truthy on a `while`, or exit before the `until` predicate fires),
|
|
961
|
+
# so the exit-edge proof does not hold and the loop is left un-narrowed.
|
|
962
|
+
# `break` inside a NESTED loop/block does not target this loop, but a
|
|
963
|
+
# nested-loop `break` is rare in predicate-assignment loops and the
|
|
964
|
+
# conservative bail only costs precision, never soundness.
|
|
965
|
+
def narrow_loop_exit_edge(node, post_loop)
|
|
966
|
+
return post_loop if loop_body_breaks?(node.statements)
|
|
967
|
+
|
|
968
|
+
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_loop)
|
|
969
|
+
node.is_a?(Prism::UntilNode) ? truthy_scope : falsey_scope
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# True when the loop body can `break` out of THIS loop. Conservatively
|
|
973
|
+
# treats any `BreakNode` under the body as a break for this loop (a
|
|
974
|
+
# break inside a nested loop/block actually targets the inner construct,
|
|
975
|
+
# but bailing is precision-only).
|
|
976
|
+
def loop_body_breaks?(statements)
|
|
977
|
+
return false if statements.nil?
|
|
978
|
+
|
|
979
|
+
found = false
|
|
980
|
+
Source::NodeWalker.each(statements) do |descendant|
|
|
981
|
+
found = true if descendant.is_a?(Prism::BreakNode)
|
|
982
|
+
end
|
|
983
|
+
found
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
# Joins loop-body content mutations into the continuation collection
|
|
987
|
+
# bindings. The mutator arguments are typed against `post_loop`, whose
|
|
988
|
+
# locals already carry the loop-body fixpoint widening (so an
|
|
989
|
+
# appended `n` that the loop decrements types `Integer`, not its
|
|
990
|
+
# entry `Constant[3]` — otherwise only the first iteration's value
|
|
991
|
+
# would be captured, an unsound under-approximation). Pre-state is
|
|
992
|
+
# read from `post_loop` too. A loop body shares the surrounding scope,
|
|
993
|
+
# so the receiver is any `LocalVariableReadNode` (no depth filter).
|
|
994
|
+
def loop_content_writeback(statements, post_loop)
|
|
995
|
+
return post_loop if statements.nil?
|
|
996
|
+
|
|
997
|
+
mutations = Hash.new { |h, k| h[k] = [] }
|
|
998
|
+
Source::NodeWalker.each(statements) do |descendant|
|
|
999
|
+
name, node = content_mutation_target(descendant) { |_r| true }
|
|
1000
|
+
mutations[name] << node unless name.nil?
|
|
1001
|
+
end
|
|
1002
|
+
return post_loop if mutations.empty?
|
|
1003
|
+
|
|
1004
|
+
mutations.reduce(post_loop) do |acc, (name, calls)|
|
|
1005
|
+
joined = join_content_for_local(name, calls, acc, post_loop)
|
|
1006
|
+
joined.nil? ? acc : acc.with_local(name, joined)
|
|
1007
|
+
end
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Re-evaluates the loop body once from the converged fixpoint
|
|
1011
|
+
# bindings, solely for the `on_enter` side effect of re-recording
|
|
1012
|
+
# the body's per-node entry scopes. Gated behind the
|
|
1013
|
+
# display-path-only `converged_loop_recording` flag so the check
|
|
1014
|
+
# path neither pays the extra body evaluation nor risks any
|
|
1015
|
+
# diagnostic drift.
|
|
1016
|
+
def record_converged_loop_body(node, post_pred, bindings, names, body_first)
|
|
1017
|
+
return unless @converged_loop_recording && @on_enter
|
|
1018
|
+
|
|
1019
|
+
loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
|
|
1020
|
+
nil
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
# Runs the slice-B loop-body rebind fixpoint, returning the per-name
|
|
1024
|
+
# continuation binding. Seed: a pre-existing local seeds with its
|
|
1025
|
+
# post-predicate binding; a local FIRST assigned inside the body seeds
|
|
1026
|
+
# with `nil` so the 0-iteration path (the body may never run) degrades
|
|
1027
|
+
# it to `T | nil`, matching the historical nil-injection treatment.
|
|
1028
|
+
def loop_rebind_fixpoint(node, post_pred, names, body_first)
|
|
1029
|
+
nil_const = Type::Combinator.constant_of(nil)
|
|
1030
|
+
seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
|
|
1031
|
+
BodyFixpoint.converge(
|
|
1032
|
+
names: names,
|
|
1033
|
+
seed_bindings: seed,
|
|
1034
|
+
widen: Type::Combinator.method(:widen_value_pinned),
|
|
1035
|
+
evaluate_body: ->(bindings) { loop_body_exit_bindings(node, post_pred, bindings, names, body_first) }
|
|
1036
|
+
)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Names of locals the loop body can rebind, partitioned into those
|
|
1040
|
+
# already bound in `base_scope` (their pre-loop binding seeds the
|
|
1041
|
+
# fixpoint) and those FIRST assigned inside the body (no pre-state, so
|
|
1042
|
+
# they seed with `nil` for 0-iteration soundness). A loop body
|
|
1043
|
+
# introduces no new binding scope — every write leaks to the
|
|
1044
|
+
# surrounding scope — so unlike a block there is no introduced-name
|
|
1045
|
+
# filter; every local-write form under the body node counts.
|
|
1046
|
+
def loop_body_local_writes(statements, base_scope)
|
|
1047
|
+
pre_existing = []
|
|
1048
|
+
body_first = []
|
|
1049
|
+
Source::NodeWalker.each(statements) do |descendant|
|
|
1050
|
+
next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
|
|
1051
|
+
|
|
1052
|
+
name = descendant.name
|
|
1053
|
+
if base_scope.locals.key?(name)
|
|
1054
|
+
pre_existing << name
|
|
1055
|
+
else
|
|
1056
|
+
body_first << name
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
[pre_existing.uniq, body_first.uniq - pre_existing.uniq]
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Evaluates the loop body once with each fixpoint-tracked local bound
|
|
1063
|
+
# to the supplied running assumption and returns the per-name exit
|
|
1064
|
+
# binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
|
|
1065
|
+
#
|
|
1066
|
+
# The body runs from `post_pred` overlaid with the assumptions, then
|
|
1067
|
+
# narrowed by the predicate's loop-entry edge: a `while` body only
|
|
1068
|
+
# runs when the predicate is TRUTHY, an `until` body only when it is
|
|
1069
|
+
# FALSEY. Re-applying that narrowing per iteration keeps loop-carried
|
|
1070
|
+
# narrowing sound — without it, an accumulator whose rebind can
|
|
1071
|
+
# introduce `nil` (`prefix = idx ? prefix[0, idx] : nil` under
|
|
1072
|
+
# `while prefix && …`) would re-enter the body with `nil` un-narrowed
|
|
1073
|
+
# and false-fire `possible nil receiver` on the guarded re-read. The
|
|
1074
|
+
# historical single body pass (which seeds these locals from their
|
|
1075
|
+
# never-nil pre-loop binding) did not need this; the fixpoint, which
|
|
1076
|
+
# feeds the widened assumption back in, does.
|
|
1077
|
+
#
|
|
1078
|
+
# A body-FIRST local (no pre-loop binding) is deliberately NOT overlaid
|
|
1079
|
+
# into the body-entry scope: when the body runs it assigns the local
|
|
1080
|
+
# before any use, exactly as the historical single body pass saw it.
|
|
1081
|
+
# Its `nil` seed exists only to model the 0-iteration path and is kept
|
|
1082
|
+
# as a join constituent by {BodyFixpoint#converge}; feeding that `nil`
|
|
1083
|
+
# back into the body re-evaluation would leak it past a condition-form
|
|
1084
|
+
# assignment the engine does not thread into the branch (`if exps.size
|
|
1085
|
+
# > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
|
|
1086
|
+
def loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
|
|
1087
|
+
overlaid = bindings.except(*body_first)
|
|
1088
|
+
entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
|
|
1089
|
+
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
|
|
1090
|
+
body_entry = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
|
|
1091
|
+
_type, exit_scope = sub_eval(node.statements, body_entry)
|
|
1092
|
+
names.to_h { |name| [name, exit_scope.local(name)] }
|
|
832
1093
|
end
|
|
833
1094
|
|
|
834
1095
|
# `for index in collection; body; end`. Unlike `each {}` blocks,
|
|
@@ -1058,8 +1319,18 @@ module Rigor
|
|
|
1058
1319
|
# like a singleton-side call. Observed surfacing 915 false
|
|
1059
1320
|
# positives in `prism-1.9.0`'s auto-generated `copy`
|
|
1060
1321
|
# methods alone.
|
|
1061
|
-
|
|
1062
|
-
|
|
1322
|
+
# A nested `def` is a return barrier: its body's `return`s belong to
|
|
1323
|
+
# the inner method, not the one currently being inferred. Suspend the
|
|
1324
|
+
# return sink across the nested body so `eval_return` does not record
|
|
1325
|
+
# them into the outer method's return type.
|
|
1326
|
+
outer_sink = Thread.current[RETURN_SINK_KEY]
|
|
1327
|
+
Thread.current[RETURN_SINK_KEY] = nil
|
|
1328
|
+
begin
|
|
1329
|
+
sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
|
|
1330
|
+
sub_eval(node.body, body_scope, class_context: @class_context) if node.body
|
|
1331
|
+
ensure
|
|
1332
|
+
Thread.current[RETURN_SINK_KEY] = outer_sink
|
|
1333
|
+
end
|
|
1063
1334
|
[Type::Combinator.constant_of(node.name), scope]
|
|
1064
1335
|
end
|
|
1065
1336
|
|
|
@@ -1081,6 +1352,11 @@ module Rigor
|
|
|
1081
1352
|
# observe the outer scope, matching Ruby evaluation order.
|
|
1082
1353
|
def eval_call(node)
|
|
1083
1354
|
call_type = scope.type_of(node, tracer: tracer)
|
|
1355
|
+
# ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }`
|
|
1356
|
+
# returns the memo; the engine otherwise types the call `Dynamic[top]`.
|
|
1357
|
+
# Compute the joined memo type from the block's content mutations of
|
|
1358
|
+
# the memo block-param and adopt it as the call's return type.
|
|
1359
|
+
call_type = each_with_object_return(node, call_type)
|
|
1084
1360
|
evaluate_block_if_present(node)
|
|
1085
1361
|
# `ruby2_keywords def foo(...)` (and similar wrappers like
|
|
1086
1362
|
# `private def`, `public def`, `module_function def`) parse
|
|
@@ -1098,6 +1374,14 @@ module Rigor
|
|
|
1098
1374
|
# `self_type` for the def's body.
|
|
1099
1375
|
evaluate_def_arguments(node)
|
|
1100
1376
|
post_scope = record_closure_escape_if_any(node)
|
|
1377
|
+
# ADR-56 slice A — non-escaping block captured-local write-back.
|
|
1378
|
+
# A `:non_escaping` block (each / times / upto / map …) that
|
|
1379
|
+
# rebinds an outer local must not leave that local's pre-call
|
|
1380
|
+
# binding unmodified in the continuation scope; the spec MUST in
|
|
1381
|
+
# § "Fact stability and mutation" names captured locals a
|
|
1382
|
+
# first-class invalidation category. (The escaping / unknown path
|
|
1383
|
+
# already widened to Dynamic[top] via `record_closure_escape_if_any`.)
|
|
1384
|
+
post_scope = write_back_block_captures(node, post_scope)
|
|
1101
1385
|
post_scope = apply_rbs_extended_assertions(node, post_scope)
|
|
1102
1386
|
post_scope = apply_plugin_assertions(node, post_scope)
|
|
1103
1387
|
post_scope = apply_rspec_matcher_narrowing(node, post_scope)
|
|
@@ -1108,6 +1392,18 @@ module Rigor
|
|
|
1108
1392
|
# justification when the value is mutated. Always-safe
|
|
1109
1393
|
# (loses precision, never invents facts).
|
|
1110
1394
|
post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
|
|
1395
|
+
# ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a
|
|
1396
|
+
# self-call resolves to a user method that CONTENT-mutates one of its
|
|
1397
|
+
# parameters inside an escaping block (the `build_option_parser(opts)`
|
|
1398
|
+
# idiom — the callee returns an `OptionParser` whose
|
|
1399
|
+
# `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
|
|
1400
|
+
# the matching caller-argument local. The callee's escape is invisible
|
|
1401
|
+
# across the boundary, so without this the caller's `options` keeps its
|
|
1402
|
+
# seed and `options.fetch(:mode)` folds to a wrong constant. Precise:
|
|
1403
|
+
# fires only when the resolved callee actually escape-mutates that
|
|
1404
|
+
# parameter (not for every self-call), and sound — only loses
|
|
1405
|
+
# precision on the floored argument.
|
|
1406
|
+
post_scope = widen_callee_escaped_argument_captures(node, post_scope)
|
|
1111
1407
|
# And the same widening for outer-scope locals / ivars
|
|
1112
1408
|
# mutated inside the block body (`items.each { |x| arr << x }`):
|
|
1113
1409
|
# the block lives in a child scope so without an explicit
|
|
@@ -1116,6 +1412,14 @@ module Rigor
|
|
|
1116
1412
|
# precision — so blindly applying is safe regardless of
|
|
1117
1413
|
# whether the block actually runs.
|
|
1118
1414
|
post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
|
|
1415
|
+
# ADR-56 slice C — receiver-content element-type join. The widening
|
|
1416
|
+
# above forgets a content-mutated collection's literal arity but
|
|
1417
|
+
# keeps only the seed's element types (the B1 unsound under-
|
|
1418
|
+
# approximation for a non-empty seed). Join the appended / stored
|
|
1419
|
+
# element / key / value types into the continuation collection's
|
|
1420
|
+
# parameter so `out = [0]; arr.each { |x| out << x }` types
|
|
1421
|
+
# `Array[0 | Integer]`, not `Array[0]`. Always sound — only widens.
|
|
1422
|
+
post_scope = content_writeback_block_captures(node, post_scope)
|
|
1119
1423
|
# Indexed-collection narrowing — drop any
|
|
1120
1424
|
# `receiver[key] ||= default` narrowing the analyzer
|
|
1121
1425
|
# recorded earlier when an intervening `[]=` writes the
|
|
@@ -1142,9 +1446,47 @@ module Rigor
|
|
|
1142
1446
|
# new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
|
|
1143
1447
|
# § "Flow-folding" — G2 intervening-call case.
|
|
1144
1448
|
post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
|
|
1449
|
+
# C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are
|
|
1450
|
+
# narrowed to non-nil on a successful-match edge; a later call
|
|
1451
|
+
# that itself runs a regex match rebinds them, so the narrowed
|
|
1452
|
+
# facts must be dropped. We forget them only when the call is
|
|
1453
|
+
# match-CAPABLE (a regex-matching method, or an implicit-self /
|
|
1454
|
+
# unknown-receiver call whose body we cannot prove match-free).
|
|
1455
|
+
# A call provably match-free on a known receiver — `$3.to_i`,
|
|
1456
|
+
# `year < 50` — does NOT clobber, so the multi-statement
|
|
1457
|
+
# `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
|
|
1458
|
+
# while a genuinely interposed match still invalidates.
|
|
1459
|
+
post_scope = post_scope.forget_match_globals if match_capable_call?(node)
|
|
1145
1460
|
[call_type, post_scope]
|
|
1146
1461
|
end
|
|
1147
1462
|
|
|
1463
|
+
# Method names that (may) run a regex match and therefore rebind
|
|
1464
|
+
# the `$~` family. Conservative over-approximation — a few set
|
|
1465
|
+
# globals only with a Regexp argument, but we do not inspect args.
|
|
1466
|
+
MATCH_CAPABLE_METHODS = %i[
|
|
1467
|
+
=~ match match? gsub gsub! sub sub! scan split slice slice!
|
|
1468
|
+
[] partition rpartition index rindex === grep grep_v
|
|
1469
|
+
].freeze
|
|
1470
|
+
private_constant :MATCH_CAPABLE_METHODS
|
|
1471
|
+
|
|
1472
|
+
# True when `node` could rebind the regex match-data globals:
|
|
1473
|
+
# a known regex-matching method by name, or an implicit-self /
|
|
1474
|
+
# self-receiver call whose body we cannot inspect for an internal
|
|
1475
|
+
# match. An explicit-receiver call to a non-matching method
|
|
1476
|
+
# (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so
|
|
1477
|
+
# the multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the
|
|
1478
|
+
# narrowed globals. The over-approximation is one-directional: a
|
|
1479
|
+
# user method that secretly matches on an explicit receiver is the
|
|
1480
|
+
# only escape, and re-narrowing on the next real guard recovers —
|
|
1481
|
+
# weighed against the false-positive cost, precision wins here.
|
|
1482
|
+
def match_capable_call?(node)
|
|
1483
|
+
return true unless node.is_a?(Prism::CallNode)
|
|
1484
|
+
return true if MATCH_CAPABLE_METHODS.include?(node.name)
|
|
1485
|
+
|
|
1486
|
+
receiver = node.receiver
|
|
1487
|
+
receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
1488
|
+
end
|
|
1489
|
+
|
|
1148
1490
|
# Returns a scope with each ivar's narrowed local binding
|
|
1149
1491
|
# widened back to its class-ivar seed value when the call
|
|
1150
1492
|
# is one that could plausibly mutate ivars on the enclosing
|
|
@@ -1421,10 +1763,9 @@ module Rigor
|
|
|
1421
1763
|
end
|
|
1422
1764
|
end
|
|
1423
1765
|
|
|
1424
|
-
# ADR-37 slice 2 — gathers each plugin's post-return
|
|
1425
|
-
#
|
|
1426
|
-
# facts-only `FlowContribution
|
|
1427
|
-
# `flow_contribution_for` escape valve, swallowing per-plugin
|
|
1766
|
+
# ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return
|
|
1767
|
+
# narrowing from the method-gated `type_specifier` DSL, wrapped as
|
|
1768
|
+
# a facts-only `FlowContribution`, swallowing per-plugin
|
|
1428
1769
|
# exceptions so a buggy plugin can't abort the assertion path.
|
|
1429
1770
|
EMPTY_CONTRIBUTIONS = [].freeze
|
|
1430
1771
|
private_constant :EMPTY_CONTRIBUTIONS
|
|
@@ -1432,8 +1773,11 @@ module Rigor
|
|
|
1432
1773
|
# Per-dispatch collection of plugin narrowing contributions. Mirrors
|
|
1433
1774
|
# `MethodDispatcher#collect_plugin_contributions`: visit only the
|
|
1434
1775
|
# registry-ordered subset of plugins that implement a per-call path
|
|
1435
|
-
# (`for_statement` =
|
|
1436
|
-
#
|
|
1776
|
+
# (`for_statement` = declares a `type_specifier`), gate each path
|
|
1777
|
+
# by membership AND by the ADR-52 WD1 method-name gates (every
|
|
1778
|
+
# `type_specifier` rule is `methods:`-gated, so the common
|
|
1779
|
+
# no-candidate case is a single Set probe; a pruned
|
|
1780
|
+
# consultation could only have returned `[]`), and accumulate
|
|
1437
1781
|
# lazily (shared frozen empty array otherwise). Same contributions in
|
|
1438
1782
|
# the same order as visiting every plugin; the caller is read-only.
|
|
1439
1783
|
def collect_plugin_contributions(registry, call_node, current_scope)
|
|
@@ -1441,16 +1785,21 @@ module Rigor
|
|
|
1441
1785
|
relevant = index.for_statement
|
|
1442
1786
|
return EMPTY_CONTRIBUTIONS if relevant.empty?
|
|
1443
1787
|
|
|
1788
|
+
name = call_node.respond_to?(:name) ? call_node.name : nil
|
|
1789
|
+
return EMPTY_CONTRIBUTIONS unless index.statement_candidate?(name)
|
|
1790
|
+
|
|
1791
|
+
collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
|
|
1792
|
+
end
|
|
1793
|
+
|
|
1794
|
+
# The post-gate walk, in registry order — the same order the
|
|
1795
|
+
# ungated walk used.
|
|
1796
|
+
def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
|
|
1444
1797
|
result = nil
|
|
1445
1798
|
relevant.each do |plugin|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
if index.type_specifier?(plugin)
|
|
1451
|
-
facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
|
|
1452
|
-
(result ||= []) << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
|
|
1453
|
-
end
|
|
1799
|
+
next unless index.type_specifier_candidate_for?(plugin, name)
|
|
1800
|
+
|
|
1801
|
+
facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
|
|
1802
|
+
(result ||= []) << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
|
|
1454
1803
|
rescue StandardError
|
|
1455
1804
|
next
|
|
1456
1805
|
end
|
|
@@ -1611,12 +1960,25 @@ module Rigor
|
|
|
1611
1960
|
# A `:non_escaping` classification (or any block-less call)
|
|
1612
1961
|
# leaves the post-call scope unchanged.
|
|
1613
1962
|
def record_closure_escape_if_any(node)
|
|
1614
|
-
|
|
1963
|
+
# ADR-57 slice 3 work-item 1: an escaping block can also be attached
|
|
1964
|
+
# to a RECEIVER call in a chain rather than to `node` itself — the
|
|
1965
|
+
# canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end
|
|
1966
|
+
# .parse!(argv)` idiom, where the content-mutating block hangs off
|
|
1967
|
+
# `OptionParser.new` but the statement-level call node is the chained
|
|
1968
|
+
# `.parse!`. A receiver call is evaluated as an expression, never as a
|
|
1969
|
+
# statement, so its block never reaches this escape handler on its own.
|
|
1970
|
+
# Fold each escaping receiver-chain block's content widening into the
|
|
1971
|
+
# continuation here so the captured collection is floored regardless of
|
|
1972
|
+
# how deep in the receiver chain its mutating block lives.
|
|
1973
|
+
post_scope = widen_escaping_receiver_chain_captures(node, scope)
|
|
1974
|
+
|
|
1975
|
+
return post_scope unless node.block.is_a?(Prism::BlockNode)
|
|
1615
1976
|
|
|
1616
1977
|
classification = classify_closure_escape(node)
|
|
1617
|
-
return
|
|
1978
|
+
return post_scope if classification == :non_escaping
|
|
1618
1979
|
|
|
1619
|
-
post_scope = drop_captured_narrowing(node.block,
|
|
1980
|
+
post_scope = drop_captured_narrowing(node.block, post_scope)
|
|
1981
|
+
post_scope = widen_escaping_content_captures(node.block, post_scope)
|
|
1620
1982
|
post_scope.with_fact(
|
|
1621
1983
|
Analysis::FactStore::Fact.new(
|
|
1622
1984
|
bucket: :dynamic_origin,
|
|
@@ -1628,6 +1990,224 @@ module Rigor
|
|
|
1628
1990
|
)
|
|
1629
1991
|
end
|
|
1630
1992
|
|
|
1993
|
+
# Floor each caller-argument local whose matching parameter the resolved
|
|
1994
|
+
# callee escape-mutates (see the call-site comment). Only self-dispatch
|
|
1995
|
+
# calls resolving to a discovered user def are considered; the per-def
|
|
1996
|
+
# "which parameters escape-mutate" set is memoised on the def node.
|
|
1997
|
+
def widen_callee_escaped_argument_captures(node, base_scope)
|
|
1998
|
+
# Apply to the statement call AND every call in its receiver chain: the
|
|
1999
|
+
# `build_option_parser(options).parse!(argv)` idiom puts the escape-
|
|
2000
|
+
# mutating helper call in the RECEIVER position, where its argument is
|
|
2001
|
+
# never the statement node's own argument.
|
|
2002
|
+
acc = floor_callee_escaped_args_for_call(node, base_scope)
|
|
2003
|
+
receiver = node.receiver
|
|
2004
|
+
while receiver.is_a?(Prism::CallNode)
|
|
2005
|
+
acc = floor_callee_escaped_args_for_call(receiver, acc)
|
|
2006
|
+
receiver = receiver.receiver
|
|
2007
|
+
end
|
|
2008
|
+
acc
|
|
2009
|
+
end
|
|
2010
|
+
|
|
2011
|
+
def floor_callee_escaped_args_for_call(node, base_scope)
|
|
2012
|
+
return base_scope unless self_dispatch_call?(node)
|
|
2013
|
+
# Fast path — the floor only ever touches a local passed as an
|
|
2014
|
+
# argument, so a call with no arguments cannot floor anything. Skip the
|
|
2015
|
+
# def resolution + body scan entirely (the overwhelming common case).
|
|
2016
|
+
return base_scope unless call_passes_local_argument?(node)
|
|
2017
|
+
|
|
2018
|
+
def_node = resolve_self_callee_def(node)
|
|
2019
|
+
return base_scope if def_node.nil?
|
|
2020
|
+
|
|
2021
|
+
escaped = escaped_content_parameters(def_node)
|
|
2022
|
+
return base_scope if escaped.empty?
|
|
2023
|
+
|
|
2024
|
+
floor_arguments_at_positions(node, escaped, base_scope)
|
|
2025
|
+
end
|
|
2026
|
+
|
|
2027
|
+
# The user def a self-dispatch `node` resolves to in the enclosing class,
|
|
2028
|
+
# or nil. Reuses the discovery index `Scope#user_def_for` reads; no
|
|
2029
|
+
# ancestor walk (the boundary-escape idiom is same-class), keeping this
|
|
2030
|
+
# off the hot path for the overwhelming majority of self-calls that
|
|
2031
|
+
# resolve to nothing escaping.
|
|
2032
|
+
def resolve_self_callee_def(node)
|
|
2033
|
+
class_name = enclosing_class_name_for(scope.self_type)
|
|
2034
|
+
return scope.top_level_def_for(node.name) if class_name.nil?
|
|
2035
|
+
|
|
2036
|
+
scope.user_def_for(class_name, node.name)
|
|
2037
|
+
end
|
|
2038
|
+
|
|
2039
|
+
def self_dispatch_call?(node)
|
|
2040
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
2041
|
+
|
|
2042
|
+
node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
2043
|
+
end
|
|
2044
|
+
|
|
2045
|
+
# The set of `[name, position]` parameters of `def_node` whose content a
|
|
2046
|
+
# block in the body escape-mutates. Memoised per def node (the body walk
|
|
2047
|
+
# is otherwise repeated at every call site). A parameter is "escape-
|
|
2048
|
+
# mutated" when a `param[k] = v` / `param << x` mutation on it appears
|
|
2049
|
+
# inside a block whose receiving call is not proven non-escaping.
|
|
2050
|
+
def escaped_content_parameters(def_node)
|
|
2051
|
+
cache = (@escaped_param_cache ||= {}.compare_by_identity)
|
|
2052
|
+
cache[def_node] ||= compute_escaped_content_parameters(def_node)
|
|
2053
|
+
end
|
|
2054
|
+
|
|
2055
|
+
def compute_escaped_content_parameters(def_node)
|
|
2056
|
+
positions = positional_parameter_positions(def_node)
|
|
2057
|
+
return {} if positions.empty?
|
|
2058
|
+
|
|
2059
|
+
mutated = Set.new
|
|
2060
|
+
Source::NodeWalker.each(def_node.body) do |descendant|
|
|
2061
|
+
next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
|
|
2062
|
+
next if syntactically_non_escaping_call?(descendant)
|
|
2063
|
+
|
|
2064
|
+
collect_content_mutations(descendant.block.body).each_key do |name|
|
|
2065
|
+
mutated << name if positions.key?(name)
|
|
2066
|
+
end
|
|
2067
|
+
end
|
|
2068
|
+
positions.slice(*mutated)
|
|
2069
|
+
end
|
|
2070
|
+
|
|
2071
|
+
# A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s
|
|
2072
|
+
# non-escaping verdict, used when scanning a callee body where the block-
|
|
2073
|
+
# owning call's receiver TYPE is not available. A call whose method name
|
|
2074
|
+
# is a known structural iterator (`each` / `map` / `tap` / …) runs its
|
|
2075
|
+
# block synchronously and does not retain it, so its captured mutations
|
|
2076
|
+
# are not a cross-boundary escape. Any other name (`on`, `subscribe`,
|
|
2077
|
+
# `define_method`, an unknown DSL hook) is treated as escaping — sound,
|
|
2078
|
+
# since mis-classifying a truly-non-escaping call only floors an argument
|
|
2079
|
+
# that was about to be precise.
|
|
2080
|
+
SYNTACTIC_NON_ESCAPING_BLOCK_METHODS = (
|
|
2081
|
+
ClosureEscapeAnalyzer::ENUMERABLE_NON_ESCAPING +
|
|
2082
|
+
ClosureEscapeAnalyzer::OBJECT_NON_ESCAPING +
|
|
2083
|
+
ClosureEscapeAnalyzer::ARRAY_EXTRA +
|
|
2084
|
+
ClosureEscapeAnalyzer::HASH_EXTRA +
|
|
2085
|
+
ClosureEscapeAnalyzer::RANGE_EXTRA +
|
|
2086
|
+
ClosureEscapeAnalyzer::INTEGER_EXTRA
|
|
2087
|
+
).to_set.freeze
|
|
2088
|
+
private_constant :SYNTACTIC_NON_ESCAPING_BLOCK_METHODS
|
|
2089
|
+
|
|
2090
|
+
def syntactically_non_escaping_call?(call_node)
|
|
2091
|
+
SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
|
|
2092
|
+
end
|
|
2093
|
+
|
|
2094
|
+
# `{ name => position }` for the required / optional positional
|
|
2095
|
+
# parameters of a def. Keyword / rest / block parameters are skipped —
|
|
2096
|
+
# the boundary-escape idiom passes a plain positional collection.
|
|
2097
|
+
def positional_parameter_positions(def_node)
|
|
2098
|
+
params = def_node.parameters
|
|
2099
|
+
return {} if params.nil?
|
|
2100
|
+
|
|
2101
|
+
ordered = (params.requireds || []) + (params.optionals || [])
|
|
2102
|
+
positions = {}
|
|
2103
|
+
ordered.each_with_index do |param, index|
|
|
2104
|
+
positions[param.name] = index if param.respond_to?(:name)
|
|
2105
|
+
end
|
|
2106
|
+
positions
|
|
2107
|
+
end
|
|
2108
|
+
|
|
2109
|
+
# True when at least one argument of `node` is a bare local-variable read
|
|
2110
|
+
# (positional or keyword value) bound in the current scope — a cheap
|
|
2111
|
+
# pre-filter so the def resolution / body scan only runs for calls that
|
|
2112
|
+
# could actually floor something.
|
|
2113
|
+
def call_passes_local_argument?(node)
|
|
2114
|
+
args = node.arguments
|
|
2115
|
+
return false unless args.respond_to?(:arguments)
|
|
2116
|
+
|
|
2117
|
+
args.arguments.any? do |arg|
|
|
2118
|
+
case arg
|
|
2119
|
+
when Prism::LocalVariableReadNode
|
|
2120
|
+
scope.locals.key?(arg.name)
|
|
2121
|
+
when Prism::KeywordHashNode
|
|
2122
|
+
arg.elements.any? do |pair|
|
|
2123
|
+
pair.is_a?(Prism::AssocNode) &&
|
|
2124
|
+
pair.value.is_a?(Prism::LocalVariableReadNode) &&
|
|
2125
|
+
scope.locals.key?(pair.value.name)
|
|
2126
|
+
end
|
|
2127
|
+
else
|
|
2128
|
+
false
|
|
2129
|
+
end
|
|
2130
|
+
end
|
|
2131
|
+
end
|
|
2132
|
+
|
|
2133
|
+
def floor_arguments_at_positions(node, positions, base_scope)
|
|
2134
|
+
args = node.arguments
|
|
2135
|
+
return base_scope unless args.respond_to?(:arguments)
|
|
2136
|
+
|
|
2137
|
+
argument_nodes = args.arguments
|
|
2138
|
+
positions.values.uniq.reduce(base_scope) do |acc, index|
|
|
2139
|
+
arg = argument_nodes[index]
|
|
2140
|
+
next acc unless arg.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(arg.name)
|
|
2141
|
+
|
|
2142
|
+
floored = content_floor_for(acc.local(arg.name))
|
|
2143
|
+
floored.nil? ? acc : acc.with_local(arg.name, floored)
|
|
2144
|
+
end
|
|
2145
|
+
end
|
|
2146
|
+
|
|
2147
|
+
# Walk the receiver chain of `node` and fold the escaping-content
|
|
2148
|
+
# widening of every block-bearing, escaping receiver call into
|
|
2149
|
+
# `base_scope`. Only receiver calls are walked — `node` itself is handled
|
|
2150
|
+
# by the caller. A `:non_escaping` receiver block is left to slice C's
|
|
2151
|
+
# non-escaping write-back (which the receiver expression evaluation
|
|
2152
|
+
# already drives), so we only floor the escaping / unknown ones here.
|
|
2153
|
+
def widen_escaping_receiver_chain_captures(node, base_scope)
|
|
2154
|
+
receiver = node.receiver
|
|
2155
|
+
acc = base_scope
|
|
2156
|
+
while receiver.is_a?(Prism::CallNode)
|
|
2157
|
+
if receiver.block.is_a?(Prism::BlockNode) &&
|
|
2158
|
+
classify_closure_escape(receiver) != :non_escaping
|
|
2159
|
+
acc = widen_escaping_content_captures(receiver.block, acc)
|
|
2160
|
+
end
|
|
2161
|
+
receiver = receiver.receiver
|
|
2162
|
+
end
|
|
2163
|
+
acc
|
|
2164
|
+
end
|
|
2165
|
+
|
|
2166
|
+
# ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks).
|
|
2167
|
+
# An escaping / unknown block that CONTENT-mutates a captured outer
|
|
2168
|
+
# local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a
|
|
2169
|
+
# stored proc) previously left that local's content untouched — only its
|
|
2170
|
+
# narrowing was dropped, so a constant seed (`options = {}`, `s = ""`)
|
|
2171
|
+
# survived and its element fold (`options[:format]` -> `"text"`,
|
|
2172
|
+
# `s.empty?` -> `true`) was unsoundly precise.
|
|
2173
|
+
#
|
|
2174
|
+
# An escaping block may run later and any number of times, so joining a
|
|
2175
|
+
# bounded evidence set is not sound (unlike slice C's non-escaping
|
|
2176
|
+
# join): the sound continuation is the bare-collection floor — Array ->
|
|
2177
|
+
# `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String ->
|
|
2178
|
+
# `String`. The seed's element/key/value precision is forgotten; only
|
|
2179
|
+
# the carrier survives. Read-only captures and locals the block merely
|
|
2180
|
+
# rebinds (already floored by `drop_captured_narrowing`) are untouched.
|
|
2181
|
+
def widen_escaping_content_captures(block_node, post_scope)
|
|
2182
|
+
body = block_node.body
|
|
2183
|
+
return post_scope if body.nil?
|
|
2184
|
+
|
|
2185
|
+
mutations = collect_content_mutations(body)
|
|
2186
|
+
return post_scope if mutations.empty?
|
|
2187
|
+
|
|
2188
|
+
mutations.keys.reduce(post_scope) do |acc, name|
|
|
2189
|
+
floored = content_floor_for(acc.local(name))
|
|
2190
|
+
floored.nil? ? acc : acc.with_local(name, floored)
|
|
2191
|
+
end
|
|
2192
|
+
end
|
|
2193
|
+
|
|
2194
|
+
# The Dynamic-floor carrier for a content-mutated escaping capture, or
|
|
2195
|
+
# nil when the pre-state is not a recognised mutable collection (leave
|
|
2196
|
+
# it alone — e.g. an already-`Dynamic` binding or an unknown shape).
|
|
2197
|
+
def content_floor_for(type)
|
|
2198
|
+
return nil if type.nil?
|
|
2199
|
+
|
|
2200
|
+
if stringish?(type)
|
|
2201
|
+
Type::Combinator.nominal_of("String")
|
|
2202
|
+
elsif hashish?(type)
|
|
2203
|
+
Type::Combinator.nominal_of("Hash",
|
|
2204
|
+
type_args: [Type::Combinator.untyped,
|
|
2205
|
+
Type::Combinator.untyped])
|
|
2206
|
+
elsif arrayish?(type)
|
|
2207
|
+
Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
|
|
2208
|
+
end
|
|
2209
|
+
end
|
|
2210
|
+
|
|
1631
2211
|
def classify_closure_escape(call_node)
|
|
1632
2212
|
receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
|
|
1633
2213
|
ClosureEscapeAnalyzer.classify(
|
|
@@ -1654,6 +2234,22 @@ module Rigor
|
|
|
1654
2234
|
names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
|
|
1655
2235
|
end
|
|
1656
2236
|
|
|
2237
|
+
# Names of outer locals the block body can REBIND, across every
|
|
2238
|
+
# local-write form: plain `=` (`LocalVariableWriteNode`), the
|
|
2239
|
+
# operator / `||=` / `&&=` compound forms, and a multi-assign target
|
|
2240
|
+
# (`x, y = ...` → `LocalVariableTargetNode` under `MultiWriteNode`).
|
|
2241
|
+
# Block-introduced names (parameters, numbered params, `;`-locals) and
|
|
2242
|
+
# names not bound in the outer scope are excluded — a write to either
|
|
2243
|
+
# is not a captured rebind of an outer variable.
|
|
2244
|
+
LOCAL_WRITE_NODES = [
|
|
2245
|
+
Prism::LocalVariableWriteNode,
|
|
2246
|
+
Prism::LocalVariableOperatorWriteNode,
|
|
2247
|
+
Prism::LocalVariableOrWriteNode,
|
|
2248
|
+
Prism::LocalVariableAndWriteNode,
|
|
2249
|
+
Prism::LocalVariableTargetNode
|
|
2250
|
+
].freeze
|
|
2251
|
+
private_constant :LOCAL_WRITE_NODES
|
|
2252
|
+
|
|
1657
2253
|
def captured_local_writes(block_node, base_scope)
|
|
1658
2254
|
body = block_node.body
|
|
1659
2255
|
return [] if body.nil?
|
|
@@ -1661,7 +2257,7 @@ module Rigor
|
|
|
1661
2257
|
introduced = block_introduced_locals(block_node)
|
|
1662
2258
|
outer_writes = []
|
|
1663
2259
|
Source::NodeWalker.each(body) do |descendant|
|
|
1664
|
-
next unless descendant.is_a?(
|
|
2260
|
+
next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
|
|
1665
2261
|
next if introduced.include?(descendant.name)
|
|
1666
2262
|
next unless base_scope.locals.key?(descendant.name)
|
|
1667
2263
|
|
|
@@ -1670,6 +2266,313 @@ module Rigor
|
|
|
1670
2266
|
outer_writes.uniq
|
|
1671
2267
|
end
|
|
1672
2268
|
|
|
2269
|
+
# ADR-56 slice A. For a `:non_escaping` block, fold the continuation
|
|
2270
|
+
# binding of every outer local the body can rebind back into
|
|
2271
|
+
# `post_scope`. The binding is a capped fixpoint (cap 3) over the
|
|
2272
|
+
# block body re-evaluated under the running per-name assumption,
|
|
2273
|
+
# joined with the pre-call binding (kept as a constituent so the
|
|
2274
|
+
# 0-iteration path — `[].each { … }` — stays sound), value-pinned-
|
|
2275
|
+
# widened on the final permitted iteration, and floored to
|
|
2276
|
+
# `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
|
|
2277
|
+
#
|
|
2278
|
+
# Fast path: a block writing no outer local leaves `post_scope`
|
|
2279
|
+
# byte-identical (the overwhelming majority of blocks), so this costs
|
|
2280
|
+
# one extra `captured_local_writes` walk and nothing else.
|
|
2281
|
+
def write_back_block_captures(call_node, post_scope)
|
|
2282
|
+
block = call_node.block
|
|
2283
|
+
return post_scope unless block.is_a?(Prism::BlockNode)
|
|
2284
|
+
return post_scope unless classify_closure_escape(call_node) == :non_escaping
|
|
2285
|
+
|
|
2286
|
+
names = captured_local_writes(block, scope)
|
|
2287
|
+
return post_scope if names.empty?
|
|
2288
|
+
|
|
2289
|
+
seed = names.to_h { |name| [name, scope.local(name)] }
|
|
2290
|
+
result = BodyFixpoint.converge(
|
|
2291
|
+
names: names,
|
|
2292
|
+
seed_bindings: seed,
|
|
2293
|
+
widen: Type::Combinator.method(:widen_value_pinned),
|
|
2294
|
+
evaluate_body: ->(bindings) { block_exit_bindings(call_node, block, bindings, names) }
|
|
2295
|
+
)
|
|
2296
|
+
|
|
2297
|
+
result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
2298
|
+
end
|
|
2299
|
+
|
|
2300
|
+
# ADR-56 slice C — receiver-content element-type join. After the
|
|
2301
|
+
# rebind write-back and `MutationWidening.widen_after_block` (which
|
|
2302
|
+
# forgets a content-mutated collection's literal arity but keeps only
|
|
2303
|
+
# the SEED's element types), join the appended/stored element / key /
|
|
2304
|
+
# value types INTO the continuation collection's parameter, so
|
|
2305
|
+
# `out = [0]; arr.each { |x| out << x }` types `out` as
|
|
2306
|
+
# `Array[0 | Integer]` (sound) rather than `Array[0]` (the B1
|
|
2307
|
+
# under-approximation: the runtime array is `[0, 1, 2, 3]`).
|
|
2308
|
+
#
|
|
2309
|
+
# Pre-state is read from `post_scope` so a local that is BOTH rebound
|
|
2310
|
+
# and content-mutated composes: the rebind fixpoint result feeds the
|
|
2311
|
+
# content join. The block body is typed once for argument evidence;
|
|
2312
|
+
# the floor is `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the
|
|
2313
|
+
# sound empty-seed behaviour). Always sound — only ever widens.
|
|
2314
|
+
def content_writeback_block_captures(call_node, post_scope)
|
|
2315
|
+
block = call_node.block
|
|
2316
|
+
return post_scope unless block.is_a?(Prism::BlockNode)
|
|
2317
|
+
return post_scope unless classify_closure_escape(call_node) == :non_escaping
|
|
2318
|
+
|
|
2319
|
+
body = block.body
|
|
2320
|
+
return post_scope if body.nil?
|
|
2321
|
+
|
|
2322
|
+
mutations = collect_content_mutations(body)
|
|
2323
|
+
return post_scope if mutations.empty?
|
|
2324
|
+
|
|
2325
|
+
entry = build_block_entry_scope(call_node, block)
|
|
2326
|
+
mutations.reduce(post_scope) do |acc, (name, calls)|
|
|
2327
|
+
joined = join_content_for_local(name, calls, acc, entry)
|
|
2328
|
+
joined.nil? ? acc : acc.with_local(name, joined)
|
|
2329
|
+
end
|
|
2330
|
+
end
|
|
2331
|
+
|
|
2332
|
+
# ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }`
|
|
2333
|
+
# the return is the memo object after the block has mutated it through
|
|
2334
|
+
# the `acc` alias. Compute the joined memo type the same way captured-
|
|
2335
|
+
# local content mutations are joined: pre-state = the memo argument's
|
|
2336
|
+
# type, added evidence = the content-mutator args on the memo block
|
|
2337
|
+
# param. Returns `call_type` unchanged for any other call, a missing
|
|
2338
|
+
# block, or a memo whose pre-state is not a collection.
|
|
2339
|
+
def each_with_object_return(call_node, call_type)
|
|
2340
|
+
return call_type unless call_node.name == :each_with_object
|
|
2341
|
+
|
|
2342
|
+
block = call_node.block
|
|
2343
|
+
return call_type unless block.is_a?(Prism::BlockNode)
|
|
2344
|
+
|
|
2345
|
+
memo_arg = call_node.arguments&.arguments&.first
|
|
2346
|
+
return call_type if memo_arg.nil?
|
|
2347
|
+
|
|
2348
|
+
memo_param = each_with_object_memo_param(block)
|
|
2349
|
+
return call_type if memo_param.nil?
|
|
2350
|
+
|
|
2351
|
+
body = block.body
|
|
2352
|
+
return call_type if body.nil?
|
|
2353
|
+
|
|
2354
|
+
# The memo alias is a block-local (depth 0) — collect content
|
|
2355
|
+
# mutations on it directly rather than via the captured-local walk.
|
|
2356
|
+
calls = body_content_mutations_on(body, memo_param)
|
|
2357
|
+
return call_type if calls.empty?
|
|
2358
|
+
|
|
2359
|
+
pre_state = scope.type_of(memo_arg, tracer: tracer)
|
|
2360
|
+
entry = build_block_entry_scope(call_node, block)
|
|
2361
|
+
joined = join_content_for_param(calls, pre_state, entry)
|
|
2362
|
+
joined || call_type
|
|
2363
|
+
end
|
|
2364
|
+
|
|
2365
|
+
# The name of the memo block parameter (the SECOND positional param of
|
|
2366
|
+
# an `each_with_object` block), or nil when the block does not bind a
|
|
2367
|
+
# second positional param.
|
|
2368
|
+
def each_with_object_memo_param(block)
|
|
2369
|
+
params_root = block.parameters
|
|
2370
|
+
return nil unless params_root.is_a?(Prism::BlockParametersNode)
|
|
2371
|
+
|
|
2372
|
+
params = params_root.parameters
|
|
2373
|
+
return nil if params.nil?
|
|
2374
|
+
|
|
2375
|
+
requireds = params.requireds
|
|
2376
|
+
return nil if requireds.size < 2
|
|
2377
|
+
|
|
2378
|
+
second = requireds[1]
|
|
2379
|
+
second.respond_to?(:name) ? second.name : nil
|
|
2380
|
+
end
|
|
2381
|
+
|
|
2382
|
+
# Content-mutator calls on a block-local receiver `var_name`
|
|
2383
|
+
# (depth 0) within `body`.
|
|
2384
|
+
def body_content_mutations_on(body, var_name)
|
|
2385
|
+
calls = []
|
|
2386
|
+
Source::NodeWalker.each(body) do |descendant|
|
|
2387
|
+
next unless descendant.is_a?(Prism::CallNode)
|
|
2388
|
+
next unless MutationWidening::CONTENT_ADDERS.include?(descendant.name)
|
|
2389
|
+
|
|
2390
|
+
receiver = descendant.receiver
|
|
2391
|
+
next unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
2392
|
+
next unless receiver.name == var_name
|
|
2393
|
+
|
|
2394
|
+
calls << descendant
|
|
2395
|
+
end
|
|
2396
|
+
calls
|
|
2397
|
+
end
|
|
2398
|
+
|
|
2399
|
+
# Joins content evidence for a memo / param given its pre-state and a
|
|
2400
|
+
# list of mutator calls, dispatching Array vs Hash by the mutator set.
|
|
2401
|
+
def join_content_for_param(calls, pre_state, block_entry)
|
|
2402
|
+
return nil if pre_state.nil?
|
|
2403
|
+
|
|
2404
|
+
if stringish?(pre_state)
|
|
2405
|
+
# String carries no element parameter; mutating `<<`/`concat`
|
|
2406
|
+
# makes the constant value unsound (`s = "a"; s << x` → runtime
|
|
2407
|
+
# `"a…"`), so widen to the nominal base. Sound — only widens.
|
|
2408
|
+
Type::Combinator.nominal_of("String")
|
|
2409
|
+
elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
|
|
2410
|
+
join_hash_param(calls, pre_state, block_entry)
|
|
2411
|
+
else
|
|
2412
|
+
join_array_param(calls, pre_state, block_entry)
|
|
2413
|
+
end
|
|
2414
|
+
end
|
|
2415
|
+
|
|
2416
|
+
def join_hash_param(calls, pre_state, block_entry)
|
|
2417
|
+
pairs = calls.flat_map { |c| hash_pair_types(c, block_entry) }
|
|
2418
|
+
return nil if pairs.empty? && !hashish?(pre_state)
|
|
2419
|
+
|
|
2420
|
+
MutationWidening.join_hash_content(pre_state, pairs)
|
|
2421
|
+
end
|
|
2422
|
+
|
|
2423
|
+
def join_array_param(calls, pre_state, block_entry)
|
|
2424
|
+
return nil unless arrayish?(pre_state)
|
|
2425
|
+
|
|
2426
|
+
added = calls.flat_map do |c|
|
|
2427
|
+
# Index-write on an array (`a[i] += v`) introduces no new element
|
|
2428
|
+
# evidence we can cheaply attribute — the array-arity forget
|
|
2429
|
+
# already widened the binding; contribute nothing.
|
|
2430
|
+
next [] if index_write?(c)
|
|
2431
|
+
|
|
2432
|
+
MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
|
|
2433
|
+
end
|
|
2434
|
+
MutationWidening.join_array_content(pre_state, added)
|
|
2435
|
+
end
|
|
2436
|
+
|
|
2437
|
+
# Walks the block body for content-mutator calls (`<<`, `push`,
|
|
2438
|
+
# `[]=`, …) whose receiver is a captured outer local (depth >= 1),
|
|
2439
|
+
# returning `{ name => [call_node, ...] }`. Mirrors the
|
|
2440
|
+
# `MutationWidening.widen_after_block` walk (descends into nested
|
|
2441
|
+
# blocks; the depth check keeps nested block-locals out).
|
|
2442
|
+
def collect_content_mutations(body)
|
|
2443
|
+
mutations = Hash.new { |h, k| h[k] = [] }
|
|
2444
|
+
Source::NodeWalker.each(body) do |descendant|
|
|
2445
|
+
name, node = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.positive? }
|
|
2446
|
+
mutations[name] << node unless name.nil?
|
|
2447
|
+
end
|
|
2448
|
+
mutations
|
|
2449
|
+
end
|
|
2450
|
+
|
|
2451
|
+
# Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a
|
|
2452
|
+
# multi-assign target) that mutate a collection's CONTENT without a
|
|
2453
|
+
# `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the
|
|
2454
|
+
# OrWrite even though the appended values land on the nested array —
|
|
2455
|
+
# leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
|
|
2456
|
+
INDEX_WRITE_NODES = [
|
|
2457
|
+
Prism::IndexOrWriteNode,
|
|
2458
|
+
Prism::IndexAndWriteNode,
|
|
2459
|
+
Prism::IndexOperatorWriteNode,
|
|
2460
|
+
Prism::IndexTargetNode
|
|
2461
|
+
].freeze
|
|
2462
|
+
private_constant :INDEX_WRITE_NODES
|
|
2463
|
+
|
|
2464
|
+
# `[receiver_name, node]` when `node` is a content mutation whose
|
|
2465
|
+
# receiver is a local variable satisfying `accept` (depth predicate),
|
|
2466
|
+
# else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the
|
|
2467
|
+
# index-write node forms.
|
|
2468
|
+
def content_mutation_target(node)
|
|
2469
|
+
is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
|
|
2470
|
+
return [nil, nil] unless is_call_mutator || index_write?(node)
|
|
2471
|
+
|
|
2472
|
+
receiver = node.receiver
|
|
2473
|
+
return [nil, nil] unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
2474
|
+
return [nil, nil] unless yield(receiver)
|
|
2475
|
+
|
|
2476
|
+
[receiver.name, node]
|
|
2477
|
+
end
|
|
2478
|
+
|
|
2479
|
+
# Computes the joined continuation collection type for one captured
|
|
2480
|
+
# local from its content-mutator calls. Returns `nil` (no overlay)
|
|
2481
|
+
# when the pre-state is neither an Array-ish nor a Hash-ish binding —
|
|
2482
|
+
# e.g. a String accumulator, whose `<<` carries no element parameter
|
|
2483
|
+
# and whose binding already types as `String`.
|
|
2484
|
+
def join_content_for_local(name, calls, post_scope, block_entry)
|
|
2485
|
+
join_content_for_param(calls, post_scope.local(name), block_entry)
|
|
2486
|
+
end
|
|
2487
|
+
|
|
2488
|
+
def hash_mutations?(calls)
|
|
2489
|
+
calls.any? do |c|
|
|
2490
|
+
index_write?(c) || (c.is_a?(Prism::CallNode) && MutationWidening::HASH_CONTENT_ADDERS.include?(c.name))
|
|
2491
|
+
end
|
|
2492
|
+
end
|
|
2493
|
+
|
|
2494
|
+
def index_write?(node)
|
|
2495
|
+
INDEX_WRITE_NODES.any? { |k| node.is_a?(k) }
|
|
2496
|
+
end
|
|
2497
|
+
|
|
2498
|
+
def arrayish?(type)
|
|
2499
|
+
case type
|
|
2500
|
+
when Type::Tuple then true
|
|
2501
|
+
when Type::Nominal then type.class_name == "Array"
|
|
2502
|
+
when Type::Union then type.members.any? { |m| arrayish?(m) }
|
|
2503
|
+
else false
|
|
2504
|
+
end
|
|
2505
|
+
end
|
|
2506
|
+
|
|
2507
|
+
def hashish?(type)
|
|
2508
|
+
case type
|
|
2509
|
+
when Type::HashShape then true
|
|
2510
|
+
when Type::Nominal then type.class_name == "Hash"
|
|
2511
|
+
when Type::Union then type.members.any? { |m| hashish?(m) }
|
|
2512
|
+
else false
|
|
2513
|
+
end
|
|
2514
|
+
end
|
|
2515
|
+
|
|
2516
|
+
def stringish?(type)
|
|
2517
|
+
(type.is_a?(Type::Constant) && type.value.is_a?(String)) ||
|
|
2518
|
+
(type.is_a?(Type::Nominal) && type.class_name == "String")
|
|
2519
|
+
end
|
|
2520
|
+
|
|
2521
|
+
# `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or
|
|
2522
|
+
# an index-write node (`h[k] ||= v`), typed in the block-entry scope.
|
|
2523
|
+
# For an index-write the stored value is opaque (the appended values
|
|
2524
|
+
# often land on a NESTED collection via `h[k] << v`), so the value is
|
|
2525
|
+
# floored to `untyped` — sound: it only ever widens the value param.
|
|
2526
|
+
# Returns `[]` for other forms.
|
|
2527
|
+
def hash_pair_types(node, block_entry)
|
|
2528
|
+
if index_write?(node)
|
|
2529
|
+
key = index_key_type(node, block_entry)
|
|
2530
|
+
return [] if key.nil?
|
|
2531
|
+
|
|
2532
|
+
return [[key, Type::Combinator.untyped]]
|
|
2533
|
+
end
|
|
2534
|
+
|
|
2535
|
+
args = content_arg_types(node, block_entry)
|
|
2536
|
+
return [] if args.size < 2
|
|
2537
|
+
|
|
2538
|
+
[[args.first, args.last]]
|
|
2539
|
+
end
|
|
2540
|
+
|
|
2541
|
+
# Type of the index expression of an index-write node (`h[k] ||= v`).
|
|
2542
|
+
def index_key_type(node, block_entry)
|
|
2543
|
+
args = node.arguments
|
|
2544
|
+
return nil unless args.is_a?(Prism::ArgumentsNode)
|
|
2545
|
+
|
|
2546
|
+
first = args.arguments.first
|
|
2547
|
+
first.nil? ? nil : block_entry.type_of(first, tracer: tracer)
|
|
2548
|
+
rescue StandardError
|
|
2549
|
+
nil
|
|
2550
|
+
end
|
|
2551
|
+
|
|
2552
|
+
# Argument types for a content-mutator call, typed against the
|
|
2553
|
+
# block-entry scope (block params bound). A sub-evaluator over
|
|
2554
|
+
# `block_entry` keeps the argument typing flow-correct for params /
|
|
2555
|
+
# `;`-locals without leaking into the outer scope.
|
|
2556
|
+
def content_arg_types(call_node, block_entry)
|
|
2557
|
+
arguments = call_node.arguments
|
|
2558
|
+
return [] if arguments.nil?
|
|
2559
|
+
|
|
2560
|
+
arguments.arguments.map { |arg| block_entry.type_of(arg, tracer: tracer) }
|
|
2561
|
+
rescue StandardError
|
|
2562
|
+
[]
|
|
2563
|
+
end
|
|
2564
|
+
|
|
2565
|
+
# Evaluates `block`'s body once with each written outer local bound to
|
|
2566
|
+
# the supplied `bindings` (block params / `;`-locals re-bound as
|
|
2567
|
+
# usual) and returns the per-name exit binding for `names`. Used as
|
|
2568
|
+
# the `BodyFixpoint` body-evaluator.
|
|
2569
|
+
def block_exit_bindings(call_node, block, bindings, names)
|
|
2570
|
+
entry = build_block_entry_scope(call_node, block)
|
|
2571
|
+
entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
|
|
2572
|
+
_type, exit_scope = sub_eval(block, entry)
|
|
2573
|
+
names.to_h { |name| [name, exit_scope.local(name)] }
|
|
2574
|
+
end
|
|
2575
|
+
|
|
1673
2576
|
# Names introduced by the block itself (parameters, numbered
|
|
1674
2577
|
# parameters via `BlockParameterBinder`, plus explicit
|
|
1675
2578
|
# `;`-prefixed block-locals on `BlockParametersNode`). Writes
|
|
@@ -1799,7 +2702,14 @@ module Rigor
|
|
|
1799
2702
|
seeded = scope.class_ivars_for(path)
|
|
1800
2703
|
return body_scope if seeded.empty?
|
|
1801
2704
|
|
|
1802
|
-
|
|
2705
|
+
# ADR-58 WD1 — the class-ivar index unions every `@x = …` write across
|
|
2706
|
+
# the class flow-insensitively, so a ctor `@x = nil` seed makes a read
|
|
2707
|
+
# in a *different* method type `T | nil`. That `nil` is
|
|
2708
|
+
# declaration-sourced, not flow-live, so `seed_declaration_sourced_ivar`
|
|
2709
|
+
# marks each seeded ivar: `possible-nil-receiver` then declines to fire
|
|
2710
|
+
# on the cross-method invariant unless a method-local write or
|
|
2711
|
+
# narrowing makes the nil flow-live (which drops the mark).
|
|
2712
|
+
seeded.reduce(body_scope) { |acc, (name, type)| acc.seed_declaration_sourced_ivar(name, type) }
|
|
1803
2713
|
end
|
|
1804
2714
|
|
|
1805
2715
|
# Cvars are visible from BOTH instance and singleton method
|
|
@@ -1834,30 +2744,18 @@ module Rigor
|
|
|
1834
2744
|
# ScopeIndexer-populated declaration overrides
|
|
1835
2745
|
# (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
|
|
1836
2746
|
# remain reachable from inside nested bodies.
|
|
1837
|
-
def build_fresh_body_scope
|
|
1838
|
-
# Single allocation instead of a
|
|
1839
|
-
# per class/method body on the main walk, so the chain's
|
|
1840
|
-
#
|
|
1841
|
-
# Local-empty by design;
|
|
1842
|
-
#
|
|
1843
|
-
#
|
|
2747
|
+
def build_fresh_body_scope
|
|
2748
|
+
# Single allocation instead of a deep `with_*` chain — this runs
|
|
2749
|
+
# per class/method body on the main walk, so the chain's throwaway
|
|
2750
|
+
# intermediate Scopes were a top `Scope#rebuild` source (ADR-44).
|
|
2751
|
+
# Local-empty by design; the discovery index is inherited whole by
|
|
2752
|
+
# reference (ADR-53 Track A), so a table added to the index can no
|
|
2753
|
+
# longer be dropped here by a missed per-field copy.
|
|
1844
2754
|
Scope.new(
|
|
1845
2755
|
environment: scope.environment,
|
|
1846
2756
|
locals: {}.freeze,
|
|
1847
2757
|
source_path: scope.source_path,
|
|
1848
|
-
|
|
1849
|
-
discovered_classes: scope.discovered_classes,
|
|
1850
|
-
in_source_constants: scope.in_source_constants,
|
|
1851
|
-
class_ivars: scope.class_ivars,
|
|
1852
|
-
class_cvars: scope.class_cvars,
|
|
1853
|
-
program_globals: scope.program_globals,
|
|
1854
|
-
discovered_methods: scope.discovered_methods,
|
|
1855
|
-
discovered_def_nodes: scope.discovered_def_nodes,
|
|
1856
|
-
discovered_def_sources: scope.discovered_def_sources,
|
|
1857
|
-
discovered_superclasses: scope.discovered_superclasses,
|
|
1858
|
-
discovered_includes: scope.discovered_includes,
|
|
1859
|
-
discovered_class_sources: scope.discovered_class_sources,
|
|
1860
|
-
discovered_method_visibilities: scope.discovered_method_visibilities
|
|
2758
|
+
discovery: scope.discovery
|
|
1861
2759
|
)
|
|
1862
2760
|
end
|
|
1863
2761
|
|
|
@@ -2002,12 +2900,44 @@ module Rigor
|
|
|
2002
2900
|
|
|
2003
2901
|
# ----- helpers -----
|
|
2004
2902
|
|
|
2903
|
+
# Explicit `return value` (including `return` inside a block, which in
|
|
2904
|
+
# Ruby returns from the *enclosing method*). The control-transfer value
|
|
2905
|
+
# is `Bot` — a `return` produces no value at its own position — but the
|
|
2906
|
+
# returned expression's type is recorded into the active return sink so
|
|
2907
|
+
# the method-return inference joins it with the body's tail type.
|
|
2908
|
+
# Returns inside a nested `def`/lambda are barriers: `eval_def` clears
|
|
2909
|
+
# the sink around the nested body, so this handler only ever appends a
|
|
2910
|
+
# return that genuinely exits the method currently being inferred.
|
|
2911
|
+
def eval_return(node)
|
|
2912
|
+
sink = Thread.current[RETURN_SINK_KEY]
|
|
2913
|
+
record_return_value(node, sink) if sink
|
|
2914
|
+
[Type::Combinator.bot, scope]
|
|
2915
|
+
end
|
|
2916
|
+
|
|
2917
|
+
def record_return_value(node, sink)
|
|
2918
|
+
args = node.arguments&.arguments || []
|
|
2919
|
+
# `return` with no argument returns nil; `return a` records the
|
|
2920
|
+
# argument's type; `return a, b, c` packs a Tuple — in Ruby a
|
|
2921
|
+
# multi-value return yields the array `[a, b, c]`, so the inferred
|
|
2922
|
+
# return contributes the corresponding Tuple element-by-element.
|
|
2923
|
+
if args.empty?
|
|
2924
|
+
sink << Type::Combinator.constant_of(nil)
|
|
2925
|
+
elsif args.size == 1
|
|
2926
|
+
type, = sub_eval(args.first, scope)
|
|
2927
|
+
sink << type
|
|
2928
|
+
else
|
|
2929
|
+
element_types = args.map { |arg| sub_eval(arg, scope).first }
|
|
2930
|
+
sink << Type::Combinator.tuple_of(*element_types)
|
|
2931
|
+
end
|
|
2932
|
+
end
|
|
2933
|
+
|
|
2005
2934
|
def sub_eval(node, with_scope, class_context: @class_context)
|
|
2006
2935
|
StatementEvaluator.new(
|
|
2007
2936
|
scope: with_scope,
|
|
2008
2937
|
tracer: tracer,
|
|
2009
2938
|
on_enter: @on_enter,
|
|
2010
|
-
class_context: class_context
|
|
2939
|
+
class_context: class_context,
|
|
2940
|
+
converged_loop_recording: @converged_loop_recording
|
|
2011
2941
|
).evaluate(node)
|
|
2012
2942
|
end
|
|
2013
2943
|
|