rigortype 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +756 -132
- data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
- data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/fact_store.rb +5 -4
- data/lib/rigor/analysis/rule_catalog.rb +153 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
- data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
- data/lib/rigor/analysis/runner.rb +75 -27
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
- data/lib/rigor/analysis/worker_session.rb +31 -25
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +5 -3
- data/lib/rigor/cli/annotate_command.rb +122 -16
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/check_command.rb +118 -16
- data/lib/rigor/cli/coverage_command.rb +148 -16
- data/lib/rigor/cli/coverage_scan.rb +57 -0
- data/lib/rigor/cli/explain_command.rb +2 -0
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
- data/lib/rigor/cli/mutation_protection_report.rb +73 -0
- data/lib/rigor/cli/options.rb +9 -0
- data/lib/rigor/cli/plugins_command.rb +4 -5
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/protection_renderer.rb +63 -0
- data/lib/rigor/cli/protection_report.rb +68 -0
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -1
- data/lib/rigor/cli/trace_command.rb +2 -1
- data/lib/rigor/cli/triage_command.rb +8 -4
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_command.rb +2 -1
- data/lib/rigor/cli.rb +12 -3
- data/lib/rigor/configuration/dependencies.rb +2 -4
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +100 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
- data/lib/rigor/environment/class_registry.rb +4 -3
- data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
- data/lib/rigor/environment/lockfile_resolver.rb +1 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
- data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
- data/lib/rigor/environment/rbs_loader.rb +74 -5
- data/lib/rigor/environment.rb +17 -7
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution.rb +3 -5
- data/lib/rigor/inference/acceptance.rb +17 -9
- data/lib/rigor/inference/block_parameter_binder.rb +2 -3
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +1072 -71
- data/lib/rigor/inference/hkt_body.rb +8 -11
- data/lib/rigor/inference/hkt_body_parser.rb +10 -12
- data/lib/rigor/inference/hkt_registry.rb +10 -11
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
- 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 +237 -24
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
- data/lib/rigor/inference/method_dispatcher.rb +112 -49
- 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 +147 -11
- data/lib/rigor/inference/narrowing.rb +284 -53
- data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
- data/lib/rigor/inference/project_patched_methods.rb +4 -7
- data/lib/rigor/inference/project_patched_scanner.rb +2 -13
- data/lib/rigor/inference/protection_scanner.rb +86 -0
- data/lib/rigor/inference/scope_indexer.rb +821 -76
- data/lib/rigor/inference/statement_evaluator.rb +1179 -102
- data/lib/rigor/inference/struct_fold_safety.rb +181 -0
- data/lib/rigor/inference/synthetic_method.rb +7 -7
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/language_server/completion_provider.rb +6 -12
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
- data/lib/rigor/language_server/hover_provider.rb +2 -3
- data/lib/rigor/language_server/hover_renderer.rb +2 -11
- data/lib/rigor/language_server/server.rb +9 -17
- data/lib/rigor/language_server.rb +4 -5
- data/lib/rigor/plugin/base.rb +245 -87
- data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
- data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
- data/lib/rigor/plugin/macro.rb +6 -8
- data/lib/rigor/plugin/manifest.rb +49 -90
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +18 -18
- data/lib/rigor/plugin/type_node_resolver.rb +6 -8
- data/lib/rigor/protection/mutation_scanner.rb +120 -0
- data/lib/rigor/protection/mutator.rb +246 -0
- data/lib/rigor/rbs_extended.rb +24 -36
- data/lib/rigor/reflection.rb +4 -7
- data/lib/rigor/scope/discovery_index.rb +16 -2
- data/lib/rigor/scope.rb +185 -16
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observed_call.rb +3 -3
- data/lib/rigor/sig_gen/writer.rb +40 -2
- data/lib/rigor/source/constant_path.rb +62 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/bound_method.rb +2 -11
- data/lib/rigor/type/combinator.rb +45 -3
- data/lib/rigor/type/constant.rb +2 -11
- data/lib/rigor/type/data_class.rb +2 -11
- data/lib/rigor/type/data_instance.rb +2 -11
- data/lib/rigor/type/hash_shape.rb +2 -11
- data/lib/rigor/type/integer_range.rb +2 -11
- data/lib/rigor/type/intersection.rb +2 -11
- data/lib/rigor/type/nominal.rb +2 -11
- data/lib/rigor/type/plain_lattice.rb +37 -0
- data/lib/rigor/type/refined.rb +72 -13
- data/lib/rigor/type/singleton.rb +2 -11
- data/lib/rigor/type/struct_class.rb +75 -0
- data/lib/rigor/type/struct_instance.rb +93 -0
- data/lib/rigor/type/tuple.rb +5 -15
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
- 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 +3 -4
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
- 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/assertion_recognizer.rb +2 -3
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +18 -1
- data/sig/rigor/type.rbs +37 -1
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +25 -2
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -6,7 +6,10 @@ require_relative "../reflection"
|
|
|
6
6
|
require_relative "../type"
|
|
7
7
|
require_relative "../analysis/fact_store"
|
|
8
8
|
require_relative "../source/node_walker"
|
|
9
|
+
require_relative "../source/constant_path"
|
|
9
10
|
require_relative "block_parameter_binder"
|
|
11
|
+
require_relative "body_fixpoint"
|
|
12
|
+
require_relative "struct_fold_safety"
|
|
10
13
|
require_relative "closure_escape_analyzer"
|
|
11
14
|
require_relative "indexed_narrowing"
|
|
12
15
|
require_relative "method_dispatcher"
|
|
@@ -98,10 +101,36 @@ module Rigor
|
|
|
98
101
|
Prism::SingletonClassNode => :eval_singleton_class,
|
|
99
102
|
Prism::CallNode => :eval_call,
|
|
100
103
|
Prism::BlockNode => :eval_block,
|
|
104
|
+
Prism::ReturnNode => :eval_return,
|
|
105
|
+
Prism::BreakNode => :eval_break,
|
|
101
106
|
Prism::MatchWriteNode => :eval_match_write
|
|
102
107
|
}.freeze
|
|
103
108
|
private_constant :HANDLERS
|
|
104
109
|
|
|
110
|
+
# Thread-local sink (an Array) collecting the value types of explicit
|
|
111
|
+
# `return value` nodes reached while evaluating a method body, so
|
|
112
|
+
# `ExpressionTyper#infer_user_method_return` can join them into the
|
|
113
|
+
# method's inferred return type. The flow value of a `return` is still
|
|
114
|
+
# `Bot` (it transfers control rather than producing a value); the sink
|
|
115
|
+
# only records what the method *returns* through that edge. nil means
|
|
116
|
+
# "not collecting" — a top-level / DSL-block walk, or inside a nested
|
|
117
|
+
# `def` barrier (whose returns belong to the inner method).
|
|
118
|
+
RETURN_SINK_KEY = :rigor_return_sink
|
|
119
|
+
private_constant :RETURN_SINK_KEY
|
|
120
|
+
|
|
121
|
+
# Thread-local sink (an Array of `[BreakNode, Scope]`) collecting the
|
|
122
|
+
# scope at each `break` reached while evaluating a loop body, so
|
|
123
|
+
# `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true;
|
|
124
|
+
# break`) into the loop continuation that the fall-through would
|
|
125
|
+
# otherwise drop. Stacks like the return sink: a nested loop installs its
|
|
126
|
+
# own sink, restored on exit, so an inner loop's break does not leak to
|
|
127
|
+
# the outer one. A `break` inside a block / nested loop targets that
|
|
128
|
+
# inner construct, not the lexical loop — filtered out by the
|
|
129
|
+
# directly-targeting break set, see {#directly_targeting_breaks}.
|
|
130
|
+
# See docs/notes/20260615-loop-break-binding-propagation-design.md.
|
|
131
|
+
BREAK_SINK_KEY = :rigor_break_sink
|
|
132
|
+
private_constant :BREAK_SINK_KEY
|
|
133
|
+
|
|
105
134
|
# Lexical class frame: the `name:` field is the qualified class
|
|
106
135
|
# name as it would render in Ruby (e.g., `"Foo::Bar"`); the
|
|
107
136
|
# `singleton:` field is `true` for `class << self` frames so
|
|
@@ -121,11 +150,40 @@ module Rigor
|
|
|
121
150
|
# by {#eval_def} to look up the method's RBS signature. Each
|
|
122
151
|
# `ClassNode`/`ModuleNode` entry pushes a frame; `SingletonClassNode`
|
|
123
152
|
# over `self` flips the innermost frame to singleton mode.
|
|
124
|
-
|
|
153
|
+
# @param converged_loop_recording [Boolean] when true (and an
|
|
154
|
+
# `on_enter` recorder is installed), {#eval_loop} re-evaluates a
|
|
155
|
+
# fixpoint-tracked loop body ONE extra time from the CONVERGED
|
|
156
|
+
# bindings so the last-visit-wins per-node scope index reflects
|
|
157
|
+
# the post-writeback state instead of the cap-N intermediate
|
|
158
|
+
# assumption (`result *= i` annotating `1 | 2` rather than
|
|
159
|
+
# `Integer`). Display-path only — `rigor check` leaves it off,
|
|
160
|
+
# keeping its diagnostics and wall-clock unchanged.
|
|
161
|
+
def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze,
|
|
162
|
+
converged_loop_recording: false)
|
|
125
163
|
@scope = scope
|
|
126
164
|
@tracer = tracer
|
|
127
165
|
@on_enter = on_enter
|
|
128
166
|
@class_context = class_context.freeze
|
|
167
|
+
@converged_loop_recording = converged_loop_recording
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Runs `block` with a fresh return sink installed, then yields the
|
|
171
|
+
# collected explicit-`return` value types to the caller. The sink is
|
|
172
|
+
# an array of `Rigor::Type`. Nested invocations stack: the previous
|
|
173
|
+
# sink is restored on exit so a `def` evaluated inside another method's
|
|
174
|
+
# body (which itself installed a sink) does not corrupt the outer one.
|
|
175
|
+
# Used by `ExpressionTyper#infer_user_method_return` to join the
|
|
176
|
+
# explicit returns into the inferred method-return type.
|
|
177
|
+
def self.with_return_sink
|
|
178
|
+
previous = Thread.current[RETURN_SINK_KEY]
|
|
179
|
+
sink = []
|
|
180
|
+
Thread.current[RETURN_SINK_KEY] = sink
|
|
181
|
+
begin
|
|
182
|
+
result = yield
|
|
183
|
+
ensure
|
|
184
|
+
Thread.current[RETURN_SINK_KEY] = previous
|
|
185
|
+
end
|
|
186
|
+
[result, sink]
|
|
129
187
|
end
|
|
130
188
|
|
|
131
189
|
# Evaluate `node` under the receiver scope. Returns `[type, scope']`
|
|
@@ -178,9 +236,28 @@ module Rigor
|
|
|
178
236
|
# default branch in {#evaluate}.
|
|
179
237
|
def eval_local_write(node)
|
|
180
238
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
239
|
+
# ADR-58 WD1 — `r = @right` where `@right`'s optionality is purely
|
|
240
|
+
# declaration-sourced makes `r` declaration-sourced too (the survey's
|
|
241
|
+
# exact rotation/traversal shape `r = @right; r.key`). The mark is
|
|
242
|
+
# computed on the RHS *value*'s provenance — a pure ivar read of a
|
|
243
|
+
# currently declaration-sourced ivar — so it survives the local copy.
|
|
244
|
+
# Any other RHS (a call result, a method-local-nil-bearing value)
|
|
245
|
+
# leaves the local flow-live and the diagnostic fires as before.
|
|
246
|
+
if declaration_sourced_ivar_read?(node.value, post_rhs)
|
|
247
|
+
return [rhs_type, post_rhs.with_declaration_sourced_local(node.name, rhs_type)]
|
|
248
|
+
end
|
|
249
|
+
|
|
181
250
|
[rhs_type, post_rhs.with_local(node.name, rhs_type)]
|
|
182
251
|
end
|
|
183
252
|
|
|
253
|
+
# True when `value_node` is a bare instance-variable read whose binding
|
|
254
|
+
# in `scope_at_read` is currently marked declaration-sourced.
|
|
255
|
+
def declaration_sourced_ivar_read?(value_node, scope_at_read)
|
|
256
|
+
return false unless value_node.is_a?(Prism::InstanceVariableReadNode)
|
|
257
|
+
|
|
258
|
+
scope_at_read.declaration_sourced?(:ivar, value_node.name)
|
|
259
|
+
end
|
|
260
|
+
|
|
184
261
|
# Slice 7 phase 1 — instance/class/global variable
|
|
185
262
|
# writes. Each handler evaluates the rvalue under the
|
|
186
263
|
# entry scope and binds the named variable into the
|
|
@@ -304,9 +381,7 @@ module Rigor
|
|
|
304
381
|
end
|
|
305
382
|
|
|
306
383
|
# `receiver[key] ||= default` — the Redmine `Query#as_params`
|
|
307
|
-
# idiom
|
|
308
|
-
# "Indexed-collection narrowing through `Hash[k] ||= default`").
|
|
309
|
-
# After the `||=`, the next read at `receiver[key]` is known
|
|
384
|
+
# idiom. After the `||=`, the next read at `receiver[key]` is known
|
|
310
385
|
# non-nil; the next `<<` / `[]=` / other mutator runs against
|
|
311
386
|
# a Tuple / Hash carrier instead of the `Constant[nil]` an
|
|
312
387
|
# empty `HashShape{}` lookup would otherwise fold to.
|
|
@@ -419,10 +494,24 @@ module Rigor
|
|
|
419
494
|
# then-branch unconditionally exits (return / next /
|
|
420
495
|
# break / raise) and there is no else, the post-scope
|
|
421
496
|
# is the falsey edge of the predicate (subsequent
|
|
422
|
-
# statements observe the predicate-was-false world).
|
|
497
|
+
# statements observe the predicate-was-false world). The
|
|
498
|
+
# then-body is the *skipped* path, so the bare narrowing
|
|
499
|
+
# (no body assignments) is the correct continuation.
|
|
423
500
|
return [Type::Combinator.union(then_type, else_type), falsey_scope] \
|
|
424
501
|
if branch_terminates?(node.statements, then_type) && node.subsequent.nil?
|
|
425
|
-
|
|
502
|
+
# Symmetric case: the else / elsif-chain (`node.subsequent`)
|
|
503
|
+
# unconditionally exits, so the only surviving path is the
|
|
504
|
+
# then-branch that RAN. The continuation must therefore carry
|
|
505
|
+
# `then_scope` — the predicate-truthy narrowing PLUS the
|
|
506
|
+
# then-body's assignments — not the bare `truthy_scope`.
|
|
507
|
+
# Returning `truthy_scope` drops every local the then-body
|
|
508
|
+
# bound, leaving it unbound for any enclosing merge to
|
|
509
|
+
# spuriously nil-inject: e.g. the inner `elsif … else raise`
|
|
510
|
+
# of `if a then x=… elsif b then x=… else raise end` would
|
|
511
|
+
# return with `x` unbound, and the outer if's join would then
|
|
512
|
+
# read `x` as `… | nil` and fire a false `possible-nil-receiver`
|
|
513
|
+
# (liquid v5.x sweep, Event 3).
|
|
514
|
+
return [Type::Combinator.union(then_type, else_type), then_scope] \
|
|
426
515
|
if branch_terminates?(node.subsequent, else_type) && node.statements
|
|
427
516
|
|
|
428
517
|
[
|
|
@@ -457,10 +546,17 @@ module Rigor
|
|
|
457
546
|
else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
|
|
458
547
|
# Slice 7 phase 14 — same early-return narrowing as
|
|
459
548
|
# `if`: when the body unconditionally exits and there
|
|
460
|
-
# is no else, the post-scope is the truthy edge
|
|
549
|
+
# is no else, the post-scope is the truthy edge (the body
|
|
550
|
+
# is the skipped path, so the bare narrowing is correct).
|
|
461
551
|
return [Type::Combinator.union(then_type, else_type), truthy_scope] \
|
|
462
552
|
if branch_terminates?(node.statements, then_type) && node.else_clause.nil?
|
|
463
|
-
|
|
553
|
+
# Symmetric to the `if` else-exits fix: when the else-clause
|
|
554
|
+
# exits, the surviving path is the unless-body that RAN, so the
|
|
555
|
+
# continuation carries `then_scope` (the predicate-falsey
|
|
556
|
+
# narrowing PLUS the body's assignments), not the bare
|
|
557
|
+
# `falsey_scope` — otherwise body-bound locals are dropped and
|
|
558
|
+
# an enclosing merge nil-injects them.
|
|
559
|
+
return [Type::Combinator.union(then_type, else_type), then_scope] \
|
|
464
560
|
if branch_terminates?(node.else_clause, else_type) && node.statements
|
|
465
561
|
|
|
466
562
|
[
|
|
@@ -506,12 +602,33 @@ module Rigor
|
|
|
506
602
|
else_result = eval_case_else(node.else_clause, falsey_scope)
|
|
507
603
|
|
|
508
604
|
all_results = [*branch_results, else_result]
|
|
605
|
+
branch_nodes = [*node.conditions, node.else_clause]
|
|
509
606
|
[
|
|
510
607
|
Type::Combinator.union(*all_results.map(&:first)),
|
|
511
|
-
|
|
608
|
+
join_case_branch_scopes(all_results, branch_nodes)
|
|
512
609
|
]
|
|
513
610
|
end
|
|
514
611
|
|
|
612
|
+
# Joins the post-scopes of every `when`/`in`/`else` branch, dropping
|
|
613
|
+
# the scope of any branch that terminates (raises / returns / throws /
|
|
614
|
+
# types to `Bot`) before the merge — control never falls through such
|
|
615
|
+
# a branch, so its half-bound locals must not nil-inject the names a
|
|
616
|
+
# live sibling branch assigned. Mirrors the `branch_terminates?` rule
|
|
617
|
+
# `eval_if`/`eval_unless` already apply to the if/else merge: e.g.
|
|
618
|
+
# `case x; when 1 then v="a"; when 2 then v="b"; else raise; end`
|
|
619
|
+
# keeps `v: "a" | "b"` instead of `... | nil`. When every branch
|
|
620
|
+
# terminates the merge is itself unreachable; fall back to the full
|
|
621
|
+
# join so the continuation scope stays well-formed.
|
|
622
|
+
def join_case_branch_scopes(results, nodes)
|
|
623
|
+
live = []
|
|
624
|
+
results.each_with_index do |(type, branch_scope), i|
|
|
625
|
+
live << branch_scope unless branch_terminates?(nodes[i], type)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
live = results.map(&:last) if live.empty?
|
|
629
|
+
reduce_scopes_with_nil_injection(live)
|
|
630
|
+
end
|
|
631
|
+
|
|
515
632
|
def eval_case_when_branches(subject, conditions, entry_scope)
|
|
516
633
|
results = []
|
|
517
634
|
falsey_scope = entry_scope
|
|
@@ -810,13 +927,294 @@ module Rigor
|
|
|
810
927
|
# common case where no `break VALUE` is observed.
|
|
811
928
|
def eval_loop(node)
|
|
812
929
|
_pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
813
|
-
return [Type::Combinator.constant_of(nil), post_pred] if node.statements.nil?
|
|
930
|
+
return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
|
|
931
|
+
|
|
932
|
+
# The historical single body pass joined with the pre-loop scope.
|
|
933
|
+
# This continues to carry everything the fixpoint does NOT track:
|
|
934
|
+
# receiver-mutation widening of non-rebound locals (`buf.push(i)`
|
|
935
|
+
# widens `buf`'s Tuple), body-introduced locals' nil-injection, and
|
|
936
|
+
# the loop value itself. The fixpoint then OVERLAYS only the
|
|
937
|
+
# rebound-local bindings it corrects.
|
|
938
|
+
#
|
|
939
|
+
# The pass runs under a break sink so a `break`-path binding
|
|
940
|
+
# (`flag = true; break`) the fall-through `body_scope` drops is
|
|
941
|
+
# collected for the continuation join below.
|
|
942
|
+
break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, post_pred)
|
|
943
|
+
base_scope = join_with_nil_injection(post_pred, body_scope)
|
|
944
|
+
|
|
945
|
+
rebound, body_first = loop_body_local_writes(node.statements, post_pred)
|
|
946
|
+
names = rebound + body_first
|
|
947
|
+
|
|
948
|
+
# Fast path: a loop whose body rebinds no local skips the rebind
|
|
949
|
+
# fixpoint, but still needs the slice-C content writeback (a loop may
|
|
950
|
+
# content-mutate a collection without rebinding any local — `acc <<
|
|
951
|
+
# x`), so apply it to the single-pass join before returning.
|
|
952
|
+
if names.empty?
|
|
953
|
+
fast = loop_content_writeback(node.statements, base_scope)
|
|
954
|
+
return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
|
|
955
|
+
end
|
|
814
956
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
957
|
+
post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first)
|
|
958
|
+
# Recover `break`-path bindings the fall-through dropped (`flag = true;
|
|
959
|
+
# break` -> `flag` is `false | true`, not the stale `false`).
|
|
960
|
+
post_loop = join_break_scopes(post_loop, break_sink, break_targets, names)
|
|
961
|
+
post_loop = narrow_loop_exit_edge(node, post_loop)
|
|
962
|
+
[Type::Combinator.constant_of(nil), post_loop]
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
# The continuation scope for a loop whose body rebinds locals: the
|
|
966
|
+
# ADR-56 slice-B rebind fixpoint overlaid on `base_scope`, then the
|
|
967
|
+
# slice-C receiver-content writeback.
|
|
968
|
+
def converged_loop_scope(node, post_pred, base_scope, names, body_first)
|
|
969
|
+
# ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and
|
|
970
|
+
# may compound (`d *= 2`), so the historical single body pass joined
|
|
971
|
+
# with the pre-loop scope kept stale folded constants
|
|
972
|
+
# (`d = 1; while …; d *= 2; end` → `1 | 2`, never reaching `4, 8`).
|
|
973
|
+
# Fold each body-written local's continuation binding through the same
|
|
974
|
+
# capped fixpoint slice A uses for non-escaping block captures. Seed:
|
|
975
|
+
# a pre-existing local seeds with its post-predicate binding; a local
|
|
976
|
+
# FIRST assigned inside the body seeds with `nil` so the 0-iteration
|
|
977
|
+
# path degrades it to `T | nil`, matching the nil-injection treatment.
|
|
978
|
+
result = loop_rebind_fixpoint(node, post_pred, names, body_first)
|
|
979
|
+
# Display-path re-record: the fixpoint's body re-evaluations fire
|
|
980
|
+
# `on_enter` with the cap-N INTERMEDIATE assumptions, so the
|
|
981
|
+
# last-visit-wins scope index would annotate loop-body lines with
|
|
982
|
+
# stale pre-convergence constants. One extra pass from the converged
|
|
983
|
+
# bindings (result discarded) re-records the body's entry scopes.
|
|
984
|
+
record_converged_loop_body(node, post_pred, result, names, body_first)
|
|
985
|
+
post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
986
|
+
# ADR-56 slice C — loop-body receiver-content element-type join. A loop
|
|
987
|
+
# that content-mutates a collection (`acc << n`) keeps only the seed's
|
|
988
|
+
# element types after the single-pass widen; join the appended/stored
|
|
989
|
+
# types into the continuation collection (pre-state read from
|
|
990
|
+
# `post_loop` so a local both rebound and content-mutated composes).
|
|
991
|
+
loop_content_writeback(node.statements, post_loop)
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred`
|
|
995
|
+
# loop exits PRECISELY on the predicate's exit edge: `while` exits when
|
|
996
|
+
# `pred` is falsey, `until` when `pred` is truthy. So after the loop the
|
|
997
|
+
# predicate-assignment target carries the exit polarity — `until line =
|
|
998
|
+
# io.gets; …; end; line.foo` reads `line` non-nil because the loop ran
|
|
999
|
+
# until `gets` returned a truthy (non-nil) line. Apply the exit edge of
|
|
1000
|
+
# `Narrowing.predicate_scopes` to the continuation scope.
|
|
1001
|
+
#
|
|
1002
|
+
# Guarded against `break`: a `break` exits the loop WITHOUT the predicate
|
|
1003
|
+
# ever going false (`while line = gets; break if done; end` can leave
|
|
1004
|
+
# `line` truthy on a `while`, or exit before the `until` predicate fires),
|
|
1005
|
+
# so the exit-edge proof does not hold and the loop is left un-narrowed.
|
|
1006
|
+
# `break` inside a NESTED loop/block does not target this loop, but a
|
|
1007
|
+
# nested-loop `break` is rare in predicate-assignment loops and the
|
|
1008
|
+
# conservative bail only costs precision, never soundness.
|
|
1009
|
+
def narrow_loop_exit_edge(node, post_loop)
|
|
1010
|
+
return post_loop if loop_body_breaks?(node.statements)
|
|
1011
|
+
|
|
1012
|
+
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_loop)
|
|
1013
|
+
node.is_a?(Prism::UntilNode) ? truthy_scope : falsey_scope
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# True when the loop body can `break` out of THIS loop. Conservatively
|
|
1017
|
+
# treats any `BreakNode` under the body as a break for this loop (a
|
|
1018
|
+
# break inside a nested loop/block actually targets the inner construct,
|
|
1019
|
+
# but bailing is precision-only).
|
|
1020
|
+
def loop_body_breaks?(statements)
|
|
1021
|
+
return false if statements.nil?
|
|
1022
|
+
|
|
1023
|
+
found = false
|
|
1024
|
+
Source::NodeWalker.each(statements) do |descendant|
|
|
1025
|
+
found = true if descendant.is_a?(Prism::BreakNode)
|
|
1026
|
+
end
|
|
1027
|
+
found
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
# A `break` inside one of these nested constructs targets the inner
|
|
1031
|
+
# construct (an inner loop, a block's method, a nested def), NOT the
|
|
1032
|
+
# lexical loop — so the directly-targeting break scan does not descend
|
|
1033
|
+
# into them.
|
|
1034
|
+
BREAK_BOUNDARY_NODES = [
|
|
1035
|
+
Prism::ForNode, Prism::WhileNode, Prism::UntilNode,
|
|
1036
|
+
Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
|
|
1037
|
+
Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
|
|
1038
|
+
].freeze
|
|
1039
|
+
private_constant :BREAK_BOUNDARY_NODES
|
|
1040
|
+
|
|
1041
|
+
# The `BreakNode`s that lexically target THIS loop — reachable from the
|
|
1042
|
+
# body without crossing a nested loop / block / def boundary. An
|
|
1043
|
+
# identity-keyed Hash used as a membership set to filter the collected
|
|
1044
|
+
# break scopes (the thread-local sink also collects breaks from nested
|
|
1045
|
+
# blocks that did not install their own sink).
|
|
1046
|
+
def directly_targeting_breaks(statements)
|
|
1047
|
+
found = {}.compare_by_identity
|
|
1048
|
+
collect_direct_breaks(statements, found)
|
|
1049
|
+
found
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
def collect_direct_breaks(node, found)
|
|
1053
|
+
return if node.nil?
|
|
1054
|
+
|
|
1055
|
+
found[node] = true if node.is_a?(Prism::BreakNode)
|
|
1056
|
+
node.compact_child_nodes.each do |child|
|
|
1057
|
+
next if BREAK_BOUNDARY_NODES.any? { |klass| child.is_a?(klass) }
|
|
1058
|
+
|
|
1059
|
+
collect_direct_breaks(child, found)
|
|
1060
|
+
end
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
# Installs a fresh thread-local break sink around `yield` (a loop-body
|
|
1064
|
+
# evaluation), returning `[collected, yield_result]`. Stacks: the
|
|
1065
|
+
# previous sink is restored on exit so a nested loop's breaks do not
|
|
1066
|
+
# leak to the enclosing loop.
|
|
1067
|
+
def collect_break_scopes
|
|
1068
|
+
previous = Thread.current[BREAK_SINK_KEY]
|
|
1069
|
+
sink = []
|
|
1070
|
+
Thread.current[BREAK_SINK_KEY] = sink
|
|
1071
|
+
begin
|
|
1072
|
+
result = yield
|
|
1073
|
+
ensure
|
|
1074
|
+
Thread.current[BREAK_SINK_KEY] = previous
|
|
1075
|
+
end
|
|
1076
|
+
[sink, result]
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# Runs a loop body's single pass under a break sink. Returns the
|
|
1080
|
+
# directly-targeting break set, the collected break scopes, and the
|
|
1081
|
+
# fall-through body scope — the three inputs the continuation's
|
|
1082
|
+
# {#join_break_scopes} needs. Shared by `eval_loop` and `eval_for`.
|
|
1083
|
+
def capture_loop_body_breaks(statements, entry)
|
|
1084
|
+
targets = directly_targeting_breaks(statements)
|
|
1085
|
+
sink, (_type, body_scope) = collect_break_scopes { sub_eval(statements, entry) }
|
|
1086
|
+
[targets, sink, body_scope]
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# Joins each directly-targeting break's body-written local bindings into
|
|
1090
|
+
# the loop continuation, so a `break`-path binding the fall-through
|
|
1091
|
+
# dropped is recovered (`flag = true; break` -> `flag` becomes `false |
|
|
1092
|
+
# true`). Only loop-body-written names are joined — an unchanged local
|
|
1093
|
+
# unions to itself; a break-only-written local is already present via the
|
|
1094
|
+
# fixpoint / nil-injection seed, so the union reflects its break value.
|
|
1095
|
+
def join_break_scopes(continuation, sink, targeting, names)
|
|
1096
|
+
return continuation if sink.empty? || names.empty?
|
|
1097
|
+
|
|
1098
|
+
breaks = sink.select { |(node, _scope)| targeting.key?(node) }
|
|
1099
|
+
breaks.reduce(continuation) do |cont, (_node, break_scope)|
|
|
1100
|
+
names.reduce(cont) do |acc, name|
|
|
1101
|
+
break_value = break_scope.local(name)
|
|
1102
|
+
next acc if break_value.nil?
|
|
1103
|
+
|
|
1104
|
+
current = acc.local(name)
|
|
1105
|
+
joined = current ? Type::Combinator.union(current, break_value) : break_value
|
|
1106
|
+
acc.with_local(name, joined)
|
|
1107
|
+
end
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# Joins loop-body content mutations into the continuation collection
|
|
1112
|
+
# bindings. The mutator arguments are typed against `post_loop`, whose
|
|
1113
|
+
# locals already carry the loop-body fixpoint widening (so an
|
|
1114
|
+
# appended `n` that the loop decrements types `Integer`, not its
|
|
1115
|
+
# entry `Constant[3]` — otherwise only the first iteration's value
|
|
1116
|
+
# would be captured, an unsound under-approximation). Pre-state is
|
|
1117
|
+
# read from `post_loop` too. A loop body shares the surrounding scope,
|
|
1118
|
+
# so the receiver is any `LocalVariableReadNode` (no depth filter).
|
|
1119
|
+
def loop_content_writeback(statements, post_loop)
|
|
1120
|
+
return post_loop if statements.nil?
|
|
1121
|
+
|
|
1122
|
+
mutations = Hash.new { |h, k| h[k] = [] }
|
|
1123
|
+
Source::NodeWalker.each(statements) do |descendant|
|
|
1124
|
+
name, node = content_mutation_target(descendant) { |_r| true }
|
|
1125
|
+
mutations[name] << node unless name.nil?
|
|
1126
|
+
end
|
|
1127
|
+
return post_loop if mutations.empty?
|
|
1128
|
+
|
|
1129
|
+
mutations.reduce(post_loop) do |acc, (name, calls)|
|
|
1130
|
+
joined = join_content_for_local(name, calls, acc, post_loop)
|
|
1131
|
+
joined.nil? ? acc : acc.with_local(name, joined)
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Re-evaluates the loop body once from the converged fixpoint
|
|
1136
|
+
# bindings, solely for the `on_enter` side effect of re-recording
|
|
1137
|
+
# the body's per-node entry scopes. Gated behind the
|
|
1138
|
+
# display-path-only `converged_loop_recording` flag so the check
|
|
1139
|
+
# path neither pays the extra body evaluation nor risks any
|
|
1140
|
+
# diagnostic drift.
|
|
1141
|
+
def record_converged_loop_body(node, post_pred, bindings, names, body_first)
|
|
1142
|
+
return unless @converged_loop_recording && @on_enter
|
|
1143
|
+
|
|
1144
|
+
loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
|
|
1145
|
+
nil
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# Runs the slice-B loop-body rebind fixpoint, returning the per-name
|
|
1149
|
+
# continuation binding. Seed: a pre-existing local seeds with its
|
|
1150
|
+
# post-predicate binding; a local FIRST assigned inside the body seeds
|
|
1151
|
+
# with `nil` so the 0-iteration path (the body may never run) degrades
|
|
1152
|
+
# it to `T | nil`, matching the historical nil-injection treatment.
|
|
1153
|
+
def loop_rebind_fixpoint(node, post_pred, names, body_first)
|
|
1154
|
+
nil_const = Type::Combinator.constant_of(nil)
|
|
1155
|
+
seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
|
|
1156
|
+
BodyFixpoint.converge(
|
|
1157
|
+
names: names,
|
|
1158
|
+
seed_bindings: seed,
|
|
1159
|
+
widen: Type::Combinator.method(:widen_value_pinned),
|
|
1160
|
+
evaluate_body: ->(bindings) { loop_body_exit_bindings(node, post_pred, bindings, names, body_first) }
|
|
1161
|
+
)
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
# Names of locals the loop body can rebind, partitioned into those
|
|
1165
|
+
# already bound in `base_scope` (their pre-loop binding seeds the
|
|
1166
|
+
# fixpoint) and those FIRST assigned inside the body (no pre-state, so
|
|
1167
|
+
# they seed with `nil` for 0-iteration soundness). A loop body
|
|
1168
|
+
# introduces no new binding scope — every write leaks to the
|
|
1169
|
+
# surrounding scope — so unlike a block there is no introduced-name
|
|
1170
|
+
# filter; every local-write form under the body node counts.
|
|
1171
|
+
def loop_body_local_writes(statements, base_scope)
|
|
1172
|
+
pre_existing = []
|
|
1173
|
+
body_first = []
|
|
1174
|
+
Source::NodeWalker.each(statements) do |descendant|
|
|
1175
|
+
next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
|
|
1176
|
+
|
|
1177
|
+
name = descendant.name
|
|
1178
|
+
if base_scope.locals.key?(name)
|
|
1179
|
+
pre_existing << name
|
|
1180
|
+
else
|
|
1181
|
+
body_first << name
|
|
1182
|
+
end
|
|
1183
|
+
end
|
|
1184
|
+
[pre_existing.uniq, body_first.uniq - pre_existing.uniq]
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
# Evaluates the loop body once with each fixpoint-tracked local bound
|
|
1188
|
+
# to the supplied running assumption and returns the per-name exit
|
|
1189
|
+
# binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
|
|
1190
|
+
#
|
|
1191
|
+
# The body runs from `post_pred` overlaid with the assumptions, then
|
|
1192
|
+
# narrowed by the predicate's loop-entry edge: a `while` body only
|
|
1193
|
+
# runs when the predicate is TRUTHY, an `until` body only when it is
|
|
1194
|
+
# FALSEY. Re-applying that narrowing per iteration keeps loop-carried
|
|
1195
|
+
# narrowing sound — without it, an accumulator whose rebind can
|
|
1196
|
+
# introduce `nil` (`prefix = idx ? prefix[0, idx] : nil` under
|
|
1197
|
+
# `while prefix && …`) would re-enter the body with `nil` un-narrowed
|
|
1198
|
+
# and false-fire `possible nil receiver` on the guarded re-read. The
|
|
1199
|
+
# historical single body pass (which seeds these locals from their
|
|
1200
|
+
# never-nil pre-loop binding) did not need this; the fixpoint, which
|
|
1201
|
+
# feeds the widened assumption back in, does.
|
|
1202
|
+
#
|
|
1203
|
+
# A body-FIRST local (no pre-loop binding) is deliberately NOT overlaid
|
|
1204
|
+
# into the body-entry scope: when the body runs it assigns the local
|
|
1205
|
+
# before any use, exactly as the historical single body pass saw it.
|
|
1206
|
+
# Its `nil` seed exists only to model the 0-iteration path and is kept
|
|
1207
|
+
# as a join constituent by {BodyFixpoint#converge}; feeding that `nil`
|
|
1208
|
+
# back into the body re-evaluation would leak it past a condition-form
|
|
1209
|
+
# assignment the engine does not thread into the branch (`if exps.size
|
|
1210
|
+
# > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
|
|
1211
|
+
def loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
|
|
1212
|
+
overlaid = bindings.except(*body_first)
|
|
1213
|
+
entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
|
|
1214
|
+
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
|
|
1215
|
+
body_entry = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
|
|
1216
|
+
_type, exit_scope = sub_eval(node.statements, body_entry)
|
|
1217
|
+
names.to_h { |name| [name, exit_scope.local(name)] }
|
|
820
1218
|
end
|
|
821
1219
|
|
|
822
1220
|
# `for index in collection; body; end`. Unlike `each {}` blocks,
|
|
@@ -834,11 +1232,19 @@ module Rigor
|
|
|
834
1232
|
element_type = for_iteration_element_type(coll_type)
|
|
835
1233
|
body_entry = bind_for_index(node.index, element_type, post_coll)
|
|
836
1234
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1235
|
+
if node.statements.nil?
|
|
1236
|
+
return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
# Run the body pass under a break sink so a `break`-path binding the
|
|
1240
|
+
# fall-through drops is recovered into the continuation (the `for`
|
|
1241
|
+
# sibling of `eval_loop`'s break join; `for` has no fixpoint, so the
|
|
1242
|
+
# single-pass join is the only continuation).
|
|
1243
|
+
break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, body_entry)
|
|
1244
|
+
continuation = join_with_nil_injection(post_coll, body_scope)
|
|
1245
|
+
pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
|
|
1246
|
+
continuation = join_break_scopes(continuation, break_sink, break_targets, pre_existing + body_first)
|
|
1247
|
+
[Type::Combinator.constant_of(nil), continuation]
|
|
842
1248
|
end
|
|
843
1249
|
|
|
844
1250
|
# `for x in coll` is semantically `coll.each { |x| ... }`. We
|
|
@@ -1005,7 +1411,7 @@ module Rigor
|
|
|
1005
1411
|
# (`Constant[nil]` for an empty body); we discard the body's
|
|
1006
1412
|
# post-scope.
|
|
1007
1413
|
def eval_class_or_module(node)
|
|
1008
|
-
name =
|
|
1414
|
+
name = Source::ConstantPath.qualified_name(node.constant_path)
|
|
1009
1415
|
new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
|
|
1010
1416
|
body_type, _body_scope = eval_class_body(node, new_context)
|
|
1011
1417
|
[body_type, scope]
|
|
@@ -1046,8 +1452,18 @@ module Rigor
|
|
|
1046
1452
|
# like a singleton-side call. Observed surfacing 915 false
|
|
1047
1453
|
# positives in `prism-1.9.0`'s auto-generated `copy`
|
|
1048
1454
|
# methods alone.
|
|
1049
|
-
|
|
1050
|
-
|
|
1455
|
+
# A nested `def` is a return barrier: its body's `return`s belong to
|
|
1456
|
+
# the inner method, not the one currently being inferred. Suspend the
|
|
1457
|
+
# return sink across the nested body so `eval_return` does not record
|
|
1458
|
+
# them into the outer method's return type.
|
|
1459
|
+
outer_sink = Thread.current[RETURN_SINK_KEY]
|
|
1460
|
+
Thread.current[RETURN_SINK_KEY] = nil
|
|
1461
|
+
begin
|
|
1462
|
+
sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
|
|
1463
|
+
sub_eval(node.body, body_scope, class_context: @class_context) if node.body
|
|
1464
|
+
ensure
|
|
1465
|
+
Thread.current[RETURN_SINK_KEY] = outer_sink
|
|
1466
|
+
end
|
|
1051
1467
|
[Type::Combinator.constant_of(node.name), scope]
|
|
1052
1468
|
end
|
|
1053
1469
|
|
|
@@ -1069,6 +1485,11 @@ module Rigor
|
|
|
1069
1485
|
# observe the outer scope, matching Ruby evaluation order.
|
|
1070
1486
|
def eval_call(node)
|
|
1071
1487
|
call_type = scope.type_of(node, tracer: tracer)
|
|
1488
|
+
# ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }`
|
|
1489
|
+
# returns the memo; the engine otherwise types the call `Dynamic[top]`.
|
|
1490
|
+
# Compute the joined memo type from the block's content mutations of
|
|
1491
|
+
# the memo block-param and adopt it as the call's return type.
|
|
1492
|
+
call_type = each_with_object_return(node, call_type)
|
|
1072
1493
|
evaluate_block_if_present(node)
|
|
1073
1494
|
# `ruby2_keywords def foo(...)` (and similar wrappers like
|
|
1074
1495
|
# `private def`, `public def`, `module_function def`) parse
|
|
@@ -1086,6 +1507,14 @@ module Rigor
|
|
|
1086
1507
|
# `self_type` for the def's body.
|
|
1087
1508
|
evaluate_def_arguments(node)
|
|
1088
1509
|
post_scope = record_closure_escape_if_any(node)
|
|
1510
|
+
# ADR-56 slice A — non-escaping block captured-local write-back.
|
|
1511
|
+
# A `:non_escaping` block (each / times / upto / map …) that
|
|
1512
|
+
# rebinds an outer local must not leave that local's pre-call
|
|
1513
|
+
# binding unmodified in the continuation scope; the spec MUST in
|
|
1514
|
+
# § "Fact stability and mutation" names captured locals a
|
|
1515
|
+
# first-class invalidation category. (The escaping / unknown path
|
|
1516
|
+
# already widened to Dynamic[top] via `record_closure_escape_if_any`.)
|
|
1517
|
+
post_scope = write_back_block_captures(node, post_scope)
|
|
1089
1518
|
post_scope = apply_rbs_extended_assertions(node, post_scope)
|
|
1090
1519
|
post_scope = apply_plugin_assertions(node, post_scope)
|
|
1091
1520
|
post_scope = apply_rspec_matcher_narrowing(node, post_scope)
|
|
@@ -1096,14 +1525,28 @@ module Rigor
|
|
|
1096
1525
|
# justification when the value is mutated. Always-safe
|
|
1097
1526
|
# (loses precision, never invents facts).
|
|
1098
1527
|
post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
|
|
1099
|
-
#
|
|
1100
|
-
#
|
|
1101
|
-
#
|
|
1102
|
-
#
|
|
1103
|
-
#
|
|
1104
|
-
#
|
|
1105
|
-
#
|
|
1528
|
+
# ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a
|
|
1529
|
+
# self-call resolves to a user method that CONTENT-mutates one of its
|
|
1530
|
+
# parameters inside an escaping block (the `build_option_parser(opts)`
|
|
1531
|
+
# idiom — the callee returns an `OptionParser` whose
|
|
1532
|
+
# `opts.on { o[:k] = v }` blocks close over the passed-in hash), floor
|
|
1533
|
+
# the matching caller-argument local. The callee's escape is invisible
|
|
1534
|
+
# across the boundary, so without this the caller's `options` keeps its
|
|
1535
|
+
# seed and `options.fetch(:mode)` folds to a wrong constant. Precise:
|
|
1536
|
+
# fires only when the resolved callee actually escape-mutates that
|
|
1537
|
+
# parameter (not for every self-call), and sound — only loses
|
|
1538
|
+
# precision on the floored argument.
|
|
1539
|
+
post_scope = widen_callee_escaped_argument_captures(node, post_scope)
|
|
1540
|
+
# Same always-safe rationale as `widen_after_call` above —
|
|
1541
|
+
# propagates outer-scope local / ivar widening from block body
|
|
1542
|
+
# mutations (`items.each { |x| arr << x }`).
|
|
1106
1543
|
post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
|
|
1544
|
+
# ADR-56 slice C — receiver-content element-type join. Joins
|
|
1545
|
+
# appended / stored element / key / value types into the
|
|
1546
|
+
# continuation collection so `out = [0]; arr.each { |x| out << x }`
|
|
1547
|
+
# types `Array[0 | Integer]`, not `Array[0]`. Same always-safe
|
|
1548
|
+
# rationale (only widens).
|
|
1549
|
+
post_scope = content_writeback_block_captures(node, post_scope)
|
|
1107
1550
|
# Indexed-collection narrowing — drop any
|
|
1108
1551
|
# `receiver[key] ||= default` narrowing the analyzer
|
|
1109
1552
|
# recorded earlier when an intervening `[]=` writes the
|
|
@@ -1130,9 +1573,47 @@ module Rigor
|
|
|
1130
1573
|
# new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
|
|
1131
1574
|
# § "Flow-folding" — G2 intervening-call case.
|
|
1132
1575
|
post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
|
|
1576
|
+
# C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are
|
|
1577
|
+
# narrowed to non-nil on a successful-match edge; a later call
|
|
1578
|
+
# that itself runs a regex match rebinds them, so the narrowed
|
|
1579
|
+
# facts must be dropped. We forget them only when the call is
|
|
1580
|
+
# match-CAPABLE (a regex-matching method, or an implicit-self /
|
|
1581
|
+
# unknown-receiver call whose body we cannot prove match-free).
|
|
1582
|
+
# A call provably match-free on a known receiver — `$3.to_i`,
|
|
1583
|
+
# `year < 50` — does NOT clobber, so the multi-statement
|
|
1584
|
+
# `m = /…/ =~ s; …; use($2)` stdlib idiom keeps its precision
|
|
1585
|
+
# while a genuinely interposed match still invalidates.
|
|
1586
|
+
post_scope = post_scope.forget_match_globals if match_capable_call?(node)
|
|
1133
1587
|
[call_type, post_scope]
|
|
1134
1588
|
end
|
|
1135
1589
|
|
|
1590
|
+
# Method names that (may) run a regex match and therefore rebind
|
|
1591
|
+
# the `$~` family. Conservative over-approximation — a few set
|
|
1592
|
+
# globals only with a Regexp argument, but we do not inspect args.
|
|
1593
|
+
MATCH_CAPABLE_METHODS = %i[
|
|
1594
|
+
=~ match match? gsub gsub! sub sub! scan split slice slice!
|
|
1595
|
+
[] partition rpartition index rindex === grep grep_v
|
|
1596
|
+
].freeze
|
|
1597
|
+
private_constant :MATCH_CAPABLE_METHODS
|
|
1598
|
+
|
|
1599
|
+
# True when `node` could rebind the regex match-data globals:
|
|
1600
|
+
# a known regex-matching method by name, or an implicit-self /
|
|
1601
|
+
# self-receiver call whose body we cannot inspect for an internal
|
|
1602
|
+
# match. An explicit-receiver call to a non-matching method
|
|
1603
|
+
# (`$3.to_i`, `year < 50`, `buf << c`) is treated as match-free so
|
|
1604
|
+
# the multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the
|
|
1605
|
+
# narrowed globals. The over-approximation is one-directional: a
|
|
1606
|
+
# user method that secretly matches on an explicit receiver is the
|
|
1607
|
+
# only escape, and re-narrowing on the next real guard recovers —
|
|
1608
|
+
# weighed against the false-positive cost, precision wins here.
|
|
1609
|
+
def match_capable_call?(node)
|
|
1610
|
+
return true unless node.is_a?(Prism::CallNode)
|
|
1611
|
+
return true if MATCH_CAPABLE_METHODS.include?(node.name)
|
|
1612
|
+
|
|
1613
|
+
receiver = node.receiver
|
|
1614
|
+
receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
1615
|
+
end
|
|
1616
|
+
|
|
1136
1617
|
# Returns a scope with each ivar's narrowed local binding
|
|
1137
1618
|
# widened back to its class-ivar seed value when the call
|
|
1138
1619
|
# is one that could plausibly mutate ivars on the enclosing
|
|
@@ -1255,7 +1736,7 @@ module Rigor
|
|
|
1255
1736
|
args = matcher.arguments&.arguments || []
|
|
1256
1737
|
return nil unless args.size == 1
|
|
1257
1738
|
|
|
1258
|
-
class_name =
|
|
1739
|
+
class_name = Source::ConstantPath.qualified_name_or_nil(args.first)
|
|
1259
1740
|
return nil if class_name.nil?
|
|
1260
1741
|
|
|
1261
1742
|
{ local: local_name, kind: :class, class_name: class_name, exact: exact }
|
|
@@ -1311,35 +1792,6 @@ module Rigor
|
|
|
1311
1792
|
matcher.arguments.nil? || matcher.arguments.arguments.empty?
|
|
1312
1793
|
end
|
|
1313
1794
|
|
|
1314
|
-
# Decodes a `Prism::ConstantReadNode` /
|
|
1315
|
-
# `Prism::ConstantPathNode` into a colon-joined class
|
|
1316
|
-
# name string, or returns nil for any other node
|
|
1317
|
-
# shape. Mirrors the conservative envelope used by the
|
|
1318
|
-
# `is_a?` / `kind_of?` predicate narrower.
|
|
1319
|
-
def constant_node_name(node)
|
|
1320
|
-
case node
|
|
1321
|
-
when Prism::ConstantReadNode
|
|
1322
|
-
node.name.to_s
|
|
1323
|
-
when Prism::ConstantPathNode
|
|
1324
|
-
flatten_constant_path(node)
|
|
1325
|
-
end
|
|
1326
|
-
end
|
|
1327
|
-
|
|
1328
|
-
def flatten_constant_path(node)
|
|
1329
|
-
parts = []
|
|
1330
|
-
cursor = node
|
|
1331
|
-
while cursor.is_a?(Prism::ConstantPathNode)
|
|
1332
|
-
parts.unshift(cursor.name.to_s)
|
|
1333
|
-
cursor = cursor.parent
|
|
1334
|
-
end
|
|
1335
|
-
case cursor
|
|
1336
|
-
when Prism::ConstantReadNode then parts.unshift(cursor.name.to_s)
|
|
1337
|
-
when nil then nil # ::Foo absolute root — preserve as-is
|
|
1338
|
-
else return nil
|
|
1339
|
-
end
|
|
1340
|
-
parts.join("::")
|
|
1341
|
-
end
|
|
1342
|
-
|
|
1343
1795
|
# Slice 4b-2 (ADR-7 § "Slice 4-A/4-B") — applies the
|
|
1344
1796
|
# post-return facts the merger produces for an
|
|
1345
1797
|
# `RBS::Extended`-annotated call. Reads through
|
|
@@ -1348,9 +1800,8 @@ module Rigor
|
|
|
1348
1800
|
# rows for `:always` assert directives (the slice-4a
|
|
1349
1801
|
# routing places conditional asserts on `truthy_facts` /
|
|
1350
1802
|
# `falsey_facts`, which `Narrowing.predicate_scopes`
|
|
1351
|
-
# consumes).
|
|
1352
|
-
#
|
|
1353
|
-
# the same merger and land here.
|
|
1803
|
+
# consumes). Plugin `:always` assertions are handled by
|
|
1804
|
+
# the sibling `apply_plugin_assertions`, not this path.
|
|
1354
1805
|
def apply_rbs_extended_assertions(call_node, current_scope)
|
|
1355
1806
|
method_def = resolve_call_method(call_node, current_scope)
|
|
1356
1807
|
return current_scope if method_def.nil?
|
|
@@ -1416,16 +1867,9 @@ module Rigor
|
|
|
1416
1867
|
EMPTY_CONTRIBUTIONS = [].freeze
|
|
1417
1868
|
private_constant :EMPTY_CONTRIBUTIONS
|
|
1418
1869
|
|
|
1419
|
-
#
|
|
1420
|
-
#
|
|
1421
|
-
#
|
|
1422
|
-
# (`for_statement` = declares a `type_specifier`), gate each path
|
|
1423
|
-
# by membership AND by the ADR-52 WD1 method-name gates (every
|
|
1424
|
-
# `type_specifier` rule is `methods:`-gated, so the common
|
|
1425
|
-
# no-candidate case is a single Set probe; a pruned
|
|
1426
|
-
# consultation could only have returned `[]`), and accumulate
|
|
1427
|
-
# lazily (shared frozen empty array otherwise). Same contributions in
|
|
1428
|
-
# the same order as visiting every plugin; the caller is read-only.
|
|
1870
|
+
# Fast-exit guard: skip if no plugin declares a `type_specifier`, or if
|
|
1871
|
+
# no registered method-name gate matches the call. See
|
|
1872
|
+
# `collect_gated_statement_contributions` for the full consultation.
|
|
1429
1873
|
def collect_plugin_contributions(registry, call_node, current_scope)
|
|
1430
1874
|
index = registry.contribution_index
|
|
1431
1875
|
relevant = index.for_statement
|
|
@@ -1437,8 +1881,10 @@ module Rigor
|
|
|
1437
1881
|
collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
|
|
1438
1882
|
end
|
|
1439
1883
|
|
|
1440
|
-
#
|
|
1441
|
-
#
|
|
1884
|
+
# ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order.
|
|
1885
|
+
# Visits only plugins in `for_statement` (declare a `type_specifier`),
|
|
1886
|
+
# further gated by the method-name Set probe so the common no-candidate
|
|
1887
|
+
# case is a single lookup. Accumulates lazily; caller is read-only.
|
|
1442
1888
|
def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
|
|
1443
1889
|
result = nil
|
|
1444
1890
|
relevant.each do |plugin|
|
|
@@ -1606,12 +2052,25 @@ module Rigor
|
|
|
1606
2052
|
# A `:non_escaping` classification (or any block-less call)
|
|
1607
2053
|
# leaves the post-call scope unchanged.
|
|
1608
2054
|
def record_closure_escape_if_any(node)
|
|
1609
|
-
|
|
2055
|
+
# ADR-57 slice 3 work-item 1: an escaping block can also be attached
|
|
2056
|
+
# to a RECEIVER call in a chain rather than to `node` itself — the
|
|
2057
|
+
# canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end
|
|
2058
|
+
# .parse!(argv)` idiom, where the content-mutating block hangs off
|
|
2059
|
+
# `OptionParser.new` but the statement-level call node is the chained
|
|
2060
|
+
# `.parse!`. A receiver call is evaluated as an expression, never as a
|
|
2061
|
+
# statement, so its block never reaches this escape handler on its own.
|
|
2062
|
+
# Fold each escaping receiver-chain block's content widening into the
|
|
2063
|
+
# continuation here so the captured collection is floored regardless of
|
|
2064
|
+
# how deep in the receiver chain its mutating block lives.
|
|
2065
|
+
post_scope = widen_escaping_receiver_chain_captures(node, scope)
|
|
2066
|
+
|
|
2067
|
+
return post_scope unless node.block.is_a?(Prism::BlockNode)
|
|
1610
2068
|
|
|
1611
2069
|
classification = classify_closure_escape(node)
|
|
1612
|
-
return
|
|
2070
|
+
return post_scope if classification == :non_escaping
|
|
1613
2071
|
|
|
1614
|
-
post_scope = drop_captured_narrowing(node.block,
|
|
2072
|
+
post_scope = drop_captured_narrowing(node.block, post_scope)
|
|
2073
|
+
post_scope = widen_escaping_content_captures(node.block, post_scope)
|
|
1615
2074
|
post_scope.with_fact(
|
|
1616
2075
|
Analysis::FactStore::Fact.new(
|
|
1617
2076
|
bucket: :dynamic_origin,
|
|
@@ -1623,6 +2082,224 @@ module Rigor
|
|
|
1623
2082
|
)
|
|
1624
2083
|
end
|
|
1625
2084
|
|
|
2085
|
+
# Floor each caller-argument local whose matching parameter the resolved
|
|
2086
|
+
# callee escape-mutates (see the call-site comment). Only self-dispatch
|
|
2087
|
+
# calls resolving to a discovered user def are considered; the per-def
|
|
2088
|
+
# "which parameters escape-mutate" set is memoised on the def node.
|
|
2089
|
+
def widen_callee_escaped_argument_captures(node, base_scope)
|
|
2090
|
+
# Apply to the statement call AND every call in its receiver chain: the
|
|
2091
|
+
# `build_option_parser(options).parse!(argv)` idiom puts the escape-
|
|
2092
|
+
# mutating helper call in the RECEIVER position, where its argument is
|
|
2093
|
+
# never the statement node's own argument.
|
|
2094
|
+
acc = floor_callee_escaped_args_for_call(node, base_scope)
|
|
2095
|
+
receiver = node.receiver
|
|
2096
|
+
while receiver.is_a?(Prism::CallNode)
|
|
2097
|
+
acc = floor_callee_escaped_args_for_call(receiver, acc)
|
|
2098
|
+
receiver = receiver.receiver
|
|
2099
|
+
end
|
|
2100
|
+
acc
|
|
2101
|
+
end
|
|
2102
|
+
|
|
2103
|
+
def floor_callee_escaped_args_for_call(node, base_scope)
|
|
2104
|
+
return base_scope unless self_dispatch_call?(node)
|
|
2105
|
+
# Fast path — the floor only ever touches a local passed as an
|
|
2106
|
+
# argument, so a call with no arguments cannot floor anything. Skip the
|
|
2107
|
+
# def resolution + body scan entirely (the overwhelming common case).
|
|
2108
|
+
return base_scope unless call_passes_local_argument?(node)
|
|
2109
|
+
|
|
2110
|
+
def_node = resolve_self_callee_def(node)
|
|
2111
|
+
return base_scope if def_node.nil?
|
|
2112
|
+
|
|
2113
|
+
escaped = escaped_content_parameters(def_node)
|
|
2114
|
+
return base_scope if escaped.empty?
|
|
2115
|
+
|
|
2116
|
+
floor_arguments_at_positions(node, escaped, base_scope)
|
|
2117
|
+
end
|
|
2118
|
+
|
|
2119
|
+
# The user def a self-dispatch `node` resolves to in the enclosing class,
|
|
2120
|
+
# or nil. Reuses the discovery index `Scope#user_def_for` reads; no
|
|
2121
|
+
# ancestor walk (the boundary-escape idiom is same-class), keeping this
|
|
2122
|
+
# off the hot path for the overwhelming majority of self-calls that
|
|
2123
|
+
# resolve to nothing escaping.
|
|
2124
|
+
def resolve_self_callee_def(node)
|
|
2125
|
+
class_name = enclosing_class_name_for(scope.self_type)
|
|
2126
|
+
return scope.top_level_def_for(node.name) if class_name.nil?
|
|
2127
|
+
|
|
2128
|
+
scope.user_def_for(class_name, node.name)
|
|
2129
|
+
end
|
|
2130
|
+
|
|
2131
|
+
def self_dispatch_call?(node)
|
|
2132
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
2133
|
+
|
|
2134
|
+
node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
2135
|
+
end
|
|
2136
|
+
|
|
2137
|
+
# The set of `[name, position]` parameters of `def_node` whose content a
|
|
2138
|
+
# block in the body escape-mutates. Memoised per def node (the body walk
|
|
2139
|
+
# is otherwise repeated at every call site). A parameter is "escape-
|
|
2140
|
+
# mutated" when a `param[k] = v` / `param << x` mutation on it appears
|
|
2141
|
+
# inside a block whose receiving call is not proven non-escaping.
|
|
2142
|
+
def escaped_content_parameters(def_node)
|
|
2143
|
+
cache = (@escaped_param_cache ||= {}.compare_by_identity)
|
|
2144
|
+
cache[def_node] ||= compute_escaped_content_parameters(def_node)
|
|
2145
|
+
end
|
|
2146
|
+
|
|
2147
|
+
def compute_escaped_content_parameters(def_node)
|
|
2148
|
+
positions = positional_parameter_positions(def_node)
|
|
2149
|
+
return {} if positions.empty?
|
|
2150
|
+
|
|
2151
|
+
mutated = Set.new
|
|
2152
|
+
Source::NodeWalker.each(def_node.body) do |descendant|
|
|
2153
|
+
next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
|
|
2154
|
+
next if syntactically_non_escaping_call?(descendant)
|
|
2155
|
+
|
|
2156
|
+
collect_content_mutations(descendant.block.body).each_key do |name|
|
|
2157
|
+
mutated << name if positions.key?(name)
|
|
2158
|
+
end
|
|
2159
|
+
end
|
|
2160
|
+
positions.slice(*mutated)
|
|
2161
|
+
end
|
|
2162
|
+
|
|
2163
|
+
# A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s
|
|
2164
|
+
# non-escaping verdict, used when scanning a callee body where the block-
|
|
2165
|
+
# owning call's receiver TYPE is not available. A call whose method name
|
|
2166
|
+
# is a known structural iterator (`each` / `map` / `tap` / …) runs its
|
|
2167
|
+
# block synchronously and does not retain it, so its captured mutations
|
|
2168
|
+
# are not a cross-boundary escape. Any other name (`on`, `subscribe`,
|
|
2169
|
+
# `define_method`, an unknown DSL hook) is treated as escaping — sound,
|
|
2170
|
+
# since mis-classifying a truly-non-escaping call only floors an argument
|
|
2171
|
+
# that was about to be precise.
|
|
2172
|
+
SYNTACTIC_NON_ESCAPING_BLOCK_METHODS = (
|
|
2173
|
+
ClosureEscapeAnalyzer::ENUMERABLE_NON_ESCAPING +
|
|
2174
|
+
ClosureEscapeAnalyzer::OBJECT_NON_ESCAPING +
|
|
2175
|
+
ClosureEscapeAnalyzer::ARRAY_EXTRA +
|
|
2176
|
+
ClosureEscapeAnalyzer::HASH_EXTRA +
|
|
2177
|
+
ClosureEscapeAnalyzer::RANGE_EXTRA +
|
|
2178
|
+
ClosureEscapeAnalyzer::INTEGER_EXTRA
|
|
2179
|
+
).to_set.freeze
|
|
2180
|
+
private_constant :SYNTACTIC_NON_ESCAPING_BLOCK_METHODS
|
|
2181
|
+
|
|
2182
|
+
def syntactically_non_escaping_call?(call_node)
|
|
2183
|
+
SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
|
|
2184
|
+
end
|
|
2185
|
+
|
|
2186
|
+
# `{ name => position }` for the required / optional positional
|
|
2187
|
+
# parameters of a def. Keyword / rest / block parameters are skipped —
|
|
2188
|
+
# the boundary-escape idiom passes a plain positional collection.
|
|
2189
|
+
def positional_parameter_positions(def_node)
|
|
2190
|
+
params = def_node.parameters
|
|
2191
|
+
return {} if params.nil?
|
|
2192
|
+
|
|
2193
|
+
ordered = (params.requireds || []) + (params.optionals || [])
|
|
2194
|
+
positions = {}
|
|
2195
|
+
ordered.each_with_index do |param, index|
|
|
2196
|
+
positions[param.name] = index if param.respond_to?(:name)
|
|
2197
|
+
end
|
|
2198
|
+
positions
|
|
2199
|
+
end
|
|
2200
|
+
|
|
2201
|
+
# True when at least one argument of `node` is a bare local-variable read
|
|
2202
|
+
# (positional or keyword value) bound in the current scope — a cheap
|
|
2203
|
+
# pre-filter so the def resolution / body scan only runs for calls that
|
|
2204
|
+
# could actually floor something.
|
|
2205
|
+
def call_passes_local_argument?(node)
|
|
2206
|
+
args = node.arguments
|
|
2207
|
+
return false unless args.respond_to?(:arguments)
|
|
2208
|
+
|
|
2209
|
+
args.arguments.any? do |arg|
|
|
2210
|
+
case arg
|
|
2211
|
+
when Prism::LocalVariableReadNode
|
|
2212
|
+
scope.locals.key?(arg.name)
|
|
2213
|
+
when Prism::KeywordHashNode
|
|
2214
|
+
arg.elements.any? do |pair|
|
|
2215
|
+
pair.is_a?(Prism::AssocNode) &&
|
|
2216
|
+
pair.value.is_a?(Prism::LocalVariableReadNode) &&
|
|
2217
|
+
scope.locals.key?(pair.value.name)
|
|
2218
|
+
end
|
|
2219
|
+
else
|
|
2220
|
+
false
|
|
2221
|
+
end
|
|
2222
|
+
end
|
|
2223
|
+
end
|
|
2224
|
+
|
|
2225
|
+
def floor_arguments_at_positions(node, positions, base_scope)
|
|
2226
|
+
args = node.arguments
|
|
2227
|
+
return base_scope unless args.respond_to?(:arguments)
|
|
2228
|
+
|
|
2229
|
+
argument_nodes = args.arguments
|
|
2230
|
+
positions.values.uniq.reduce(base_scope) do |acc, index|
|
|
2231
|
+
arg = argument_nodes[index]
|
|
2232
|
+
next acc unless arg.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(arg.name)
|
|
2233
|
+
|
|
2234
|
+
floored = content_floor_for(acc.local(arg.name))
|
|
2235
|
+
floored.nil? ? acc : acc.with_local(arg.name, floored)
|
|
2236
|
+
end
|
|
2237
|
+
end
|
|
2238
|
+
|
|
2239
|
+
# Walk the receiver chain of `node` and fold the escaping-content
|
|
2240
|
+
# widening of every block-bearing, escaping receiver call into
|
|
2241
|
+
# `base_scope`. Only receiver calls are walked — `node` itself is handled
|
|
2242
|
+
# by the caller. A `:non_escaping` receiver block is left to slice C's
|
|
2243
|
+
# non-escaping write-back (which the receiver expression evaluation
|
|
2244
|
+
# already drives), so we only floor the escaping / unknown ones here.
|
|
2245
|
+
def widen_escaping_receiver_chain_captures(node, base_scope)
|
|
2246
|
+
receiver = node.receiver
|
|
2247
|
+
acc = base_scope
|
|
2248
|
+
while receiver.is_a?(Prism::CallNode)
|
|
2249
|
+
if receiver.block.is_a?(Prism::BlockNode) &&
|
|
2250
|
+
classify_closure_escape(receiver) != :non_escaping
|
|
2251
|
+
acc = widen_escaping_content_captures(receiver.block, acc)
|
|
2252
|
+
end
|
|
2253
|
+
receiver = receiver.receiver
|
|
2254
|
+
end
|
|
2255
|
+
acc
|
|
2256
|
+
end
|
|
2257
|
+
|
|
2258
|
+
# ADR-57 slice 2 (ADR-56 mechanisms 2 / 8 extended to escaping blocks).
|
|
2259
|
+
# An escaping / unknown block that CONTENT-mutates a captured outer
|
|
2260
|
+
# local (`options[:k] = v` in an `OptionParser#on` block, `s << x` in a
|
|
2261
|
+
# stored proc) previously left that local's content untouched — only its
|
|
2262
|
+
# narrowing was dropped, so a constant seed (`options = {}`, `s = ""`)
|
|
2263
|
+
# survived and its element fold (`options[:format]` -> `"text"`,
|
|
2264
|
+
# `s.empty?` -> `true`) was unsoundly precise.
|
|
2265
|
+
#
|
|
2266
|
+
# An escaping block may run later and any number of times, so joining a
|
|
2267
|
+
# bounded evidence set is not sound (unlike slice C's non-escaping
|
|
2268
|
+
# join): the sound continuation is the bare-collection floor — Array ->
|
|
2269
|
+
# `Array[Dynamic[top]]`, Hash -> `Hash[untyped, untyped]`, String ->
|
|
2270
|
+
# `String`. The seed's element/key/value precision is forgotten; only
|
|
2271
|
+
# the carrier survives. Read-only captures and locals the block merely
|
|
2272
|
+
# rebinds (already floored by `drop_captured_narrowing`) are untouched.
|
|
2273
|
+
def widen_escaping_content_captures(block_node, post_scope)
|
|
2274
|
+
body = block_node.body
|
|
2275
|
+
return post_scope if body.nil?
|
|
2276
|
+
|
|
2277
|
+
mutations = collect_content_mutations(body)
|
|
2278
|
+
return post_scope if mutations.empty?
|
|
2279
|
+
|
|
2280
|
+
mutations.keys.reduce(post_scope) do |acc, name|
|
|
2281
|
+
floored = content_floor_for(acc.local(name))
|
|
2282
|
+
floored.nil? ? acc : acc.with_local(name, floored)
|
|
2283
|
+
end
|
|
2284
|
+
end
|
|
2285
|
+
|
|
2286
|
+
# The Dynamic-floor carrier for a content-mutated escaping capture, or
|
|
2287
|
+
# nil when the pre-state is not a recognised mutable collection (leave
|
|
2288
|
+
# it alone — e.g. an already-`Dynamic` binding or an unknown shape).
|
|
2289
|
+
def content_floor_for(type)
|
|
2290
|
+
return nil if type.nil?
|
|
2291
|
+
|
|
2292
|
+
if stringish?(type)
|
|
2293
|
+
Type::Combinator.nominal_of("String")
|
|
2294
|
+
elsif hashish?(type)
|
|
2295
|
+
Type::Combinator.nominal_of("Hash",
|
|
2296
|
+
type_args: [Type::Combinator.untyped,
|
|
2297
|
+
Type::Combinator.untyped])
|
|
2298
|
+
elsif arrayish?(type)
|
|
2299
|
+
Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
|
|
2300
|
+
end
|
|
2301
|
+
end
|
|
2302
|
+
|
|
1626
2303
|
def classify_closure_escape(call_node)
|
|
1627
2304
|
receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
|
|
1628
2305
|
ClosureEscapeAnalyzer.classify(
|
|
@@ -1649,6 +2326,22 @@ module Rigor
|
|
|
1649
2326
|
names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
|
|
1650
2327
|
end
|
|
1651
2328
|
|
|
2329
|
+
# Names of outer locals the block body can REBIND, across every
|
|
2330
|
+
# local-write form: plain `=` (`LocalVariableWriteNode`), the
|
|
2331
|
+
# operator / `||=` / `&&=` compound forms, and a multi-assign target
|
|
2332
|
+
# (`x, y = ...` → `LocalVariableTargetNode` under `MultiWriteNode`).
|
|
2333
|
+
# Block-introduced names (parameters, numbered params, `;`-locals) and
|
|
2334
|
+
# names not bound in the outer scope are excluded — a write to either
|
|
2335
|
+
# is not a captured rebind of an outer variable.
|
|
2336
|
+
LOCAL_WRITE_NODES = [
|
|
2337
|
+
Prism::LocalVariableWriteNode,
|
|
2338
|
+
Prism::LocalVariableOperatorWriteNode,
|
|
2339
|
+
Prism::LocalVariableOrWriteNode,
|
|
2340
|
+
Prism::LocalVariableAndWriteNode,
|
|
2341
|
+
Prism::LocalVariableTargetNode
|
|
2342
|
+
].freeze
|
|
2343
|
+
private_constant :LOCAL_WRITE_NODES
|
|
2344
|
+
|
|
1652
2345
|
def captured_local_writes(block_node, base_scope)
|
|
1653
2346
|
body = block_node.body
|
|
1654
2347
|
return [] if body.nil?
|
|
@@ -1656,7 +2349,7 @@ module Rigor
|
|
|
1656
2349
|
introduced = block_introduced_locals(block_node)
|
|
1657
2350
|
outer_writes = []
|
|
1658
2351
|
Source::NodeWalker.each(body) do |descendant|
|
|
1659
|
-
next unless descendant.is_a?(
|
|
2352
|
+
next unless LOCAL_WRITE_NODES.any? { |klass| descendant.is_a?(klass) }
|
|
1660
2353
|
next if introduced.include?(descendant.name)
|
|
1661
2354
|
next unless base_scope.locals.key?(descendant.name)
|
|
1662
2355
|
|
|
@@ -1665,6 +2358,313 @@ module Rigor
|
|
|
1665
2358
|
outer_writes.uniq
|
|
1666
2359
|
end
|
|
1667
2360
|
|
|
2361
|
+
# ADR-56 slice A. For a `:non_escaping` block, fold the continuation
|
|
2362
|
+
# binding of every outer local the body can rebind back into
|
|
2363
|
+
# `post_scope`. The binding is a capped fixpoint (cap 3) over the
|
|
2364
|
+
# block body re-evaluated under the running per-name assumption,
|
|
2365
|
+
# joined with the pre-call binding (kept as a constituent so the
|
|
2366
|
+
# 0-iteration path — `[].each { … }` — stays sound), value-pinned-
|
|
2367
|
+
# widened on the final permitted iteration, and floored to
|
|
2368
|
+
# `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
|
|
2369
|
+
#
|
|
2370
|
+
# Fast path: a block writing no outer local leaves `post_scope`
|
|
2371
|
+
# byte-identical (the overwhelming majority of blocks), so this costs
|
|
2372
|
+
# one extra `captured_local_writes` walk and nothing else.
|
|
2373
|
+
def write_back_block_captures(call_node, post_scope)
|
|
2374
|
+
block = call_node.block
|
|
2375
|
+
return post_scope unless block.is_a?(Prism::BlockNode)
|
|
2376
|
+
return post_scope unless classify_closure_escape(call_node) == :non_escaping
|
|
2377
|
+
|
|
2378
|
+
names = captured_local_writes(block, scope)
|
|
2379
|
+
return post_scope if names.empty?
|
|
2380
|
+
|
|
2381
|
+
seed = names.to_h { |name| [name, scope.local(name)] }
|
|
2382
|
+
result = BodyFixpoint.converge(
|
|
2383
|
+
names: names,
|
|
2384
|
+
seed_bindings: seed,
|
|
2385
|
+
widen: Type::Combinator.method(:widen_value_pinned),
|
|
2386
|
+
evaluate_body: ->(bindings) { block_exit_bindings(call_node, block, bindings, names) }
|
|
2387
|
+
)
|
|
2388
|
+
|
|
2389
|
+
result.reduce(post_scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
2390
|
+
end
|
|
2391
|
+
|
|
2392
|
+
# ADR-56 slice C — receiver-content element-type join. After the
|
|
2393
|
+
# rebind write-back and `MutationWidening.widen_after_block` (which
|
|
2394
|
+
# forgets a content-mutated collection's literal arity but keeps only
|
|
2395
|
+
# the SEED's element types), join the appended/stored element / key /
|
|
2396
|
+
# value types INTO the continuation collection's parameter, so
|
|
2397
|
+
# `out = [0]; arr.each { |x| out << x }` types `out` as
|
|
2398
|
+
# `Array[0 | Integer]` (sound) rather than `Array[0]` (the B1
|
|
2399
|
+
# under-approximation: the runtime array is `[0, 1, 2, 3]`).
|
|
2400
|
+
#
|
|
2401
|
+
# Pre-state is read from `post_scope` so a local that is BOTH rebound
|
|
2402
|
+
# and content-mutated composes: the rebind fixpoint result feeds the
|
|
2403
|
+
# content join. The block body is typed once for argument evidence;
|
|
2404
|
+
# the floor is `Array[Dynamic[top]]` / `Hash[untyped, untyped]` (the
|
|
2405
|
+
# sound empty-seed behaviour). Always sound — only ever widens.
|
|
2406
|
+
def content_writeback_block_captures(call_node, post_scope)
|
|
2407
|
+
block = call_node.block
|
|
2408
|
+
return post_scope unless block.is_a?(Prism::BlockNode)
|
|
2409
|
+
return post_scope unless classify_closure_escape(call_node) == :non_escaping
|
|
2410
|
+
|
|
2411
|
+
body = block.body
|
|
2412
|
+
return post_scope if body.nil?
|
|
2413
|
+
|
|
2414
|
+
mutations = collect_content_mutations(body)
|
|
2415
|
+
return post_scope if mutations.empty?
|
|
2416
|
+
|
|
2417
|
+
entry = build_block_entry_scope(call_node, block)
|
|
2418
|
+
mutations.reduce(post_scope) do |acc, (name, calls)|
|
|
2419
|
+
joined = join_content_for_local(name, calls, acc, entry)
|
|
2420
|
+
joined.nil? ? acc : acc.with_local(name, joined)
|
|
2421
|
+
end
|
|
2422
|
+
end
|
|
2423
|
+
|
|
2424
|
+
# ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }`
|
|
2425
|
+
# the return is the memo object after the block has mutated it through
|
|
2426
|
+
# the `acc` alias. Compute the joined memo type the same way captured-
|
|
2427
|
+
# local content mutations are joined: pre-state = the memo argument's
|
|
2428
|
+
# type, added evidence = the content-mutator args on the memo block
|
|
2429
|
+
# param. Returns `call_type` unchanged for any other call, a missing
|
|
2430
|
+
# block, or a memo whose pre-state is not a collection.
|
|
2431
|
+
def each_with_object_return(call_node, call_type)
|
|
2432
|
+
return call_type unless call_node.name == :each_with_object
|
|
2433
|
+
|
|
2434
|
+
block = call_node.block
|
|
2435
|
+
return call_type unless block.is_a?(Prism::BlockNode)
|
|
2436
|
+
|
|
2437
|
+
memo_arg = call_node.arguments&.arguments&.first
|
|
2438
|
+
return call_type if memo_arg.nil?
|
|
2439
|
+
|
|
2440
|
+
memo_param = each_with_object_memo_param(block)
|
|
2441
|
+
return call_type if memo_param.nil?
|
|
2442
|
+
|
|
2443
|
+
body = block.body
|
|
2444
|
+
return call_type if body.nil?
|
|
2445
|
+
|
|
2446
|
+
# The memo alias is a block-local (depth 0) — collect content
|
|
2447
|
+
# mutations on it directly rather than via the captured-local walk.
|
|
2448
|
+
calls = body_content_mutations_on(body, memo_param)
|
|
2449
|
+
return call_type if calls.empty?
|
|
2450
|
+
|
|
2451
|
+
pre_state = scope.type_of(memo_arg, tracer: tracer)
|
|
2452
|
+
entry = build_block_entry_scope(call_node, block)
|
|
2453
|
+
joined = join_content_for_param(calls, pre_state, entry)
|
|
2454
|
+
joined || call_type
|
|
2455
|
+
end
|
|
2456
|
+
|
|
2457
|
+
# The name of the memo block parameter (the SECOND positional param of
|
|
2458
|
+
# an `each_with_object` block), or nil when the block does not bind a
|
|
2459
|
+
# second positional param.
|
|
2460
|
+
def each_with_object_memo_param(block)
|
|
2461
|
+
params_root = block.parameters
|
|
2462
|
+
return nil unless params_root.is_a?(Prism::BlockParametersNode)
|
|
2463
|
+
|
|
2464
|
+
params = params_root.parameters
|
|
2465
|
+
return nil if params.nil?
|
|
2466
|
+
|
|
2467
|
+
requireds = params.requireds
|
|
2468
|
+
return nil if requireds.size < 2
|
|
2469
|
+
|
|
2470
|
+
second = requireds[1]
|
|
2471
|
+
second.respond_to?(:name) ? second.name : nil
|
|
2472
|
+
end
|
|
2473
|
+
|
|
2474
|
+
# Content-mutator calls on a block-local receiver `var_name`
|
|
2475
|
+
# (depth 0) within `body`.
|
|
2476
|
+
def body_content_mutations_on(body, var_name)
|
|
2477
|
+
calls = []
|
|
2478
|
+
Source::NodeWalker.each(body) do |descendant|
|
|
2479
|
+
next unless descendant.is_a?(Prism::CallNode)
|
|
2480
|
+
next unless MutationWidening::CONTENT_ADDERS.include?(descendant.name)
|
|
2481
|
+
|
|
2482
|
+
receiver = descendant.receiver
|
|
2483
|
+
next unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
2484
|
+
next unless receiver.name == var_name
|
|
2485
|
+
|
|
2486
|
+
calls << descendant
|
|
2487
|
+
end
|
|
2488
|
+
calls
|
|
2489
|
+
end
|
|
2490
|
+
|
|
2491
|
+
# Joins content evidence for a memo / param given its pre-state and a
|
|
2492
|
+
# list of mutator calls, dispatching Array vs Hash by the mutator set.
|
|
2493
|
+
def join_content_for_param(calls, pre_state, block_entry)
|
|
2494
|
+
return nil if pre_state.nil?
|
|
2495
|
+
|
|
2496
|
+
if stringish?(pre_state)
|
|
2497
|
+
# String carries no element parameter; mutating `<<`/`concat`
|
|
2498
|
+
# makes the constant value unsound (`s = "a"; s << x` → runtime
|
|
2499
|
+
# `"a…"`), so widen to the nominal base. Sound — only widens.
|
|
2500
|
+
Type::Combinator.nominal_of("String")
|
|
2501
|
+
elsif hashish?(pre_state) || (hash_mutations?(calls) && !arrayish?(pre_state))
|
|
2502
|
+
join_hash_param(calls, pre_state, block_entry)
|
|
2503
|
+
else
|
|
2504
|
+
join_array_param(calls, pre_state, block_entry)
|
|
2505
|
+
end
|
|
2506
|
+
end
|
|
2507
|
+
|
|
2508
|
+
def join_hash_param(calls, pre_state, block_entry)
|
|
2509
|
+
pairs = calls.flat_map { |c| hash_pair_types(c, block_entry) }
|
|
2510
|
+
return nil if pairs.empty? && !hashish?(pre_state)
|
|
2511
|
+
|
|
2512
|
+
MutationWidening.join_hash_content(pre_state, pairs)
|
|
2513
|
+
end
|
|
2514
|
+
|
|
2515
|
+
def join_array_param(calls, pre_state, block_entry)
|
|
2516
|
+
return nil unless arrayish?(pre_state)
|
|
2517
|
+
|
|
2518
|
+
added = calls.flat_map do |c|
|
|
2519
|
+
# Index-write on an array (`a[i] += v`) introduces no new element
|
|
2520
|
+
# evidence we can cheaply attribute — the array-arity forget
|
|
2521
|
+
# already widened the binding; contribute nothing.
|
|
2522
|
+
next [] if index_write?(c)
|
|
2523
|
+
|
|
2524
|
+
MutationWidening.array_added_elements(c.name, content_arg_types(c, block_entry))
|
|
2525
|
+
end
|
|
2526
|
+
MutationWidening.join_array_content(pre_state, added)
|
|
2527
|
+
end
|
|
2528
|
+
|
|
2529
|
+
# Walks the block body for content-mutator calls (`<<`, `push`,
|
|
2530
|
+
# `[]=`, …) whose receiver is a captured outer local (depth >= 1),
|
|
2531
|
+
# returning `{ name => [call_node, ...] }`. Mirrors the
|
|
2532
|
+
# `MutationWidening.widen_after_block` walk (descends into nested
|
|
2533
|
+
# blocks; the depth check keeps nested block-locals out).
|
|
2534
|
+
def collect_content_mutations(body)
|
|
2535
|
+
mutations = Hash.new { |h, k| h[k] = [] }
|
|
2536
|
+
Source::NodeWalker.each(body) do |descendant|
|
|
2537
|
+
name, node = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.positive? }
|
|
2538
|
+
mutations[name] << node unless name.nil?
|
|
2539
|
+
end
|
|
2540
|
+
mutations
|
|
2541
|
+
end
|
|
2542
|
+
|
|
2543
|
+
# Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v` via a
|
|
2544
|
+
# multi-assign target) that mutate a collection's CONTENT without a
|
|
2545
|
+
# `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the
|
|
2546
|
+
# OrWrite even though the appended values land on the nested array —
|
|
2547
|
+
# leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
|
|
2548
|
+
INDEX_WRITE_NODES = [
|
|
2549
|
+
Prism::IndexOrWriteNode,
|
|
2550
|
+
Prism::IndexAndWriteNode,
|
|
2551
|
+
Prism::IndexOperatorWriteNode,
|
|
2552
|
+
Prism::IndexTargetNode
|
|
2553
|
+
].freeze
|
|
2554
|
+
private_constant :INDEX_WRITE_NODES
|
|
2555
|
+
|
|
2556
|
+
# `[receiver_name, node]` when `node` is a content mutation whose
|
|
2557
|
+
# receiver is a local variable satisfying `accept` (depth predicate),
|
|
2558
|
+
# else `[nil, nil]`. Covers `[]=`-style CallNode mutators and the
|
|
2559
|
+
# index-write node forms.
|
|
2560
|
+
def content_mutation_target(node)
|
|
2561
|
+
is_call_mutator = node.is_a?(Prism::CallNode) && MutationWidening::CONTENT_ADDERS.include?(node.name)
|
|
2562
|
+
return [nil, nil] unless is_call_mutator || index_write?(node)
|
|
2563
|
+
|
|
2564
|
+
receiver = node.receiver
|
|
2565
|
+
return [nil, nil] unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
2566
|
+
return [nil, nil] unless yield(receiver)
|
|
2567
|
+
|
|
2568
|
+
[receiver.name, node]
|
|
2569
|
+
end
|
|
2570
|
+
|
|
2571
|
+
# Computes the joined continuation collection type for one captured
|
|
2572
|
+
# local from its content-mutator calls. Returns `nil` (no overlay)
|
|
2573
|
+
# when the pre-state is neither an Array-ish nor a Hash-ish binding —
|
|
2574
|
+
# e.g. a String accumulator, whose `<<` carries no element parameter
|
|
2575
|
+
# and whose binding already types as `String`.
|
|
2576
|
+
def join_content_for_local(name, calls, post_scope, block_entry)
|
|
2577
|
+
join_content_for_param(calls, post_scope.local(name), block_entry)
|
|
2578
|
+
end
|
|
2579
|
+
|
|
2580
|
+
def hash_mutations?(calls)
|
|
2581
|
+
calls.any? do |c|
|
|
2582
|
+
index_write?(c) || (c.is_a?(Prism::CallNode) && MutationWidening::HASH_CONTENT_ADDERS.include?(c.name))
|
|
2583
|
+
end
|
|
2584
|
+
end
|
|
2585
|
+
|
|
2586
|
+
def index_write?(node)
|
|
2587
|
+
INDEX_WRITE_NODES.any? { |k| node.is_a?(k) }
|
|
2588
|
+
end
|
|
2589
|
+
|
|
2590
|
+
def arrayish?(type)
|
|
2591
|
+
case type
|
|
2592
|
+
when Type::Tuple then true
|
|
2593
|
+
when Type::Nominal then type.class_name == "Array"
|
|
2594
|
+
when Type::Union then type.members.any? { |m| arrayish?(m) }
|
|
2595
|
+
else false
|
|
2596
|
+
end
|
|
2597
|
+
end
|
|
2598
|
+
|
|
2599
|
+
def hashish?(type)
|
|
2600
|
+
case type
|
|
2601
|
+
when Type::HashShape then true
|
|
2602
|
+
when Type::Nominal then type.class_name == "Hash"
|
|
2603
|
+
when Type::Union then type.members.any? { |m| hashish?(m) }
|
|
2604
|
+
else false
|
|
2605
|
+
end
|
|
2606
|
+
end
|
|
2607
|
+
|
|
2608
|
+
def stringish?(type)
|
|
2609
|
+
(type.is_a?(Type::Constant) && type.value.is_a?(String)) ||
|
|
2610
|
+
(type.is_a?(Type::Nominal) && type.class_name == "String")
|
|
2611
|
+
end
|
|
2612
|
+
|
|
2613
|
+
# `[key_type, value_type]` for a `h[k] = v` / `h.store(k, v)` call or
|
|
2614
|
+
# an index-write node (`h[k] ||= v`), typed in the block-entry scope.
|
|
2615
|
+
# For an index-write the stored value is opaque (the appended values
|
|
2616
|
+
# often land on a NESTED collection via `h[k] << v`), so the value is
|
|
2617
|
+
# floored to `untyped` — sound: it only ever widens the value param.
|
|
2618
|
+
# Returns `[]` for other forms.
|
|
2619
|
+
def hash_pair_types(node, block_entry)
|
|
2620
|
+
if index_write?(node)
|
|
2621
|
+
key = index_key_type(node, block_entry)
|
|
2622
|
+
return [] if key.nil?
|
|
2623
|
+
|
|
2624
|
+
return [[key, Type::Combinator.untyped]]
|
|
2625
|
+
end
|
|
2626
|
+
|
|
2627
|
+
args = content_arg_types(node, block_entry)
|
|
2628
|
+
return [] if args.size < 2
|
|
2629
|
+
|
|
2630
|
+
[[args.first, args.last]]
|
|
2631
|
+
end
|
|
2632
|
+
|
|
2633
|
+
# Type of the index expression of an index-write node (`h[k] ||= v`).
|
|
2634
|
+
def index_key_type(node, block_entry)
|
|
2635
|
+
args = node.arguments
|
|
2636
|
+
return nil unless args.is_a?(Prism::ArgumentsNode)
|
|
2637
|
+
|
|
2638
|
+
first = args.arguments.first
|
|
2639
|
+
first.nil? ? nil : block_entry.type_of(first, tracer: tracer)
|
|
2640
|
+
rescue StandardError
|
|
2641
|
+
nil
|
|
2642
|
+
end
|
|
2643
|
+
|
|
2644
|
+
# Argument types for a content-mutator call, typed against the
|
|
2645
|
+
# block-entry scope (block params bound). A sub-evaluator over
|
|
2646
|
+
# `block_entry` keeps the argument typing flow-correct for params /
|
|
2647
|
+
# `;`-locals without leaking into the outer scope.
|
|
2648
|
+
def content_arg_types(call_node, block_entry)
|
|
2649
|
+
arguments = call_node.arguments
|
|
2650
|
+
return [] if arguments.nil?
|
|
2651
|
+
|
|
2652
|
+
arguments.arguments.map { |arg| block_entry.type_of(arg, tracer: tracer) }
|
|
2653
|
+
rescue StandardError
|
|
2654
|
+
[]
|
|
2655
|
+
end
|
|
2656
|
+
|
|
2657
|
+
# Evaluates `block`'s body once with each written outer local bound to
|
|
2658
|
+
# the supplied `bindings` (block params / `;`-locals re-bound as
|
|
2659
|
+
# usual) and returns the per-name exit binding for `names`. Used as
|
|
2660
|
+
# the `BodyFixpoint` body-evaluator.
|
|
2661
|
+
def block_exit_bindings(call_node, block, bindings, names)
|
|
2662
|
+
entry = build_block_entry_scope(call_node, block)
|
|
2663
|
+
entry = bindings.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
|
|
2664
|
+
_type, exit_scope = sub_eval(block, entry)
|
|
2665
|
+
names.to_h { |name| [name, exit_scope.local(name)] }
|
|
2666
|
+
end
|
|
2667
|
+
|
|
1668
2668
|
# Names introduced by the block itself (parameters, numbered
|
|
1669
2669
|
# parameters via `BlockParameterBinder`, plus explicit
|
|
1670
2670
|
# `;`-prefixed block-locals on `BlockParametersNode`). Writes
|
|
@@ -1769,6 +2769,12 @@ module Rigor
|
|
|
1769
2769
|
source_path: scope.source_path
|
|
1770
2770
|
)
|
|
1771
2771
|
bindings = binder.bind(def_node)
|
|
2772
|
+
# ADR-67 WD3 — override an undeclared parameter with its call-site
|
|
2773
|
+
# inferred type (precision-additive; an RBS-declared parameter wins,
|
|
2774
|
+
# the table is empty on a normal `check` run). The inferred type lives
|
|
2775
|
+
# only as a body local, never as an RBS contract, so it cannot fire a
|
|
2776
|
+
# parameter-boundary diagnostic (WD1, satisfied by construction).
|
|
2777
|
+
bindings = seed_inferred_param_types(bindings, def_node, singleton)
|
|
1772
2778
|
|
|
1773
2779
|
# Method bodies do NOT see the outer scope's locals. They start
|
|
1774
2780
|
# from a fresh scope with the same environment, then receive
|
|
@@ -1782,9 +2788,47 @@ module Rigor
|
|
|
1782
2788
|
fresh = seed_instance_ivars(fresh, singleton: singleton)
|
|
1783
2789
|
fresh = seed_class_cvars(fresh)
|
|
1784
2790
|
fresh = seed_program_globals(fresh)
|
|
2791
|
+
# ADR-48 Struct slice 3 — install the method body's fold-safe-local set
|
|
2792
|
+
# so a member read off a mutation-free local folds during the in-body
|
|
2793
|
+
# walk (the call-return inference path is seeded separately).
|
|
2794
|
+
fresh = fresh.with_struct_fold_safe(
|
|
2795
|
+
StructFoldSafety.fold_safe_locals(
|
|
2796
|
+
def_node.body, ->(name) { scope.struct_member_layout(name)&.[](:members) }
|
|
2797
|
+
)
|
|
2798
|
+
)
|
|
1785
2799
|
bindings.reduce(fresh) { |acc, (name, type)| acc.with_local(name, type) }
|
|
1786
2800
|
end
|
|
1787
2801
|
|
|
2802
|
+
# ADR-67 WD3 — consult the call-site parameter-inference table for this
|
|
2803
|
+
# `def` and replace each undeclared (untyped) parameter binding with its
|
|
2804
|
+
# inferred type. Keyed by `[class_name, method_name, kind]`, reconstructed
|
|
2805
|
+
# from the lexical class path — the same triple
|
|
2806
|
+
# {Inference::ParameterInferenceCollector} records. An RBS-declared
|
|
2807
|
+
# parameter (a non-untyped binding) always wins. No-op when the table is
|
|
2808
|
+
# empty (the normal `check` path), so the seed is byte-identical there.
|
|
2809
|
+
def seed_inferred_param_types(bindings, def_node, singleton)
|
|
2810
|
+
inferred = scope.param_inferred_types
|
|
2811
|
+
return bindings if inferred.empty?
|
|
2812
|
+
|
|
2813
|
+
path = current_class_path
|
|
2814
|
+
return bindings if path.nil?
|
|
2815
|
+
|
|
2816
|
+
table = inferred[[path, def_node.name, singleton ? :singleton : :instance]]
|
|
2817
|
+
return bindings if table.nil? || table.empty?
|
|
2818
|
+
|
|
2819
|
+
merged = bindings.dup
|
|
2820
|
+
table.each do |name, type|
|
|
2821
|
+
merged[name] = type if merged.key?(name) && untyped_binding?(merged[name])
|
|
2822
|
+
end
|
|
2823
|
+
merged
|
|
2824
|
+
end
|
|
2825
|
+
|
|
2826
|
+
# True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an
|
|
2827
|
+
# undeclared parameter — the only bindings ADR-67 WD3 overrides.
|
|
2828
|
+
def untyped_binding?(type)
|
|
2829
|
+
type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
|
|
2830
|
+
end
|
|
2831
|
+
|
|
1788
2832
|
def seed_instance_ivars(body_scope, singleton:)
|
|
1789
2833
|
return body_scope if singleton
|
|
1790
2834
|
|
|
@@ -1794,7 +2838,14 @@ module Rigor
|
|
|
1794
2838
|
seeded = scope.class_ivars_for(path)
|
|
1795
2839
|
return body_scope if seeded.empty?
|
|
1796
2840
|
|
|
1797
|
-
|
|
2841
|
+
# ADR-58 WD1 — the class-ivar index unions every `@x = …` write across
|
|
2842
|
+
# the class flow-insensitively, so a ctor `@x = nil` seed makes a read
|
|
2843
|
+
# in a *different* method type `T | nil`. That `nil` is
|
|
2844
|
+
# declaration-sourced, not flow-live, so `seed_declaration_sourced_ivar`
|
|
2845
|
+
# marks each seeded ivar: `possible-nil-receiver` then declines to fire
|
|
2846
|
+
# on the cross-method invariant unless a method-local write or
|
|
2847
|
+
# narrowing makes the nil flow-live (which drops the mark).
|
|
2848
|
+
seeded.reduce(body_scope) { |acc, (name, type)| acc.seed_declaration_sourced_ivar(name, type) }
|
|
1798
2849
|
end
|
|
1799
2850
|
|
|
1800
2851
|
# Cvars are visible from BOTH instance and singleton method
|
|
@@ -1868,7 +2919,7 @@ module Rigor
|
|
|
1868
2919
|
when Prism::ConstantReadNode
|
|
1869
2920
|
receiver.name.to_s == prefix.last
|
|
1870
2921
|
when Prism::ConstantPathNode
|
|
1871
|
-
rendered =
|
|
2922
|
+
rendered = Source::ConstantPath.render(receiver)
|
|
1872
2923
|
return false unless rendered
|
|
1873
2924
|
|
|
1874
2925
|
path = rendered.split("::")
|
|
@@ -1908,25 +2959,6 @@ module Rigor
|
|
|
1908
2959
|
end
|
|
1909
2960
|
end
|
|
1910
2961
|
|
|
1911
|
-
def qualified_name_for(constant_path_node)
|
|
1912
|
-
case constant_path_node
|
|
1913
|
-
when Prism::ConstantReadNode
|
|
1914
|
-
constant_path_node.name.to_s
|
|
1915
|
-
when Prism::ConstantPathNode
|
|
1916
|
-
render_constant_path(constant_path_node)
|
|
1917
|
-
end
|
|
1918
|
-
end
|
|
1919
|
-
|
|
1920
|
-
def render_constant_path(node)
|
|
1921
|
-
prefix =
|
|
1922
|
-
case node.parent
|
|
1923
|
-
when Prism::ConstantReadNode then "#{node.parent.name}::"
|
|
1924
|
-
when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
|
|
1925
|
-
else ""
|
|
1926
|
-
end
|
|
1927
|
-
"#{prefix}#{node.name}"
|
|
1928
|
-
end
|
|
1929
|
-
|
|
1930
2962
|
def singleton_context_for(node)
|
|
1931
2963
|
case node.expression
|
|
1932
2964
|
when Prism::SelfNode
|
|
@@ -1965,7 +2997,7 @@ module Rigor
|
|
|
1965
2997
|
when Prism::ConstantReadNode
|
|
1966
2998
|
expression.name.to_s
|
|
1967
2999
|
when Prism::ConstantPathNode
|
|
1968
|
-
|
|
3000
|
+
Source::ConstantPath.render(expression)
|
|
1969
3001
|
end
|
|
1970
3002
|
end
|
|
1971
3003
|
|
|
@@ -1985,12 +3017,57 @@ module Rigor
|
|
|
1985
3017
|
|
|
1986
3018
|
# ----- helpers -----
|
|
1987
3019
|
|
|
3020
|
+
# Explicit `return value` (including `return` inside a block, which in
|
|
3021
|
+
# Ruby returns from the *enclosing method*). The control-transfer value
|
|
3022
|
+
# is `Bot` — a `return` produces no value at its own position — but the
|
|
3023
|
+
# returned expression's type is recorded into the active return sink so
|
|
3024
|
+
# the method-return inference joins it with the body's tail type.
|
|
3025
|
+
# Returns inside a nested `def`/lambda are barriers: `eval_def` clears
|
|
3026
|
+
# the sink around the nested body, so this handler only ever appends a
|
|
3027
|
+
# return that genuinely exits the method currently being inferred.
|
|
3028
|
+
def eval_return(node)
|
|
3029
|
+
sink = Thread.current[RETURN_SINK_KEY]
|
|
3030
|
+
record_return_value(node, sink) if sink
|
|
3031
|
+
[Type::Combinator.bot, scope]
|
|
3032
|
+
end
|
|
3033
|
+
|
|
3034
|
+
# A `break` transfers control to the loop exit (its flow value is `Bot`,
|
|
3035
|
+
# like `return`). It records the current scope into the active loop's
|
|
3036
|
+
# break sink so the loop join can recover a `break`-path binding the
|
|
3037
|
+
# fall-through would drop (`flag = true; break` -> `flag` is `false |
|
|
3038
|
+
# true` after the loop). nil sink = a `break` not inside an inferred
|
|
3039
|
+
# loop body (a block targeting a method, or top-level) — left to the
|
|
3040
|
+
# existing escaping-block / no-op handling.
|
|
3041
|
+
def eval_break(node)
|
|
3042
|
+
sink = Thread.current[BREAK_SINK_KEY]
|
|
3043
|
+
sink << [node, scope] if sink
|
|
3044
|
+
[Type::Combinator.bot, scope]
|
|
3045
|
+
end
|
|
3046
|
+
|
|
3047
|
+
def record_return_value(node, sink)
|
|
3048
|
+
args = node.arguments&.arguments || []
|
|
3049
|
+
# `return` with no argument returns nil; `return a` records the
|
|
3050
|
+
# argument's type; `return a, b, c` packs a Tuple — in Ruby a
|
|
3051
|
+
# multi-value return yields the array `[a, b, c]`, so the inferred
|
|
3052
|
+
# return contributes the corresponding Tuple element-by-element.
|
|
3053
|
+
if args.empty?
|
|
3054
|
+
sink << Type::Combinator.constant_of(nil)
|
|
3055
|
+
elsif args.size == 1
|
|
3056
|
+
type, = sub_eval(args.first, scope)
|
|
3057
|
+
sink << type
|
|
3058
|
+
else
|
|
3059
|
+
element_types = args.map { |arg| sub_eval(arg, scope).first }
|
|
3060
|
+
sink << Type::Combinator.tuple_of(*element_types)
|
|
3061
|
+
end
|
|
3062
|
+
end
|
|
3063
|
+
|
|
1988
3064
|
def sub_eval(node, with_scope, class_context: @class_context)
|
|
1989
3065
|
StatementEvaluator.new(
|
|
1990
3066
|
scope: with_scope,
|
|
1991
3067
|
tracer: tracer,
|
|
1992
3068
|
on_enter: @on_enter,
|
|
1993
|
-
class_context: class_context
|
|
3069
|
+
class_context: class_context,
|
|
3070
|
+
converged_loop_recording: @converged_loop_recording
|
|
1994
3071
|
).evaluate(node)
|
|
1995
3072
|
end
|
|
1996
3073
|
|