rigortype 0.4.5 → 0.4.7
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 +2 -2
- data/data/gem_overlay/activesupport/core_ext.rbs +16 -13
- data/data/vendored_gem_sigs/bundler/bundler.rbs +1 -1
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +4 -3
- data/docs/handbook/01-getting-started.md +5 -2
- data/docs/handbook/11-sig-gen.md +61 -5
- data/docs/handbook/appendix-phpstan.md +4 -4
- data/docs/manual/02-cli-reference.md +192 -27
- data/docs/manual/03-configuration.md +38 -4
- data/docs/manual/04-diagnostics.md +1 -1
- data/docs/manual/06-baseline.md +6 -4
- data/docs/manual/09-editor-integration.md +13 -2
- data/docs/manual/10-mcp-server.md +47 -3
- data/docs/manual/12-caching.md +9 -1
- data/docs/manual/plugins/rigor-actionmailer.md +30 -0
- data/docs/manual/plugins/rigor-actionpack.md +49 -11
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +20 -1
- data/docs/manual/plugins/rigor-devise.md +15 -0
- data/docs/manual/plugins/rigor-rspec.md +2 -1
- data/lib/rigor/analysis/baseline.rb +24 -27
- data/lib/rigor/analysis/buffer_binding.rb +14 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +5 -6
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +2 -2
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +4 -4
- data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +2 -2
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +105 -12
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +143 -15
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -1
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -8
- data/lib/rigor/analysis/check_rules/published_constant_guard.rb +1 -1
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
- data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
- data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +9 -7
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +3 -4
- data/lib/rigor/analysis/check_rules/special_global_setters.rb +3 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +53 -2
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
- data/lib/rigor/analysis/check_rules.rb +217 -52
- data/lib/rigor/analysis/crash_signature.rb +2 -2
- data/lib/rigor/analysis/dependency_recorder.rb +15 -4
- data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +14 -3
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +6 -8
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
- data/lib/rigor/analysis/edit_replay.rb +154 -0
- data/lib/rigor/analysis/fact_store.rb +3 -3
- data/lib/rigor/analysis/incremental.rb +37 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -2
- data/lib/rigor/analysis/incremental_run_slot.rb +2 -2
- data/lib/rigor/analysis/incremental_session.rb +202 -43
- data/lib/rigor/analysis/path_expansion.rb +41 -0
- data/lib/rigor/analysis/require_relative_closure.rb +100 -0
- data/lib/rigor/analysis/rule_catalog.rb +7 -3
- data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
- data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
- data/lib/rigor/analysis/run_stats.rb +2 -2
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +8 -4
- data/lib/rigor/analysis/runner/declaration_position.rb +1 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +204 -96
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +157 -320
- data/lib/rigor/analysis/runner/project_pre_passes.rb +48 -6
- data/lib/rigor/analysis/runner/run_level_facts.rb +304 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +44 -54
- data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
- data/lib/rigor/analysis/runner/worker_payload.rb +36 -0
- data/lib/rigor/analysis/runner.rb +233 -120
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +1 -1
- data/lib/rigor/analysis/template_unit_positions.rb +2 -3
- data/lib/rigor/analysis/template_units.rb +9 -1
- data/lib/rigor/analysis/worker_session.rb +38 -33
- data/lib/rigor/bleeding_edge.rb +3 -6
- data/lib/rigor/builtins/imported_refinements.rb +6 -8
- data/lib/rigor/builtins/predefined_constant_refinements.rb +1 -1
- data/lib/rigor/builtins/regex_refinement.rb +4 -6
- data/lib/rigor/cache/descriptor.rb +6 -5
- data/lib/rigor/cache/incremental_snapshot.rb +60 -18
- data/lib/rigor/cache/rbs_cache_producer.rb +2 -2
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -2
- data/lib/rigor/cache/rbs_constant_table.rb +1 -1
- data/lib/rigor/cache/rbs_descriptor.rb +54 -3
- data/lib/rigor/cache/rbs_environment.rb +4 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +2 -2
- data/lib/rigor/cache/rbs_known_class_names.rb +1 -1
- data/lib/rigor/cache/store.rb +11 -10
- data/lib/rigor/ci_detector.rb +1 -1
- data/lib/rigor/cli/annotate_command.rb +24 -19
- data/lib/rigor/cli/baseline_command.rb +145 -55
- data/lib/rigor/cli/catalogue.rb +256 -0
- data/lib/rigor/cli/check_command.rb +188 -93
- data/lib/rigor/cli/check_runner_factory.rb +2 -2
- data/lib/rigor/cli/command.rb +15 -1
- data/lib/rigor/cli/coverage_command.rb +21 -8
- data/lib/rigor/cli/coverage_mutation.rb +5 -7
- data/lib/rigor/cli/coverage_renderer.rb +2 -1
- data/lib/rigor/cli/coverage_scan.rb +3 -5
- data/lib/rigor/cli/diff_command.rb +27 -21
- data/lib/rigor/cli/docs_command.rb +1 -2
- data/lib/rigor/cli/doctor_command.rb +12 -12
- data/lib/rigor/cli/effects_command.rb +50 -40
- data/lib/rigor/cli/effects_diff_renderer.rb +18 -17
- data/lib/rigor/cli/effects_explain_renderer.rb +13 -12
- data/lib/rigor/cli/effects_renderer.rb +2 -1
- data/lib/rigor/cli/effects_snapshot_command.rb +18 -17
- data/lib/rigor/cli/explain_command.rb +18 -8
- data/lib/rigor/cli/fused_protection_renderer.rb +2 -1
- data/lib/rigor/cli/json_document.rb +41 -0
- data/lib/rigor/cli/lens_command.rb +17 -13
- data/lib/rigor/cli/lsp_command.rb +250 -23
- data/lib/rigor/cli/mcp_command.rb +14 -8
- data/lib/rigor/cli/mutation_fork_scan.rb +1 -1
- data/lib/rigor/cli/mutation_protection_renderer.rb +3 -2
- data/lib/rigor/cli/options.rb +60 -4
- data/lib/rigor/cli/plugin_command.rb +93 -46
- data/lib/rigor/cli/plugins_command.rb +22 -18
- data/lib/rigor/cli/plugins_renderer.rb +34 -8
- data/lib/rigor/cli/protection_fork_scan.rb +2 -2
- data/lib/rigor/cli/protection_renderer.rb +2 -1
- data/lib/rigor/cli/show_bleedingedge_command.rb +23 -16
- data/lib/rigor/cli/sig_gen_command.rb +130 -34
- data/lib/rigor/cli/skill_command.rb +32 -3
- data/lib/rigor/cli/skill_deep_probe.rb +1 -1
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/trace_command.rb +14 -9
- data/lib/rigor/cli/triage_command.rb +10 -11
- data/lib/rigor/cli/triage_renderer.rb +2 -1
- data/lib/rigor/cli/type_of_command.rb +18 -12
- data/lib/rigor/cli/type_of_project_scope.rb +24 -0
- data/lib/rigor/cli/type_of_renderer.rb +4 -1
- data/lib/rigor/cli/type_scan_command.rb +14 -9
- data/lib/rigor/cli/type_scan_renderer.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +27 -17
- data/lib/rigor/cli/upgrade_command.rb +5 -0
- data/lib/rigor/cli.rb +209 -54
- data/lib/rigor/config_audit.rb +17 -6
- data/lib/rigor/configuration/severity_profile.rb +5 -5
- data/lib/rigor/configuration.rb +86 -34
- data/lib/rigor/effects/definition_context.rb +0 -2
- data/lib/rigor/effects/envelope.rb +1 -1
- data/lib/rigor/effects/framework_units.rb +1 -1
- data/lib/rigor/effects/mutation_classifier.rb +4 -7
- data/lib/rigor/effects/propagator.rb +1 -1
- data/lib/rigor/effects/registry.rb +2 -2
- data/lib/rigor/effects/snapshot.rb +1 -1
- data/lib/rigor/effects/taint_cause.rb +0 -2
- data/lib/rigor/effects/unit_scan.rb +9 -13
- data/lib/rigor/environment/bundle_sig_discovery.rb +5 -9
- data/lib/rigor/environment/class_registry.rb +3 -3
- data/lib/rigor/environment/core_root.rb +77 -0
- data/lib/rigor/environment/default_libraries.rb +1 -1
- data/lib/rigor/environment/deferred_signature_plan.rb +283 -0
- data/lib/rigor/environment/gated_signature_guard.rb +1 -1
- data/lib/rigor/environment/gated_signature_plan.rb +5 -2
- data/lib/rigor/environment/lockfile_resolver.rb +28 -12
- data/lib/rigor/environment/member_consistency/comparator.rb +18 -1
- data/lib/rigor/environment/member_consistency.rb +56 -7
- data/lib/rigor/environment/missing_gem_constant_index.rb +3 -2
- data/lib/rigor/environment/project_path_gems.rb +124 -0
- data/lib/rigor/environment/rbs_hierarchy.rb +6 -15
- data/lib/rigor/environment/rbs_loader.rb +592 -70
- data/lib/rigor/environment/reflection.rb +7 -7
- data/lib/rigor/environment/required_features.rb +136 -32
- data/lib/rigor/environment/ruby_library_roots.rb +122 -0
- data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
- data/lib/rigor/environment.rb +44 -11
- data/lib/rigor/flow_contribution/merger.rb +4 -4
- data/lib/rigor/inference/acceptance.rb +93 -32
- data/lib/rigor/inference/block_parameter_binder.rb +2 -3
- data/lib/rigor/inference/block_run_count.rb +178 -0
- data/lib/rigor/inference/budget_trace.rb +8 -2
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/numeric_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -2
- data/lib/rigor/inference/callee_mutation_chain.rb +126 -0
- data/lib/rigor/inference/callee_write_census.rb +815 -0
- data/lib/rigor/inference/construction_escape.rb +217 -0
- data/lib/rigor/inference/declaration_walk/traversal.rb +1 -1
- data/lib/rigor/inference/deep_erasure.rb +170 -0
- data/lib/rigor/inference/def_handle.rb +1 -1
- data/lib/rigor/inference/def_node_resolver.rb +1 -1
- data/lib/rigor/inference/def_return_typer.rb +204 -12
- data/lib/rigor/inference/definer_resolution.rb +21 -5
- data/lib/rigor/inference/definers_index.rb +106 -0
- data/lib/rigor/inference/direct_ivar_writes.rb +300 -0
- data/lib/rigor/inference/element_read_widening.rb +49 -3
- data/lib/rigor/inference/error_info.rb +17 -3
- data/lib/rigor/inference/expression_typer.rb +173 -57
- data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
- data/lib/rigor/inference/global_write_census.rb +110 -1
- data/lib/rigor/inference/global_writer_reach.rb +260 -0
- data/lib/rigor/inference/hkt_body.rb +1 -2
- data/lib/rigor/inference/hkt_body_parser.rb +3 -5
- data/lib/rigor/inference/hkt_reducer.rb +2 -2
- data/lib/rigor/inference/hkt_registry.rb +3 -5
- data/lib/rigor/inference/indexed_narrowing.rb +4 -7
- data/lib/rigor/inference/intervening_ivar_reset.rb +26 -4
- data/lib/rigor/inference/ivar_writer_census.rb +246 -0
- data/lib/rigor/inference/key_presence_guard.rb +2 -1
- data/lib/rigor/inference/loop_value_record.rb +57 -0
- data/lib/rigor/inference/match_rebinding/frame.rb +65 -0
- data/lib/rigor/inference/match_rebinding/yielding_match.rb +45 -0
- data/lib/rigor/inference/match_rebinding.rb +11 -1
- data/lib/rigor/inference/memo_audit.rb +55 -0
- data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_distribution.rb +40 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +113 -12
- data/lib/rigor/inference/method_dispatcher/contested_overloads.rb +89 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +7 -3
- data/lib/rigor/inference/method_dispatcher/no_match_fallback.rb +54 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +39 -27
- data/lib/rigor/inference/method_dispatcher/random_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +203 -17
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/self_substitute.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +59 -24
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +83 -20
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +0 -1
- data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +50 -17
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -5
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +4 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher.rb +109 -29
- data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
- data/lib/rigor/inference/module_function_state.rb +23 -1
- data/lib/rigor/inference/multi_target_binder.rb +1 -1
- data/lib/rigor/inference/mutation_rejoin.rb +240 -4
- data/lib/rigor/inference/mutation_widening.rb +162 -29
- data/lib/rigor/inference/narrowing.rb +113 -38
- data/lib/rigor/inference/object_mixins.rb +13 -1
- data/lib/rigor/inference/optimistic_origin.rb +1 -1
- data/lib/rigor/inference/out_buffer_mutation.rb +72 -0
- data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
- data/lib/rigor/inference/pre_eval_constants.rb +1 -1
- data/lib/rigor/inference/project_method_ownership.rb +6 -3
- data/lib/rigor/inference/project_seed_tables.rb +2 -1
- data/lib/rigor/inference/project_yielder.rb +122 -0
- data/lib/rigor/inference/receiver_alias.rb +20 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +3 -1
- data/lib/rigor/inference/refined_dispatch/own_method.rb +2 -1
- data/lib/rigor/inference/remover_holder_scan.rb +233 -0
- data/lib/rigor/inference/remover_narrowing.rb +224 -0
- data/lib/rigor/inference/rewrite_mutation.rb +53 -2
- data/lib/rigor/inference/scope_indexer/certainty.rb +9 -1
- data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
- data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +205 -0
- data/lib/rigor/inference/scope_indexer/derived_param_value.rb +112 -0
- data/lib/rigor/inference/scope_indexer/empty_seed.rb +99 -0
- data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +135 -0
- data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
- data/lib/rigor/inference/scope_indexer.rb +614 -146
- data/lib/rigor/inference/self_call_ivar_writes.rb +101 -17
- data/lib/rigor/inference/set_mutation.rb +189 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +1 -2
- data/lib/rigor/inference/statement_evaluator.rb +3036 -278
- data/lib/rigor/inference/struct_fold_safety.rb +4 -6
- data/lib/rigor/inference/synthetic_method_index.rb +23 -0
- data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +73 -14
- data/lib/rigor/inference/toplevel_def_census.rb +59 -0
- data/lib/rigor/inference/unknown_store_widening.rb +103 -4
- data/lib/rigor/inference/unthreaded_rebinds.rb +2 -3
- data/lib/rigor/inference/void_tail_summary.rb +2 -1
- data/lib/rigor/language_server/buffer_table.rb +9 -3
- data/lib/rigor/language_server/completion_provider.rb +26 -20
- data/lib/rigor/language_server/config_warning_announcer.rb +28 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +28 -9
- data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
- data/lib/rigor/language_server/folding_range_provider.rb +1 -2
- data/lib/rigor/language_server/hover_provider.rb +1 -7
- data/lib/rigor/language_server/hover_renderer.rb +6 -8
- data/lib/rigor/language_server/project_context.rb +98 -4
- data/lib/rigor/language_server/publish_batcher.rb +3 -4
- data/lib/rigor/language_server/selection_range_provider.rb +2 -4
- data/lib/rigor/language_server/server.rb +12 -6
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/lens/plugins.rb +1 -1
- data/lib/rigor/mcp/server.rb +82 -3
- data/lib/rigor/mcp.rb +1 -1
- data/lib/rigor/plugin/access_denied_error.rb +1 -2
- data/lib/rigor/plugin/base.rb +26 -11
- data/lib/rigor/plugin/bundled_catalog.rb +1 -12
- data/lib/rigor/plugin/io_boundary.rb +1 -1
- data/lib/rigor/plugin/loader.rb +25 -8
- data/lib/rigor/plugin/macro/heredoc_template.rb +7 -8
- data/lib/rigor/plugin/macro/trait_registry.rb +6 -9
- data/lib/rigor/plugin/manifest.rb +38 -16
- data/lib/rigor/plugin/registry.rb +41 -3
- data/lib/rigor/plugin/services.rb +2 -3
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -0
- data/lib/rigor/plugin/trust_policy.rb +2 -2
- data/lib/rigor/plugin/type_node_resolver.rb +2 -4
- data/lib/rigor/plugin.rb +6 -8
- data/lib/rigor/plugin_gap_advisory.rb +5 -1
- data/lib/rigor/project_environment.rb +16 -2
- data/lib/rigor/protection/analysis_guard.rb +1 -1
- data/lib/rigor/protection/discovery_seed.rb +96 -2
- data/lib/rigor/protection/mutator.rb +1 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
- data/lib/rigor/rbs_extended/hkt_directives.rb +6 -10
- data/lib/rigor/rbs_extended/reporter.rb +27 -3
- data/lib/rigor/rbs_extended.rb +2 -3
- data/lib/rigor/reflection/constant_ancestors.rb +5 -3
- data/lib/rigor/rooted_glob.rb +25 -2
- data/lib/rigor/run_scoped_slots.rb +38 -0
- data/lib/rigor/runtime/jit.rb +1 -1
- data/lib/rigor/scope/discovery_index.rb +19 -6
- data/lib/rigor/scope/resolution_chain.rb +3 -1
- data/lib/rigor/scope.rb +379 -36
- data/lib/rigor/sig_gen/classification.rb +4 -0
- data/lib/rigor/sig_gen/effect_annotation.rb +3 -3
- data/lib/rigor/sig_gen/generator.rb +668 -84
- data/lib/rigor/sig_gen/generic_header_scan.rb +189 -0
- data/lib/rigor/sig_gen/inline_declarations.rb +2 -2
- data/lib/rigor/sig_gen/layout_index.rb +3 -3
- data/lib/rigor/sig_gen/method_candidate.rb +23 -4
- data/lib/rigor/sig_gen/observation_collector.rb +7 -6
- data/lib/rigor/sig_gen/observed_call.rb +1 -1
- data/lib/rigor/sig_gen/path_mapper.rb +6 -0
- data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +21 -12
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +15 -2
- data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
- data/lib/rigor/sig_gen/type_name_qualification.rb +69 -0
- data/lib/rigor/sig_gen/write_reporter.rb +63 -14
- data/lib/rigor/sig_gen/write_result.rb +23 -12
- data/lib/rigor/sig_gen/writer.rb +217 -37
- data/lib/rigor/type/acceptance_router.rb +2 -2
- data/lib/rigor/type/accepts_result.rb +1 -1
- data/lib/rigor/type/app.rb +4 -6
- data/lib/rigor/type/combinator.rb +13 -16
- data/lib/rigor/type/constant.rb +48 -7
- data/lib/rigor/type/hash_shape.rb +26 -40
- data/lib/rigor/type/struct_instance.rb +3 -2
- data/lib/rigor/type/union.rb +1 -1
- data/lib/rigor/type_node/identifier.rb +1 -2
- data/lib/rigor/type_node/resolver_chain.rb +1 -1
- data/lib/rigor/type_node/string_literal.rb +1 -2
- data/lib/rigor/type_node/symbol_literal.rb +1 -2
- data/lib/rigor/type_node/union.rb +3 -6
- data/lib/rigor/value_semantics.rb +1 -10
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +2 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +2 -3
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +89 -70
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +25 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -5
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +0 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +2 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +79 -19
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +6 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +3 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +2 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +21 -31
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +10 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +2 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +0 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +5 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +3 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +7 -12
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/symbolize_keys.rb +102 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +19 -6
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +26 -13
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +3 -3
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +1 -3
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +6 -6
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +5 -6
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +2 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -8
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +0 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +10 -18
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +0 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +0 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -5
- data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +0 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +6 -7
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +2 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +2 -5
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -2
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +6 -6
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +3 -8
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +2 -3
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -8
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +1 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +1 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +4 -11
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +2 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -2
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +3 -4
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +6 -8
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +5 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +4 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +6 -7
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +17 -21
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +2 -3
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +3 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +4 -4
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +0 -2
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/analysis/reachability/scan_cache.rbs +1 -1
- data/sig/rigor/cli/diff_command.rbs +1 -1
- data/sig/rigor/cli/explain_command.rbs +1 -1
- data/sig/rigor/effects/file_collection.rbs +5 -6
- data/sig/rigor/effects/registry.rbs +8 -6
- data/sig/rigor/effects/summary.rbs +6 -5
- data/sig/rigor/environment.rbs +5 -3
- data/sig/rigor/inference/optimistic_origin.rbs +11 -0
- data/sig/rigor/inference.rbs +10 -1
- data/sig/rigor/plugin/additional_initializer.rbs +0 -5
- data/sig/rigor/plugin/base.rbs +1 -0
- data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
- data/sig/rigor/plugin/effect_attribution.rbs +25 -10
- data/sig/rigor/plugin/effect_edge.rbs +12 -5
- data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
- data/sig/rigor/plugin/io_boundary.rbs +6 -5
- data/sig/rigor/plugin/manifest.rbs +16 -18
- data/sig/rigor/plugin/protocol_contract.rbs +6 -9
- data/sig/rigor/plugin/registry.rbs +1 -1
- data/sig/rigor/scope.rbs +51 -50
- data/sig/rigor/type.rbs +0 -6
- data/skills/rigor-plugin-author/SKILL.md +4 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +5 -5
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +12 -1
- metadata +55 -3
|
@@ -14,6 +14,10 @@ require_relative "block_call_timing"
|
|
|
14
14
|
require_relative "block_repetition"
|
|
15
15
|
require_relative "block_parameter_binder"
|
|
16
16
|
require_relative "body_fixpoint"
|
|
17
|
+
require_relative "memo_audit"
|
|
18
|
+
require_relative "callee_mutation_chain"
|
|
19
|
+
require_relative "toplevel_def_census"
|
|
20
|
+
require_relative "callee_write_census"
|
|
17
21
|
require_relative "captured_locals"
|
|
18
22
|
require_relative "dynamic_origin"
|
|
19
23
|
require_relative "error_info"
|
|
@@ -25,10 +29,13 @@ require_relative "../analysis/check_rules/published_constant_guard"
|
|
|
25
29
|
require_relative "struct_fold_safety"
|
|
26
30
|
require_relative "closure_escape_analyzer"
|
|
27
31
|
require_relative "content_join"
|
|
32
|
+
require_relative "construction_escape"
|
|
28
33
|
require_relative "define_method_block_self"
|
|
29
34
|
require_relative "macro_block_self_type"
|
|
35
|
+
require_relative "global_writer_reach"
|
|
30
36
|
require_relative "guard_rebinding"
|
|
31
37
|
require_relative "self_call_ivar_writes"
|
|
38
|
+
require_relative "direct_ivar_writes"
|
|
32
39
|
require_relative "intervening_ivar_reset"
|
|
33
40
|
require_relative "match_rebinding"
|
|
34
41
|
require_relative "clause_widening"
|
|
@@ -41,16 +48,20 @@ require_relative "method_dispatcher"
|
|
|
41
48
|
require_relative "method_parameter_binder"
|
|
42
49
|
require_relative "multi_target_binder"
|
|
43
50
|
require_relative "mutation_widening"
|
|
51
|
+
require_relative "out_buffer_mutation"
|
|
44
52
|
require_relative "narrowing"
|
|
45
53
|
require_relative "operand_effects"
|
|
46
54
|
require_relative "operand_walk"
|
|
47
55
|
require_relative "optimistic_origin"
|
|
56
|
+
require_relative "loop_value_record"
|
|
57
|
+
require_relative "project_yielder"
|
|
48
58
|
require_relative "return_barrier"
|
|
49
59
|
require_relative "repeated_or_writes"
|
|
50
60
|
require_relative "rewrite_mutation"
|
|
51
61
|
require_relative "unknown_store_widening"
|
|
52
62
|
require_relative "untyped_rejoin"
|
|
53
63
|
require_relative "version_guard"
|
|
64
|
+
require_relative "../run_scoped_slots"
|
|
54
65
|
|
|
55
66
|
module Rigor
|
|
56
67
|
module Inference
|
|
@@ -117,8 +128,8 @@ module Rigor
|
|
|
117
128
|
# the default expression pair, so routing the or-writes here adds the body entry and nothing else.
|
|
118
129
|
Prism::ConstantOrWriteNode => :eval_constant_write,
|
|
119
130
|
Prism::ConstantPathOrWriteNode => :eval_constant_write,
|
|
120
|
-
Prism::IfNode => :
|
|
121
|
-
Prism::UnlessNode => :
|
|
131
|
+
Prism::IfNode => :eval_recorded_if,
|
|
132
|
+
Prism::UnlessNode => :eval_recorded_unless,
|
|
122
133
|
Prism::ElseNode => :eval_else,
|
|
123
134
|
Prism::CaseNode => :eval_case,
|
|
124
135
|
Prism::CaseMatchNode => :eval_case,
|
|
@@ -127,9 +138,9 @@ module Rigor
|
|
|
127
138
|
Prism::BeginNode => :eval_begin_node,
|
|
128
139
|
Prism::RescueNode => :eval_rescue,
|
|
129
140
|
Prism::EnsureNode => :eval_ensure,
|
|
130
|
-
Prism::WhileNode => :
|
|
131
|
-
Prism::UntilNode => :
|
|
132
|
-
Prism::ForNode => :
|
|
141
|
+
Prism::WhileNode => :eval_recorded_loop,
|
|
142
|
+
Prism::UntilNode => :eval_recorded_loop,
|
|
143
|
+
Prism::ForNode => :eval_recorded_for,
|
|
133
144
|
Prism::AndNode => :eval_and_or,
|
|
134
145
|
Prism::OrNode => :eval_and_or,
|
|
135
146
|
Prism::ParenthesesNode => :eval_parentheses,
|
|
@@ -186,6 +197,40 @@ module Rigor
|
|
|
186
197
|
RETURN_SINK_KEY = :rigor_return_sink
|
|
187
198
|
private_constant :RETURN_SINK_KEY
|
|
188
199
|
|
|
200
|
+
# Thread-local flag: set while a side pass ({.provisional_returns}) re-types part of a body from a scope other
|
|
201
|
+
# than the one the statement walk holds, so the `return`s it reaches are filed as provisional ({ReturnSink}).
|
|
202
|
+
PROVISIONAL_RETURNS_KEY = :rigor_provisional_returns
|
|
203
|
+
private_constant :PROVISIONAL_RETURNS_KEY
|
|
204
|
+
|
|
205
|
+
# The `return` sink {.with_return_sink} installs. Issue #2299 — a `return` the statement walk records is typed
|
|
206
|
+
# with its real bindings; one a side pass records ({.provisional_returns}) may read a name the pass skipped the
|
|
207
|
+
# binding of as `Dynamic[top]`. A provisional record of a `ReturnNode` therefore yields to the walk's record of
|
|
208
|
+
# the SAME node, and stands only where the walk never reaches that node: a block in expression position (a call
|
|
209
|
+
# argument, an array element, a chain receiver, a `return` value, an endless-def body), whose tail the side pass
|
|
210
|
+
# is the only walk to type.
|
|
211
|
+
class ReturnSink
|
|
212
|
+
def initialize
|
|
213
|
+
@types = []
|
|
214
|
+
@recorded = Set.new.compare_by_identity
|
|
215
|
+
@provisional = []
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def record(node, type)
|
|
219
|
+
if Thread.current[PROVISIONAL_RETURNS_KEY]
|
|
220
|
+
@provisional << [node, type]
|
|
221
|
+
else
|
|
222
|
+
@types << type
|
|
223
|
+
@recorded << node
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The walk's records, then each provisional record whose node the walk did not record.
|
|
228
|
+
def types
|
|
229
|
+
@types + @provisional.filter_map { |node, type| type unless @recorded.include?(node) }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
private_constant :ReturnSink
|
|
233
|
+
|
|
189
234
|
# Thread-local sink (an Array of `[NextNode, Type]`) collecting the value each `next` carries out of the block it
|
|
190
235
|
# leaves, so `ExpressionTyper#type_block_body` can join those arms into the block's value type. Issue #841: the
|
|
191
236
|
# block-return pass modelled only the fall-through tail, so `ops.all? { |o| next false unless o; true }` read as
|
|
@@ -313,14 +358,31 @@ module Rigor
|
|
|
313
358
|
# method-return type.
|
|
314
359
|
def self.with_return_sink
|
|
315
360
|
previous = Thread.current[RETURN_SINK_KEY]
|
|
316
|
-
|
|
361
|
+
previous_provisional = Thread.current[PROVISIONAL_RETURNS_KEY]
|
|
362
|
+
sink = ReturnSink.new
|
|
317
363
|
Thread.current[RETURN_SINK_KEY] = sink
|
|
364
|
+
Thread.current[PROVISIONAL_RETURNS_KEY] = nil
|
|
318
365
|
begin
|
|
319
366
|
result = yield
|
|
320
367
|
ensure
|
|
321
368
|
Thread.current[RETURN_SINK_KEY] = previous
|
|
369
|
+
Thread.current[PROVISIONAL_RETURNS_KEY] = previous_provisional
|
|
370
|
+
end
|
|
371
|
+
[result, sink.types]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Runs `block` with the `return`s it reaches filed as provisional ({ReturnSink}), restoring the previous state
|
|
375
|
+
# on exit. Issue #2299 — {ExpressionTyper#type_block_body}'s tail-only answer types `return r if r` without the
|
|
376
|
+
# `r = yield e` before it, so `r` read `Dynamic[top]` and that went into the enclosing method's return type
|
|
377
|
+
# although the statement walk of the same block recorded `r` with its real binding.
|
|
378
|
+
def self.provisional_returns
|
|
379
|
+
previous = Thread.current[PROVISIONAL_RETURNS_KEY]
|
|
380
|
+
Thread.current[PROVISIONAL_RETURNS_KEY] = true
|
|
381
|
+
begin
|
|
382
|
+
yield
|
|
383
|
+
ensure
|
|
384
|
+
Thread.current[PROVISIONAL_RETURNS_KEY] = previous
|
|
322
385
|
end
|
|
323
|
-
[result, sink]
|
|
324
386
|
end
|
|
325
387
|
|
|
326
388
|
# Runs `block` with a fresh `next` sink installed, then yields the collected `[NextNode, Type]` pairs to the
|
|
@@ -452,38 +514,730 @@ module Rigor
|
|
|
452
514
|
[type, fall_through, join_jump_scopes(fall_through, sink, JumpTargets.of(body, Prism::NextNode))]
|
|
453
515
|
end
|
|
454
516
|
|
|
455
|
-
# ADR-89 WD2 — the sorted
|
|
517
|
+
# ADR-89 WD2 — the sorted slots of the parameters (positional, then keyword) whose CONTENT `def_node` mutates
|
|
456
518
|
# (`callee_content_mutated_parameters`, the ADR-56 arg-flooring surface a caller consumes). A per-def
|
|
457
519
|
# static property of the AST — this is a pure exposure of the existing private computation (it never
|
|
458
520
|
# reads the scope), so the incremental session can compute it on any `StatementEvaluator` (an empty
|
|
459
521
|
# scope suffices), persist it in a return summary, and re-check a callee's symbol dependents when it
|
|
460
522
|
# moves (a caller's arg flooring changes even if the callee's return does not).
|
|
523
|
+
#
|
|
524
|
+
# Issue #2104 — a mutated keyword parameter reports its keyword symbol, after the positions. A parameter the
|
|
525
|
+
# def hands on to a self-call counts whether or not the callee resolves: the session's empty scope resolves no
|
|
526
|
+
# callee, and without it an edit that starts or stops forwarding a parameter to a mutating callee would leave
|
|
527
|
+
# the effects equal and the callers' floors stale.
|
|
528
|
+
#
|
|
529
|
+
# Issue #2296 — the parameters the def mutates inside a block, or through a Set-only adder, count too: they
|
|
530
|
+
# floor the caller's argument through {#floor_callee_escaped_args_for_call}'s element-keeping channels.
|
|
461
531
|
def content_mutated_parameter_positions(def_node)
|
|
462
|
-
|
|
532
|
+
slots = forwarding_slots(def_node)
|
|
533
|
+
census = callee_content_mutated_parameters(def_node).values +
|
|
534
|
+
own_block_content_parameters(def_node).values + own_set_content_parameters(def_node).values
|
|
535
|
+
each_forwarding_call(def_node, slots) { |_, forwarded| census += forwarded.map { |name, _| slots[name] } }
|
|
536
|
+
positions, keywords = census.uniq.partition { |slot| slot.is_a?(Integer) }
|
|
537
|
+
positions.sort + keywords.sort
|
|
463
538
|
end
|
|
464
539
|
|
|
465
|
-
# The local-variable reads among `call_node`'s positional
|
|
466
|
-
# content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's
|
|
467
|
-
# (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
|
|
540
|
+
# The local-variable reads among `call_node`'s arguments (positional, or a keyword's value) whose parameter the
|
|
541
|
+
# callee content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's
|
|
542
|
+
# scope (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
|
|
468
543
|
# callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
|
|
469
544
|
# {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
|
|
470
545
|
def content_mutated_arguments(call_node)
|
|
471
|
-
|
|
546
|
+
callees = argument_mutating_callees(call_node)
|
|
547
|
+
return NO_ARGUMENT_READS if callees.empty?
|
|
548
|
+
|
|
549
|
+
reads = callees.flat_map do |def_node, offset, owner|
|
|
550
|
+
arguments_for_slots(call_node, census_of(owner, :callee_content_mutated_parameters, def_node), offset)
|
|
551
|
+
end
|
|
552
|
+
callees.size == 1 ? reads : reads.uniq
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# The `[def, offset]` pairs a self-dispatch `call_node` that passes a local resolves to
|
|
556
|
+
# ({#resolve_self_callees}) — the defs whose census the callee floor reads; empty for any other call.
|
|
557
|
+
#
|
|
558
|
+
# Issue #2336 — a call no def answers that runs code the floor cannot see against its arguments (`pr.call(acc)`,
|
|
559
|
+
# `obj.method(:poke).call(acc)`, `send(name, acc)`; {#opaque_sink_call?}) answers {OPAQUE_CALLEES}: every
|
|
560
|
+
# argument it passes reads as written.
|
|
561
|
+
def argument_mutating_callees(call_node)
|
|
562
|
+
unless self_dispatch_call?(call_node)
|
|
563
|
+
found = receiver_callees(call_node)
|
|
564
|
+
return found unless found.empty? && opaque_sink_call?(call_node)
|
|
565
|
+
|
|
566
|
+
return call_passes_local_argument?(call_node) ? OPAQUE_CALLEES : NO_CALLEES
|
|
567
|
+
end
|
|
472
568
|
# Fast path — only a local passed as an argument can be reported, so a call with none skips the def
|
|
473
569
|
# resolution and the body scan entirely (the overwhelming common case).
|
|
474
|
-
return
|
|
570
|
+
return NO_CALLEES unless call_passes_local_argument?(call_node)
|
|
571
|
+
|
|
572
|
+
found = resolve_self_callees(call_node)
|
|
573
|
+
found.empty? && opaque_sink_call?(call_node) ? OPAQUE_CALLEES : found
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# True for a call whose arguments go to code no def shows: `call` / `instance_exec` / `class_exec` /
|
|
577
|
+
# `module_exec` on an explicit receiver (a Proc, a Method, a lambda), or a `send` whose target is no literal
|
|
578
|
+
# ({CalleeWriteCensus}, which reads the same calls inside a callee).
|
|
579
|
+
def opaque_sink_call?(call_node)
|
|
580
|
+
name = call_node.name
|
|
581
|
+
return !CalleeWriteCensus.literal_send?(call_node) if SEND_HAND_OFFS.include?(name)
|
|
582
|
+
return false unless OPAQUE_SINKS.include?(name)
|
|
583
|
+
|
|
584
|
+
# A receiverless `call` is a self-call to a def of that name; `instance_exec` and the rest run their block on
|
|
585
|
+
# any receiver.
|
|
586
|
+
name != :call || !self_dispatch_call?(call_node)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
OPAQUE_SINKS = %i[call instance_exec class_exec module_exec].to_set.freeze
|
|
590
|
+
private_constant :OPAQUE_SINKS
|
|
591
|
+
|
|
592
|
+
# Issue #2510, #2515 — the `[def, 0, owner]` triple an explicit-receiver `recv.name(local)` call resolves to when
|
|
593
|
+
# `recv` types as a project class object (`Const`, a bare constant or any constant path, or a local holding
|
|
594
|
+
# one), whose singleton chain has `name` (`def self.name`, `class << self`, `module_function`, an `extend`ed
|
|
595
|
+
# module, the project superclass's), or as an instance of a project class (`Opts.new.add_format(...)`,
|
|
596
|
+
# `h = Opts.new; h.add_format(...)`), whose instance chain has `name`: the same census a self-call reads then
|
|
597
|
+
# floors the passed local, so `Opts.add_format(opts, options)` handing `options` to a block that writes it
|
|
598
|
+
# leaves the literal behind. `owner` is a {CalleeOwner}: the receiver type the callee's own hand-offs
|
|
599
|
+
# (`really(parser, options)`) resolve self-calls in, not the caller's class. `resolved_census` keys the shared
|
|
600
|
+
# memo by the receiver's class and the def node, whose recorded nesting its constants resolve in. A def the
|
|
601
|
+
# receiver inherits (a superclass's, an included module's) resolves to {FLOOR_ALL} ({#walked_owner}). A receiver
|
|
602
|
+
# of any other type resolves to nothing, as does `Const.new` of a class that defines its own `self.new`; `send`
|
|
603
|
+
# hand-offs are not followed through an explicit receiver. The hand-offs inside a callee, whose arguments are
|
|
604
|
+
# parameters, take {#hand_off_receiver_callees}.
|
|
605
|
+
def receiver_callees(call_node)
|
|
606
|
+
return NO_CALLEES if call_node.receiver.nil?
|
|
607
|
+
return NO_CALLEES unless call_passes_local_argument?(call_node)
|
|
608
|
+
|
|
609
|
+
receiver_callees_in(scope, call_node)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# {#receiver_callees} for one scope `call_node`'s receiver (or `receiver`, the constant form a callee's local
|
|
613
|
+
# receiver was assigned) is typed in.
|
|
614
|
+
def receiver_callees_in(typing_scope, call_node, receiver = call_node.receiver)
|
|
615
|
+
return NO_CALLEES if overridden_constructor?(receiver, typing_scope)
|
|
616
|
+
|
|
617
|
+
receiver_type = typing_scope.type_of(receiver)
|
|
618
|
+
own = callees_for_receiver_type(receiver_type, call_node.name)
|
|
619
|
+
return own unless own.empty? && send_hand_off?(call_node)
|
|
620
|
+
|
|
621
|
+
shifted_callees(callees_for_receiver_type(receiver_type, send_target(call_node)))
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Issue #2336 — the method a `send` hand-off names (`recv.send(:poke, h)`, `send("poke", h)`).
|
|
625
|
+
def send_target(call_node) = call_node.arguments.arguments.first.unescaped.to_sym
|
|
626
|
+
|
|
627
|
+
# `callees` with each offset one further: the callees of a `send` hand-off's target, whose first argument is the
|
|
628
|
+
# name. A capped answer stays as it is.
|
|
629
|
+
def shifted_callees(callees)
|
|
630
|
+
return callees if callees.empty? || callees.equal?(CAPPED_HAND_OFF)
|
|
631
|
+
|
|
632
|
+
shifted = callees.map { |def_node, offset, owner| [def_node, offset + 1, owner] }
|
|
633
|
+
callees.is_a?(UnionCallees) ? UnionCallees.new(shifted) : shifted
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# `[def, 0, owner]` for `name` called on a receiver of `receiver_type`, or none.
|
|
637
|
+
def callees_for_receiver_type(receiver_type, name)
|
|
638
|
+
if receiver_type.is_a?(Type::Union)
|
|
639
|
+
return receiver_type.members.flat_map { |member| callees_for_receiver_type(member, name) }
|
|
640
|
+
.uniq { |callee, _, owner| [callee.object_id, owner] }
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
singleton = receiver_type.is_a?(Type::Singleton)
|
|
644
|
+
return NO_CALLEES unless singleton || receiver_type.is_a?(Type::Nominal)
|
|
645
|
+
|
|
646
|
+
class_name = receiver_type.class_name
|
|
647
|
+
found, defining =
|
|
648
|
+
if singleton then scope.singleton_def_through_ancestors(class_name, name)
|
|
649
|
+
else scope.user_def_through_ancestors(class_name, name)
|
|
650
|
+
end
|
|
651
|
+
side = singleton ? :singleton : :instance
|
|
652
|
+
return unresolved_receiver_callees(receiver_type, side, name) if found.nil?
|
|
653
|
+
|
|
654
|
+
[[found, 0, walked_owner(defining, class_name, CalleeOwner.new(receiver_type))]]
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# `owner`, the owner a def found on `defining` by a lookup on `class_name` is walked under, or {FLOOR_ALL} for a
|
|
658
|
+
# def the class inherits (`defining` is not `class_name`). An inherited def is not walked (#2521): walking it
|
|
659
|
+
# exposed the own-def census's blind spots, which #2595 closes ({#callee_write_census}), and the hand-offs #2336
|
|
660
|
+
# lists, which stay open and which the floor masks.
|
|
661
|
+
def walked_owner(defining, class_name, owner)
|
|
662
|
+
defining == class_name ? owner : FLOOR_ALL
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Issue #2336 — what a call of `name` on a receiver of `receiver_type` reaches when its `side` chain has no def of
|
|
666
|
+
# the name: a body an ownerless definition row may give it ({#receiver_ownerless_body?}), floored outright; on
|
|
667
|
+
# the singleton side, a same-named instance def
|
|
668
|
+
# written as a `module_function def x` argument ({Scope#module_function_row_def?}), which the singleton def table
|
|
669
|
+
# does not hold, walked as a class method; else the chain's `method_missing`.
|
|
670
|
+
def unresolved_receiver_callees(receiver_type, side, name)
|
|
671
|
+
class_name = receiver_type.class_name
|
|
672
|
+
return OPAQUE_CALLEES if receiver_ownerless_body?(name, class_name)
|
|
673
|
+
|
|
674
|
+
if side == :singleton
|
|
675
|
+
found, defining = scope.user_def_through_ancestors(class_name, name)
|
|
676
|
+
if found && scope.module_function_row_def?(found, defining)
|
|
677
|
+
return [[found, 0, walked_owner(defining, class_name, CalleeOwner.new(receiver_type))]]
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
method_missing_callees(class_name, side, name, 0)
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# Issue #2336 — true when an ownerless definition row may give `name` a body on `class_name`, the receiver of an
|
|
684
|
+
# explicit-receiver call ({Scope#ownerless_definition?}). A `define_method` / `define_singleton_method` /
|
|
685
|
+
# `alias_method` call or a def in a block claims no owner, so it may be the receiver's own and always counts. A
|
|
686
|
+
# top-level def lives on `Object`, so a method RBS declares on the receiver's class answers ahead of it
|
|
687
|
+
# (`Bag < Hash` and a top-level `def store`): the top-level rows count only when RBS declares none.
|
|
688
|
+
def receiver_ownerless_body?(name, class_name)
|
|
689
|
+
return true if scope.ownerless_definition?(name, top_level: false)
|
|
690
|
+
return false if rbs_declares?(class_name, name)
|
|
691
|
+
|
|
692
|
+
scope.ownerless_definition?(name)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Issue #2336 — true when a body no def table holds may answer the receiverless call of `name` in `class_name`'s
|
|
696
|
+
# `side` chain, which no def, `method_missing` or top-level def answers: a definer the chain records
|
|
697
|
+
# (`define_method(:format)` in the class body; {Scope#discovered_method_through_ancestors?}), or, for a name RBS
|
|
698
|
+
# does not declare on `Object` (`Class` on the singleton side), any ownerless definition row. A name `Object`
|
|
699
|
+
# declares (`warn`, `format`) is answered by that method once the chain has no definer of it, so a `def warn` in a
|
|
700
|
+
# `refine Kernel` block or a `define_method` on another class does not count.
|
|
701
|
+
def self_ownerless_body?(name, class_name, side)
|
|
702
|
+
return true if scope.discovered_method_through_ancestors?(class_name, name, side)
|
|
703
|
+
return false if Reflection.instance_method_definition(side == :singleton ? "Class" : "Object", name,
|
|
704
|
+
scope: scope)
|
|
705
|
+
|
|
706
|
+
scope.ownerless_definition?(name)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# Issue #2521 (#2570 review) — the `[def, offset, FLOOR_ALL]` a call of `name` that resolves to no def on
|
|
710
|
+
# `class_name`'s `side` chain reaches when the chain defines `method_missing` and `name` is not a method every
|
|
711
|
+
# object (`Object`, or `Class` on the singleton side) answers: Ruby calls `method_missing(name, *args)`, so its
|
|
712
|
+
# parameters sit `offset` places ahead of the call's arguments (-1; 0 for a `send(:name, ...)` hand-off).
|
|
713
|
+
def method_missing_callees(class_name, side, name, offset)
|
|
714
|
+
found =
|
|
715
|
+
if side == :singleton then scope.singleton_def_through_ancestors(class_name, :method_missing).first
|
|
716
|
+
else scope.user_def_through_ancestors(class_name, :method_missing).first
|
|
717
|
+
end
|
|
718
|
+
return NO_CALLEES if found.nil?
|
|
719
|
+
|
|
720
|
+
base = side == :singleton ? "Class" : "Object"
|
|
721
|
+
return NO_CALLEES if Reflection.instance_method_definition(base, name, scope: scope)
|
|
722
|
+
|
|
723
|
+
[[found, offset - 1, FLOOR_ALL]]
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# What a cross-class hand-off carries instead of an evaluator: the receiver type, which is `self` in the callee.
|
|
727
|
+
# The def's lexical nesting is not carried: it is a property of the def node ({#recorded_def_nesting}), which
|
|
728
|
+
# keys the census memo and the chain's nodes beside it. Holds no scope, so the run-scoped census memo that keeps
|
|
729
|
+
# an {CalleeMutationChain::Edge} alive keeps no caller state alive.
|
|
730
|
+
CalleeOwner = Data.define(:self_type)
|
|
731
|
+
|
|
732
|
+
# The owner of an inherited def ({#walked_owner}): its census and hand-offs are not walked. Every content
|
|
733
|
+
# parameter reads as mutated, so the argument is floored, and a {BudgetTrace::CALLEE_CHAIN_CAP} hit is recorded.
|
|
734
|
+
FLOOR_ALL = :floor_all
|
|
735
|
+
|
|
736
|
+
# Issue #2515 — the owner of a top-level def that may not be the method the call runs (several defs of the name,
|
|
737
|
+
# an alias, a class chain that may answer it first; {ToplevelDefCensus}, {#top_level_callees}): the def is not
|
|
738
|
+
# read, and EVERY argument the call passes reads as written, whatever the def's own parameters are, since another
|
|
739
|
+
# def of the name may take more of them. A hand-off to it floors the handed parameter outright.
|
|
740
|
+
FLOOR_EVERY = :floor_every
|
|
741
|
+
|
|
742
|
+
# Issue #2336 — the answer for a call no def resolves whose arguments the floor reads as written: an opaque sink
|
|
743
|
+
# ({#opaque_sink_call?}), or a name a definer other than a `def` may give a body ({#receiver_ownerless_body?},
|
|
744
|
+
# {#self_ownerless_body?}). No def is
|
|
745
|
+
# read; every argument floors as under {FLOOR_EVERY}.
|
|
746
|
+
OPAQUE_CALLEES = [[nil, 0, FLOOR_EVERY].freeze].freeze
|
|
747
|
+
|
|
748
|
+
# The owner of a top-level def reached from inside a class: its hand-offs resolve at the top level, where `self`
|
|
749
|
+
# is no instance of the caller's class.
|
|
750
|
+
TOPLEVEL_OWNER = CalleeOwner.new(nil)
|
|
751
|
+
OVER_APPROXIMATING = [FLOOR_ALL, FLOOR_EVERY].freeze
|
|
752
|
+
private_constant :CalleeOwner, :FLOOR_ALL, :FLOOR_EVERY, :OPAQUE_CALLEES, :TOPLEVEL_OWNER, :OVER_APPROXIMATING
|
|
753
|
+
|
|
754
|
+
# True for `Const.new` where the class defines its own `self.new`, whose result is not an instance of `Const`.
|
|
755
|
+
def overridden_constructor?(receiver, typing_scope)
|
|
756
|
+
return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :new
|
|
757
|
+
|
|
758
|
+
klass = typing_scope.type_of(receiver.receiver) if constant_node?(receiver.receiver)
|
|
759
|
+
klass.is_a?(Type::Singleton) &&
|
|
760
|
+
!typing_scope.singleton_def_through_ancestors(klass.class_name, :new).first.nil?
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Issue #2522 — the callees a hand-off inside `def_node`'s body (`Help.c(p, o)`, `Helper.new.c(p, o)`) resolves
|
|
764
|
+
# to: the receiver typed once under the `Module.nesting` the declaration walk recorded for `def_node`, so a bare
|
|
765
|
+
# constant follows Ruby's lookup (the lexical chain, the innermost cref's ancestors, the top level) for the
|
|
766
|
+
# spelling the def is actually written under (`class B::Opts` reads `::Helper`, `module B; class Opts` reads
|
|
767
|
+
# `B::Helper`). The nesting is a function of the def node, never of the caller, so the answer cannot depend on
|
|
768
|
+
# which caller reached the memoised edges first. A def with no recorded nesting takes
|
|
769
|
+
# {#unrecorded_hand_off_callees}.
|
|
770
|
+
def hand_off_receiver_callees(call_node, def_node, receiver = call_node.receiver)
|
|
771
|
+
nesting = exact_def_nesting(def_node)
|
|
772
|
+
return unrecorded_hand_off_callees(call_node, receiver) if nesting.nil?
|
|
773
|
+
|
|
774
|
+
receiver_callees_in(scope.with_lexical_nesting(nesting), call_node, receiver)
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# The def's recorded `Module.nesting` ({Scope::DiscoveryIndex#recorded_def_nesting}), nil when the declaration
|
|
778
|
+
# walk recorded none.
|
|
779
|
+
def recorded_def_nesting(def_node) = scope.discovery.recorded_def_nesting(def_node)
|
|
780
|
+
|
|
781
|
+
# The nesting the hand-offs of `def_node`, run with `self_type`, resolve their constants in exactly, or nil. A
|
|
782
|
+
# def in a `class << self` body answers nil: there Ruby's cref is the singleton class, whose ancestors are not
|
|
783
|
+
# the class's (#2570 review), and the recorded nesting does not name it. Such a def is receiverless, runs on the
|
|
784
|
+
# class object, and is not in its cref's instance def table, which a `module_function` / `extend self` def or an
|
|
785
|
+
# `extend`ed module's def (whose cref is the module itself) is.
|
|
786
|
+
def exact_def_nesting(def_node, self_type = scope.self_type)
|
|
787
|
+
nesting = recorded_def_nesting(def_node)
|
|
788
|
+
return nesting unless self_type.is_a?(Type::Singleton) && def_node.receiver.nil?
|
|
789
|
+
return nil if nesting.nil? || nesting.empty?
|
|
790
|
+
|
|
791
|
+
scope.user_def_for(nesting.first, def_node.name).equal?(def_node) ? nesting : nil
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# A hand-off in a def whose nesting was not recorded (a def under a header that renders no name, a bundle-served
|
|
795
|
+
# def outside a rehydrating run): the spelling of the defining class is unknown (`module A; class B::C` has the
|
|
796
|
+
# nesting `["A::B::C", "A"]`, `class A::B::C` has `["A::B::C"]`), so the union of two resolutions that try
|
|
797
|
+
# every spelling it could have had, each of which only adds callees:
|
|
798
|
+
#
|
|
799
|
+
# - By name: a bare constant `K` as each prefix of the class name followed by `K`, and as the top-level `K`; a
|
|
800
|
+
# rooted one (`::K`) as `K` alone.
|
|
801
|
+
# - Typed: the receiver typed under each truncation of the nested-spelling nesting (`["A::B::C", "A::B", "A"]`
|
|
802
|
+
# gives `["A::B::C"]`, `["A::B::C", "A::B"]` and the whole chain), which sees an alias, an inherited or
|
|
803
|
+
# included constant and a constant holding an instance.
|
|
804
|
+
def unrecorded_hand_off_callees(call_node, receiver = call_node.receiver)
|
|
805
|
+
constructed = receiver.is_a?(Prism::CallNode) && receiver.name == :new
|
|
806
|
+
written = constant_name(constructed ? receiver.receiver : receiver)
|
|
807
|
+
return NO_CALLEES if written.nil?
|
|
808
|
+
return compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed) if hand_off_memo_bypassed?
|
|
809
|
+
|
|
810
|
+
memo = unrecorded_hand_off_memo(written)
|
|
811
|
+
key = [constructed, call_node.name]
|
|
812
|
+
unless memo.key?(key)
|
|
813
|
+
return memo[key] = compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
MemoAudit.check("unrecorded hand-off", memo[key], written, key) do
|
|
817
|
+
compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def compute_unrecorded_hand_off_callees(call_node, receiver, written, constructed)
|
|
822
|
+
found = hand_off_class_names(written).flat_map do |class_name|
|
|
823
|
+
hand_off_callee_in(class_name, constructed, call_node.name)
|
|
824
|
+
end
|
|
825
|
+
hand_off_typing_scopes(written).each do |typing_scope|
|
|
826
|
+
found.concat(receiver_callees_in(typing_scope, call_node, receiver))
|
|
827
|
+
end
|
|
828
|
+
found.size > 1 ? found.uniq { |callee, _, owner| [callee.object_id, owner] } : found
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# The run's per-discovery memo of {#unrecorded_hand_off_callees}: the answer depends on the receiver's spelling,
|
|
832
|
+
# the class `self` is and its side (a constant's ancestor lookup differs between the class object and an
|
|
833
|
+
# instance), and the called name, not on the def it sits in.
|
|
834
|
+
def unrecorded_hand_off_memo(written)
|
|
835
|
+
table = (Thread.current[UNRECORDED_HAND_OFF_KEY] ||= ObjectSpace::WeakKeyMap.new)
|
|
836
|
+
self_type = scope.self_type
|
|
837
|
+
by_side = ((table[scope.discovery] ||= {})[self_type.is_a?(Type::Singleton)] ||= {})
|
|
838
|
+
(by_side[enclosing_class_name_for(self_type)] ||= {})[written] ||= {}
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
UNRECORDED_HAND_OFF_KEY = RunScopedSlots.register(:rigor_callee_unrecorded_hand_offs)
|
|
842
|
+
private_constant :UNRECORDED_HAND_OFF_KEY
|
|
843
|
+
|
|
844
|
+
# The scopes {#unrecorded_hand_off_callees} types a receiver written as `written` in. A rooted constant and a
|
|
845
|
+
# top-level def are typed once.
|
|
846
|
+
def hand_off_typing_scopes(written)
|
|
847
|
+
name = enclosing_class_name_for(scope.self_type)
|
|
848
|
+
return [scope.with_lexical_nesting([])] if name.nil?
|
|
849
|
+
return [scope] if written.start_with?("::")
|
|
850
|
+
|
|
851
|
+
parts = name.to_s.split("::")
|
|
852
|
+
chain = parts.each_index.map { |i| parts[0..(parts.size - 1 - i)].join("::") }
|
|
853
|
+
chain.each_index.map { |k| scope.with_lexical_nesting(chain[0..k]) }
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# `[def, 0, owner]` for `name` called on `class_name` (or on its `new`), or none.
|
|
857
|
+
def hand_off_callee_in(class_name, constructed, name)
|
|
858
|
+
if constructed
|
|
859
|
+
return NO_CALLEES unless scope.singleton_def_through_ancestors(class_name, :new).first.nil?
|
|
860
|
+
|
|
861
|
+
found = scope.user_def_for(class_name, name)
|
|
862
|
+
return found.nil? ? NO_CALLEES : [[found, 0, CalleeOwner.new(Type::Combinator.nominal_of(class_name))]]
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
found, defining = scope.singleton_def_through_ancestors(class_name, name)
|
|
866
|
+
return NO_CALLEES if found.nil?
|
|
867
|
+
|
|
868
|
+
owner = CalleeOwner.new(Type::Combinator.singleton_of(class_name))
|
|
869
|
+
[[found, 0, walked_owner(defining, class_name, owner)]]
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# The class names a constant written as `written` (`K`, `A::K`, `::K`) can name from the def's class name.
|
|
873
|
+
def hand_off_class_names(written)
|
|
874
|
+
return [written.delete_prefix("::")] if written.start_with?("::")
|
|
875
|
+
|
|
876
|
+
name = enclosing_class_name_for(scope.self_type)
|
|
877
|
+
return [written] if name.nil?
|
|
878
|
+
|
|
879
|
+
parts = name.to_s.split("::")
|
|
880
|
+
parts.each_index.map { |i| "#{parts[0..i].join('::')}::#{written}" } << written
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# True when a census memo must not be read or written: a dependency recorder is active (it must see each
|
|
884
|
+
# constant read), or this scope carries guard narrowings of constants (`if INST.is_a?(Sub)`), which survive
|
|
885
|
+
# `with_self_type` and change what the constant types as, so the answer is no longer the def's alone. An audit
|
|
886
|
+
# recomputation ({MemoAudit}) bypasses them too, so it reads no stored entry.
|
|
887
|
+
def hand_off_memo_bypassed?
|
|
888
|
+
Analysis::DependencyRecorder.active? || !scope.constant_narrowings.empty? || MemoAudit.suspended?
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# The name a constant node is written with, or nil when part of it is not a constant (`foo::Bar`).
|
|
892
|
+
def constant_name(node)
|
|
893
|
+
node.full_name if constant_node?(node)
|
|
894
|
+
rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError, Prism::ConstantPathNode::MissingNodesInConstantPathError
|
|
895
|
+
nil
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
# The evaluator a hand-off's {CalleeOwner} stands for, built from this one's scope and kept on it.
|
|
899
|
+
def owner_evaluator(owner)
|
|
900
|
+
(@owner_evaluators ||= {})[owner] ||=
|
|
901
|
+
self.class.new(scope: scope.with_self_type(owner.self_type))
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# The `[def, offset, owner]` triples a call inside `def_node`'s body hands its parameter to: a self-call
|
|
905
|
+
# ({#resolve_self_callees}, owner nil) or a call on a constant / `Const.new` receiver ({#hand_off_receiver?},
|
|
906
|
+
# {#hand_off_receiver_callees}).
|
|
907
|
+
#
|
|
908
|
+
# Issue #2336 — a call on `itself` is a self-call, and one on `self.class` resolves on the class object `self`
|
|
909
|
+
# types as. A call on any other receiver the callee's body cannot type (a call's result: `new.store(h)`,
|
|
910
|
+
# `instance.helper(h)`; a class variable or a global) resolves by name, as an ivar receiver does. A literal
|
|
911
|
+
# `send` hand-off on any receiver resolves as its target.
|
|
912
|
+
def hand_off_callees(call_node, def_node, depth = 0)
|
|
913
|
+
receiver = call_node.receiver
|
|
914
|
+
return self_hand_off_callees(call_node) if self_dispatch_call?(call_node) || itself_receiver?(receiver)
|
|
915
|
+
|
|
916
|
+
if receiver.is_a?(Prism::LocalVariableReadNode) || receiver.is_a?(Prism::ItLocalVariableReadNode)
|
|
917
|
+
return local_receiver_callees(call_node, def_node, depth)
|
|
918
|
+
end
|
|
919
|
+
return receiver_callees_in(scope, call_node) if class_of_self?(receiver)
|
|
920
|
+
return hand_off_receiver_callees(call_node, def_node) if constant_receiver_form?(receiver)
|
|
921
|
+
|
|
922
|
+
named_hand_off_callees(call_node)
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# {#named_instance_callees} for `call_node`'s name, or a literal `send` hand-off's target.
|
|
926
|
+
def named_hand_off_callees(call_node)
|
|
927
|
+
return named_instance_callees(call_node.name) unless send_hand_off?(call_node)
|
|
928
|
+
|
|
929
|
+
shifted_callees(named_instance_callees(send_target(call_node)))
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# `itself` / `self.itself`, which is `self`.
|
|
933
|
+
def itself_receiver?(receiver)
|
|
934
|
+
receiver.is_a?(Prism::CallNode) && receiver.name == :itself && receiver.arguments.nil? &&
|
|
935
|
+
receiver.block.nil? && self_dispatch_call?(receiver)
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# `self.class`, the class object of `self`.
|
|
939
|
+
def class_of_self?(receiver)
|
|
940
|
+
receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode) &&
|
|
941
|
+
receiver.arguments.nil? && receiver.block.nil?
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# {#resolve_self_callees} for a self-call that is a hand-off inside a def. In a def the evaluator is the top
|
|
945
|
+
# level's (`self` nil), the def a top-level one, and a top-level def is a private method on `Object`: it may be
|
|
946
|
+
# called from any object, so a same-named instance def of the caller's class answers the hand-off before the
|
|
947
|
+
# top-level one. Issue #2515 — the project's same-named instance defs are unioned in as a receiver the callee
|
|
948
|
+
# cannot type is ({#named_instance_callees}): they floor, never narrow.
|
|
949
|
+
def self_hand_off_callees(call_node)
|
|
950
|
+
found = resolve_self_callees(call_node)
|
|
951
|
+
return found unless enclosing_class_name_for(scope.self_type).nil?
|
|
952
|
+
|
|
953
|
+
send_hand_off = send_hand_off?(call_node)
|
|
954
|
+
target = send_hand_off ? call_node.arguments.arguments.first.unescaped.to_sym : call_node.name
|
|
955
|
+
named = named_instance_callees(target)
|
|
956
|
+
return named if named.equal?(CAPPED_HAND_OFF)
|
|
957
|
+
return found if named.empty?
|
|
958
|
+
|
|
959
|
+
named = named.map { |def_node, _offset, owner| [def_node, 1, owner] } if send_hand_off
|
|
960
|
+
UnionCallees.new(found + named)
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# True for a call on an explicit receiver: one the callee's body can type without its locals (a constant or
|
|
964
|
+
# `Const.new`), a local of the callee (#2515: {#local_receiver_callees}), or (issue #2595 for an ivar, #2336 for
|
|
965
|
+
# any other) one whose type the def's AST does not show, so it resolves by name ({#named_hand_off_callees}).
|
|
966
|
+
def hand_off_receiver?(node) = node.is_a?(Prism::CallNode) && !node.receiver.nil?
|
|
967
|
+
|
|
968
|
+
def constant_receiver_form?(receiver)
|
|
969
|
+
case receiver
|
|
970
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then true
|
|
971
|
+
when Prism::CallNode then receiver.name == :new && constant_node?(receiver.receiver)
|
|
972
|
+
else false
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# Issue #2515 — the callees of `local.name(p, o)` inside `def_node`, where `local` is a parameter or a local of
|
|
977
|
+
# the def. A local every assignment of which in the def is a constant receiver form (`Const`, `Const.new`) is
|
|
978
|
+
# typed inside the callee, as the constant receiver is ({#hand_off_receiver_callees}). Any other receiver (a
|
|
979
|
+
# parameter, a local assigned anything else, a block-local) has a type that depends on the caller, which is
|
|
980
|
+
# not a function of the graph node, so the call resolves by name instead ({#named_instance_callees}).
|
|
981
|
+
def local_receiver_callees(call_node, def_node, depth)
|
|
982
|
+
receiver = call_node.receiver
|
|
983
|
+
typed = receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == depth
|
|
984
|
+
forms = typed_local_forms(def_node, receiver.name) if typed
|
|
985
|
+
forms = nil if forms && local_object_opened?(def_node, call_node)
|
|
986
|
+
return named_hand_off_callees(call_node) if forms.nil?
|
|
987
|
+
|
|
988
|
+
found = forms.flat_map { |form| hand_off_receiver_callees(call_node, def_node, form) }
|
|
989
|
+
found.size > 1 ? found.uniq { |callee, _, owner| [callee.object_id, owner] } : found
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# The constant receiver forms every assignment of the local `name` in `def_node` holds, or nil when `name` is a
|
|
993
|
+
# parameter, is never assigned, or may hold anything else. The whole body is read, including the scopes the
|
|
994
|
+
# hand-off walk leaves out (a lambda, an `instance_exec` / `class_exec` / `define_method` block, a block that
|
|
995
|
+
# shadows the name), so a write there counts. The local is untyped when any write is not a constant form (an
|
|
996
|
+
# op-write, a multiple assignment, `for` or `rescue =>` target, a pattern capture, any other value), when it is
|
|
997
|
+
# the receiver of `send` / `public_send` / `__send__` / `singleton_class` or is assigned to another local
|
|
998
|
+
# (`g = h`), and, for every local, when the body calls `binding`, `eval`, `instance_eval` or
|
|
999
|
+
# `local_variable_set` on any receiver, which can write a local no node names. A local whose object the syntax
|
|
1000
|
+
# gives methods ({#local_object_opened?}) is untyped at that call. A per-def AST fact, memoised as {#def_census}.
|
|
1001
|
+
def typed_local_forms(def_node, name)
|
|
1002
|
+
def_census(:local_forms, def_node) { local_forms_of(def_node) }[name]
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# True when the file's syntax gives the receiver local's object methods the declaration tables do not show
|
|
1006
|
+
# (`def h.x`, `h.extend(M)`, `h.define_singleton_method`, `h.singleton_class...`, `class << h`, an
|
|
1007
|
+
# `h.instance_eval` that defines): {Analysis::CheckRules::LexicalMethodSites}, asked once per def for every
|
|
1008
|
+
# call on a local.
|
|
1009
|
+
def local_object_opened?(def_node, call_node)
|
|
1010
|
+
def_census(:opened_local_calls, def_node) { opened_local_call_offsets(def_node) }
|
|
1011
|
+
.include?(call_node.location.start_offset)
|
|
1012
|
+
end
|
|
475
1013
|
|
|
476
|
-
|
|
477
|
-
|
|
1014
|
+
def opened_local_call_offsets(def_node)
|
|
1015
|
+
require_relative "../analysis/check_rules/lexical_method_sites"
|
|
1016
|
+
sites = Analysis::CheckRules::LexicalMethodSites.new(def_node)
|
|
1017
|
+
offsets = Set.new
|
|
1018
|
+
Source::NodeWalker.each(def_node.body) do |node|
|
|
1019
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
478
1020
|
|
|
479
|
-
|
|
1021
|
+
offsets << node.location.start_offset if sites.singleton_local_def?(node)
|
|
1022
|
+
end
|
|
1023
|
+
offsets.freeze
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
DYNAMIC_LOCAL_CALLS = Set[:binding, :eval, :instance_eval, :local_variable_set].freeze
|
|
1027
|
+
DISPATCHING_CALLS = Set[:send, :public_send, :__send__, :singleton_class].freeze
|
|
1028
|
+
private_constant :DYNAMIC_LOCAL_CALLS, :DISPATCHING_CALLS
|
|
1029
|
+
|
|
1030
|
+
def local_forms_of(def_node)
|
|
1031
|
+
forms = {}
|
|
1032
|
+
tainted = Set.new
|
|
1033
|
+
return NO_FORMS if collect_local_forms(def_node.body, forms, tainted)
|
|
1034
|
+
|
|
1035
|
+
params = def_node.parameters
|
|
1036
|
+
forms.each_value(&:uniq!)
|
|
1037
|
+
forms.delete_if do |name, values|
|
|
1038
|
+
tainted.include?(name) || !values.all? { |value| constant_receiver_form?(value) } ||
|
|
1039
|
+
parameter_named?(params, name)
|
|
1040
|
+
end
|
|
1041
|
+
forms.empty? ? NO_FORMS : forms
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# Fills `forms` (`{ name => written values }`) and `tainted` from `node` and everything under it, a nested `def`
|
|
1045
|
+
# body excepted (its locals are its own). True when the body may write locals the nodes do not show.
|
|
1046
|
+
def collect_local_forms(node, forms, tainted)
|
|
1047
|
+
return false if node.nil?
|
|
1048
|
+
|
|
1049
|
+
return false if node.is_a?(Prism::DefNode)
|
|
1050
|
+
return true if node.is_a?(Prism::CallNode) && dynamic_local_call?(node, tainted)
|
|
1051
|
+
|
|
1052
|
+
note_local_write(node, forms, tainted)
|
|
1053
|
+
dynamic = false
|
|
1054
|
+
node.rigor_each_child { |child| dynamic = true if collect_local_forms(child, forms, tainted) }
|
|
1055
|
+
dynamic
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
def note_local_write(node, forms, tainted)
|
|
1059
|
+
case node
|
|
1060
|
+
when Prism::LocalVariableWriteNode
|
|
1061
|
+
(forms[node.name] ||= []) << node.value
|
|
1062
|
+
tainted << node.value.name if node.value.is_a?(Prism::LocalVariableReadNode)
|
|
1063
|
+
when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
|
|
1064
|
+
Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
|
|
1065
|
+
(forms[node.name] ||= []) << node
|
|
1066
|
+
end
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
# True for a `binding` / `eval` / ... call on any receiver (`Kernel.binding` included), which can write a local no
|
|
1070
|
+
# node names. A local receiving a dispatching call (`send`, `singleton_class`, ...) is tainted as a side effect.
|
|
1071
|
+
def dynamic_local_call?(node, tainted)
|
|
1072
|
+
return true if DYNAMIC_LOCAL_CALLS.include?(node.name)
|
|
1073
|
+
|
|
1074
|
+
tainted << node.receiver.name if DISPATCHING_CALLS.include?(node.name) &&
|
|
1075
|
+
node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
1076
|
+
false
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
NO_FORMS = {}.freeze
|
|
1080
|
+
private_constant :NO_FORMS
|
|
1081
|
+
|
|
1082
|
+
def parameter_named?(params, name)
|
|
1083
|
+
return false if params.nil?
|
|
1084
|
+
|
|
1085
|
+
all = [params.requireds, params.optionals, params.posts, params.keywords,
|
|
1086
|
+
[params.rest, params.keyword_rest, params.block]]
|
|
1087
|
+
all.flatten.compact.any? { |param| param.respond_to?(:name) && param.name == name }
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
# Issue #2515 — the `[def, 0, owner]` triples of every user instance def named `name`, for a hand-off on a
|
|
1091
|
+
# receiver whose type the callee cannot know. It over-approximates (it favours flooring, ADR-5), as
|
|
1092
|
+
# {#unrecorded_hand_off_callees} unions spellings. A def on a class nothing subclasses is walked under that
|
|
1093
|
+
# class. A def on a module, or on a class a project class names as its superclass, is {FLOOR_ALL}: the real
|
|
1094
|
+
# receiver may be an includer or a subclass whose overrides the walk would not see (ADR-127 WD4, as an inherited
|
|
1095
|
+
# def, {#walked_owner}). A name no user def has (a core or gem method) contributes nothing; a name with more
|
|
1096
|
+
# than {NAMED_HAND_OFF_CAP} defs answers {CAPPED_HAND_OFF}, which floors the handed parameter outright.
|
|
1097
|
+
def named_instance_callees(name)
|
|
1098
|
+
owners = instance_def_owners(name)
|
|
1099
|
+
return NO_CALLEES if owners.empty?
|
|
1100
|
+
return CAPPED_HAND_OFF if owners.size > NAMED_HAND_OFF_CAP
|
|
1101
|
+
|
|
1102
|
+
opaque = subclassing_opaque?
|
|
1103
|
+
UnionCallees.new(owners.filter_map do |class_name, open|
|
|
1104
|
+
found = scope.user_def_for(class_name, name)
|
|
1105
|
+
next if found.nil?
|
|
1106
|
+
|
|
1107
|
+
[found, 0, open || opaque ? FLOOR_ALL : CalleeOwner.new(Type::Combinator.nominal_of(class_name))]
|
|
1108
|
+
end)
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# A resolution by name, whose candidates are guesses at the receiver: every edge it yields is an
|
|
1112
|
+
# over-approximation ({CalleeMutationChain::Edge#approx}), as is any hand-off with more than one candidate.
|
|
1113
|
+
class UnionCallees < Array; end
|
|
1114
|
+
private_constant :UnionCallees
|
|
1115
|
+
|
|
1116
|
+
# The most defs one name may have for {#named_instance_callees} to walk them; more floors the parameter. A cap
|
|
1117
|
+
# on the name key's fan-out that floors, not a cut of the walk, so ADR-127's rejection of out-degree caps does
|
|
1118
|
+
# not apply: the answer stays a function of the graph node.
|
|
1119
|
+
NAMED_HAND_OFF_CAP = 8
|
|
1120
|
+
|
|
1121
|
+
# The answer for a name above the cap; {#compute_forwarding_edges} turns it into a floor of each handed
|
|
1122
|
+
# parameter.
|
|
1123
|
+
CAPPED_HAND_OFF = [[nil, 0, nil].freeze].freeze
|
|
1124
|
+
private_constant :NAMED_HAND_OFF_CAP, :CAPPED_HAND_OFF
|
|
1125
|
+
|
|
1126
|
+
# The `[class, open]` owners of `name` ({Scope#user_instance_def_owners}). A function of the discovery and the
|
|
1127
|
+
# name alone, so its run-scoped memo is keyed by both; the answer reads no constant and no narrowing, so
|
|
1128
|
+
# {#hand_off_memo_bypassed?} does not bypass it. A hit does not reach the scope, so a dependency recorder is told
|
|
1129
|
+
# of the reads here. Built per name, not as a whole index: a run holds a discovery per file, and a whole-index
|
|
1130
|
+
# build per file cost more than the walks it served.
|
|
1131
|
+
def instance_def_owners(name)
|
|
1132
|
+
return scope.user_instance_def_owners(name) if MemoAudit.suspended?
|
|
1133
|
+
|
|
1134
|
+
table = (Thread.current[INSTANCE_DEF_INDEX_KEY] ||= ObjectSpace::WeakKeyMap.new)
|
|
1135
|
+
by_name = (table[scope.discovery] ||= {})
|
|
1136
|
+
return by_name[name] = scope.user_instance_def_owners(name) unless by_name.key?(name)
|
|
1137
|
+
|
|
1138
|
+
record_instance_def_owner_reads(name, by_name[name]) if Analysis::DependencyRecorder.active?
|
|
1139
|
+
MemoAudit.check("instance def owners", by_name[name], name) { scope.user_instance_def_owners(name) }
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
# {Scope#subclassing_opacity}'s flag, memoised beside the owners per discovery; a recorder is told its keys on
|
|
1143
|
+
# every call, hit or not.
|
|
1144
|
+
def subclassing_opaque?
|
|
1145
|
+
return scope.subclassing_opacity.first if MemoAudit.suspended?
|
|
1146
|
+
|
|
1147
|
+
table = (Thread.current[INSTANCE_DEF_INDEX_KEY] ||= ObjectSpace::WeakKeyMap.new)
|
|
1148
|
+
by_name = (table[scope.discovery] ||= {})
|
|
1149
|
+
entry = by_name[OPACITY_ENTRY] ||= scope.subclassing_opacity
|
|
1150
|
+
Analysis::DependencyRecorder.read_keys(entry.last) if Analysis::DependencyRecorder.active?
|
|
1151
|
+
MemoAudit.check("subclassing opacity", entry, OPACITY_ENTRY) { scope.subclassing_opacity }.first
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
OPACITY_ENTRY = :"<subclassing-opacity>"
|
|
1155
|
+
private_constant :OPACITY_ENTRY
|
|
1156
|
+
|
|
1157
|
+
def record_instance_def_owner_reads(name, owners)
|
|
1158
|
+
Analysis::DependencyRecorder.read_name(:defines, name)
|
|
1159
|
+
owners.map(&:first).each { |owner| Analysis::DependencyRecorder.read_last_segment(:subclass_of, owner) }
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
INSTANCE_DEF_INDEX_KEY = RunScopedSlots.register(:rigor_callee_instance_def_index)
|
|
1163
|
+
private_constant :INSTANCE_DEF_INDEX_KEY
|
|
1164
|
+
|
|
1165
|
+
def constant_node?(node) = node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
1166
|
+
|
|
1167
|
+
# `census` (a `callee_*_mutated_parameters` name) of `def_node`, read through `owner`, the {CalleeOwner} the
|
|
1168
|
+
# callee's hand-offs resolve in, or this evaluator when the callee is a self-call's.
|
|
1169
|
+
def census_of(owner, census, def_node)
|
|
1170
|
+
return floor_all_census(census, def_node) if owner == FLOOR_ALL
|
|
1171
|
+
return floor_every_census(census) if owner == FLOOR_EVERY
|
|
1172
|
+
|
|
1173
|
+
(owner.nil? ? self : owner_evaluator(owner)).__send__(census, def_node)
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
# The {FLOOR_EVERY} answer: every positional and keyword argument mutated, no hand-offs.
|
|
1177
|
+
def floor_every_census(census)
|
|
1178
|
+
BudgetTrace.hit(BudgetTrace::CALLEE_CHAIN_CAP)
|
|
1179
|
+
census == :forwarding_edges ? NO_FORWARDS : EVERY_ARGUMENT
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
# The {FLOOR_ALL} answer: every content parameter mutated, and (issue #2595) a named rest or keyword rest at
|
|
1183
|
+
# {ANY_SLOT} / {ANY_KEYWORD} ({#forwarding_slots}), no hand-offs.
|
|
1184
|
+
def floor_all_census(census, def_node)
|
|
1185
|
+
BudgetTrace.hit(BudgetTrace::CALLEE_CHAIN_CAP)
|
|
1186
|
+
census == :forwarding_edges ? NO_FORWARDS : forwarding_slots(def_node)
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
# The local-variable reads `call_node` passes to the parameters of `mutated`, a `{ name => slot }` census, the
|
|
1190
|
+
# first `offset` arguments not being the callee's (the `:name` of a `send` hand-off, {#resolve_self_callees}).
|
|
1191
|
+
def arguments_for_slots(call_node, mutated, offset)
|
|
480
1192
|
return NO_ARGUMENT_READS if mutated.empty?
|
|
481
1193
|
|
|
482
1194
|
argument_nodes = call_node.arguments.arguments
|
|
483
|
-
mutated.values.uniq
|
|
484
|
-
|
|
1195
|
+
values = mutated.values.uniq
|
|
1196
|
+
reads = values.filter_map do |slot|
|
|
1197
|
+
next if slot == ANY_SLOT || slot == ANY_KEYWORD || (slot.is_a?(Integer) && (slot + offset).negative?)
|
|
1198
|
+
|
|
1199
|
+
argument = slot.is_a?(Symbol) ? keyword_argument(argument_nodes, slot) : argument_nodes[slot + offset]
|
|
1200
|
+
argument = argument.value if argument.is_a?(Prism::ImplicitNode)
|
|
485
1201
|
argument if argument.is_a?(Prism::LocalVariableReadNode)
|
|
486
1202
|
end
|
|
1203
|
+
reads += rest_arguments(argument_nodes, offset) if values.include?(ANY_SLOT)
|
|
1204
|
+
reads += keyword_rest_arguments(argument_nodes) if values.include?(ANY_KEYWORD)
|
|
1205
|
+
reads.uniq
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
# Every local read passed positionally from `offset` on: what a callee that hands its rest parameter to `super`
|
|
1209
|
+
# may mutate ({#super_forwarded_parameters}).
|
|
1210
|
+
def rest_arguments(argument_nodes, offset)
|
|
1211
|
+
argument_nodes.drop([offset, 0].max).grep(Prism::LocalVariableReadNode)
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
# Every local read passed as a keyword value: what a callee that hands its keyword rest parameter to `super` may
|
|
1215
|
+
# mutate ({#super_forwarded_parameters}).
|
|
1216
|
+
def keyword_rest_arguments(argument_nodes)
|
|
1217
|
+
hash = argument_nodes.last
|
|
1218
|
+
return NO_ARGUMENT_READS unless hash.is_a?(Prism::KeywordHashNode)
|
|
1219
|
+
|
|
1220
|
+
hash.elements.filter_map do |pair|
|
|
1221
|
+
value = pair.value if pair.is_a?(Prism::AssocNode)
|
|
1222
|
+
value = value.value if value.is_a?(Prism::ImplicitNode)
|
|
1223
|
+
value if value.is_a?(Prism::LocalVariableReadNode)
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
# The slot {#super_forwarded_parameters} gives a keyword rest parameter: every keyword value of the call.
|
|
1228
|
+
ANY_KEYWORD = :__any_keyword__
|
|
1229
|
+
private_constant :ANY_KEYWORD
|
|
1230
|
+
|
|
1231
|
+
# The value `argument_nodes` pass for the keyword `name` (`poke(acc: a)`), or nil.
|
|
1232
|
+
def keyword_argument(argument_nodes, name)
|
|
1233
|
+
hash = argument_nodes.last
|
|
1234
|
+
return unless hash.is_a?(Prism::KeywordHashNode)
|
|
1235
|
+
|
|
1236
|
+
pair = hash.elements.find do |element|
|
|
1237
|
+
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
1238
|
+
element.key.unescaped.to_sym == name
|
|
1239
|
+
end
|
|
1240
|
+
pair&.value
|
|
487
1241
|
end
|
|
488
1242
|
|
|
489
1243
|
NO_ARGUMENT_READS = [].freeze
|
|
@@ -509,16 +1263,37 @@ module Rigor
|
|
|
509
1263
|
# evaluator's statement and write-back passes ({#with_repeated_or_write_marks}), type every run of a repeating
|
|
510
1264
|
# body from one entry scope, so its slot's gradual type may be what an EARLIER run stored rather than an
|
|
511
1265
|
# absence of evidence.
|
|
1266
|
+
#
|
|
1267
|
+
# On a receiver that is itself untracked (a `Dynamic` / `Top` constituent, the #544 test the narrowing
|
|
1268
|
+
# record takes: a parameter, an unbound ivar), the slot may hold what an earlier call stored, so the reading
|
|
1269
|
+
# keeps the rvalue only as far as its class is known ({#untracked_memo_value}, #2292): read as the fresh
|
|
1270
|
+
# literal, `(h[k] ||= {})[:name].upcase` reported a call on `nil` and `(h[k] ||= 1).upcase` one on `1`. A
|
|
1271
|
+
# tracked receiver keeps the literal, so a block fold over a captured `pool = {}`, whose in-place widening
|
|
1272
|
+
# reads `Hash[Dynamic[top], Dynamic[top]]`, still answers `find { |s| (pool[s] ||= s) == "b" }` as `"b"`.
|
|
512
1273
|
def index_compound_write_value(node)
|
|
513
1274
|
return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
|
|
514
1275
|
|
|
515
1276
|
current = index_read_type(node, scope)
|
|
516
1277
|
rhs = scope.type_of(node.value, tracer: tracer)
|
|
517
|
-
|
|
1278
|
+
if memoizing_index_read?(node, current, rhs)
|
|
1279
|
+
return rhs if tracked_memo_receiver?(node.receiver)
|
|
1280
|
+
|
|
1281
|
+
memo = untracked_memo_value(rhs)
|
|
1282
|
+
return memo if memo
|
|
1283
|
+
end
|
|
518
1284
|
|
|
519
1285
|
index_write_stored_type(node, scope, current: current, rhs: rhs)
|
|
520
1286
|
end
|
|
521
1287
|
|
|
1288
|
+
# The value of `recv.each_with_object(memo) { |x, acc| … }` given `call_type`, the call's dispatched type
|
|
1289
|
+
# (issue #2411): the memo as the block's content mutations of `acc` leave it ({#each_with_object_return}).
|
|
1290
|
+
# `ExpressionTyper` reads it for every call, so a value position — a method's tail, which
|
|
1291
|
+
# {DefReturnTyper} types through `Scope#type_of` — answers what a statement does; read off the dispatch
|
|
1292
|
+
# alone, `def m = xs.each_with_object({}) { |x, h| h[x] = 1 }` returned the memo argument's `{}`.
|
|
1293
|
+
def each_with_object_value(call_node, call_type)
|
|
1294
|
+
each_with_object_return(call_node, call_type)
|
|
1295
|
+
end
|
|
1296
|
+
|
|
522
1297
|
private
|
|
523
1298
|
|
|
524
1299
|
attr_reader :scope, :tracer
|
|
@@ -550,11 +1325,11 @@ module Rigor
|
|
|
550
1325
|
end
|
|
551
1326
|
|
|
552
1327
|
# `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
|
|
553
|
-
# propagate left-to-right) and binds `name` to the result type.
|
|
554
|
-
# to a follow-up; for now they degrade to "type the rhs, do not rebind" via the default branch in {#evaluate}.
|
|
1328
|
+
# propagate left-to-right) and binds `name` to the result type.
|
|
555
1329
|
def eval_local_write(node)
|
|
556
1330
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
557
1331
|
bound = bind_local_write(node, rhs_type, post_rhs)
|
|
1332
|
+
bound = bound.with_construction_fact(node.name, :synthesised) if synthesised_write?(node, rhs_type)
|
|
558
1333
|
# Issue #667 — `m = AppConfig::MODE` makes `m` a copy of a constant the reader's file does not
|
|
559
1334
|
# declare, so `m == :production` folding to `true` is the project's configuration and not a logic
|
|
560
1335
|
# error the author can see. Stamped LAST, across every binding path above: an RHS can be both an
|
|
@@ -565,6 +1340,53 @@ module Rigor
|
|
|
565
1340
|
[rhs_type, bound.with_published_constant_mark(:local, node.name)]
|
|
566
1341
|
end
|
|
567
1342
|
|
|
1343
|
+
# Issue #2555 L3 — whether the object a local write binds is one the engine built itself, so the binding
|
|
1344
|
+
# carries the `:synthesised` construction fact ({Scope#construction_fact?}): a collection literal (`[...]`,
|
|
1345
|
+
# `{...}`), or a constructor call on the core class (`Array.new(n, 0)`, `Hash.new(0)`, `Set.new(xs)`,
|
|
1346
|
+
# `Set[1]`) whose value is a carrier of that class. Nothing else is stamped: not a copy of another binding
|
|
1347
|
+
# (`b = a`), a call whose return a signature declares, a parameter, or an instance variable.
|
|
1348
|
+
# The fact is withheld from a local that may escape anywhere in the body ({ConstructionEscape}): a store through
|
|
1349
|
+
# another holder of the object is one no seam sees, and the fact would leave the carrier without the floor that
|
|
1350
|
+
# covered it.
|
|
1351
|
+
def synthesised_write?(node, rhs_type)
|
|
1352
|
+
synthesised_construction?(node.value, rhs_type) &&
|
|
1353
|
+
!ConstructionEscape.escaped?(scope.match_frame&.body, node.name, constructed_carrier_class(rhs_type), scope)
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
def synthesised_construction?(value_node, rhs_type)
|
|
1357
|
+
case value_node
|
|
1358
|
+
when Prism::ArrayNode, Prism::HashNode then true
|
|
1359
|
+
when Prism::CallNode then core_constructor_call?(value_node, rhs_type)
|
|
1360
|
+
else false
|
|
1361
|
+
end
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
CORE_CONSTRUCTOR_NAMES = %i[new []].to_set.freeze
|
|
1365
|
+
private_constant :CORE_CONSTRUCTOR_NAMES
|
|
1366
|
+
|
|
1367
|
+
# The constant receiver must resolve to the core class itself (a project's own `Admin::Set` does not), and the
|
|
1368
|
+
# value must be a carrier of that class.
|
|
1369
|
+
def core_constructor_call?(call, rhs_type)
|
|
1370
|
+
receiver = call.receiver
|
|
1371
|
+
return false unless CORE_CONSTRUCTOR_NAMES.include?(call.name)
|
|
1372
|
+
return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
1373
|
+
|
|
1374
|
+
class_name = constructed_carrier_class(rhs_type)
|
|
1375
|
+
return false if class_name.nil?
|
|
1376
|
+
|
|
1377
|
+
receiver_type = scope.type_of(receiver, tracer: tracer)
|
|
1378
|
+
receiver_type.is_a?(Type::Singleton) && receiver_type.class_name == class_name
|
|
1379
|
+
end
|
|
1380
|
+
|
|
1381
|
+
def constructed_carrier_class(type)
|
|
1382
|
+
case type
|
|
1383
|
+
when Type::Tuple then "Array"
|
|
1384
|
+
when Type::HashShape then "Hash"
|
|
1385
|
+
when Type::Nominal then type.class_name if %w[Array Hash Set].include?(type.class_name)
|
|
1386
|
+
when Type::Constant then "Set" if type.value.is_a?(::Set)
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
1389
|
+
|
|
568
1390
|
# The binding half of {#eval_local_write}: the three mutually exclusive provenance paths a local write
|
|
569
1391
|
# can take, unchanged from when they were inline.
|
|
570
1392
|
def bind_local_write(node, rhs_type, post_rhs)
|
|
@@ -664,8 +1486,7 @@ module Rigor
|
|
|
664
1486
|
# Slice 7 phase 1 — instance/class/global variable writes. Each handler evaluates the rvalue under the entry scope
|
|
665
1487
|
# and binds the named variable into the post-scope's per-kind binding map. The expression value is the rvalue
|
|
666
1488
|
# type, matching Ruby's semantics. Bindings are method-local: a fresh scope is built at every `def` entry through
|
|
667
|
-
# `build_method_entry_scope
|
|
668
|
-
# lands.
|
|
1489
|
+
# `build_method_entry_scope`; cross-method ivar and cvar state enters it through the class-level seeds.
|
|
669
1490
|
def eval_ivar_write(node)
|
|
670
1491
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
671
1492
|
# Issue #1446 — a write ends a class guard's narrowing of the variable, as it does a global's.
|
|
@@ -678,7 +1499,19 @@ module Rigor
|
|
|
678
1499
|
# stamped by {#seed_instance_ivars}.
|
|
679
1500
|
bound = bound.with_published_constant_mark(:ivar, node.name) if
|
|
680
1501
|
published_constant_copy?(node.value, rhs_type, post_rhs)
|
|
681
|
-
[rhs_type, bound]
|
|
1502
|
+
[rhs_type, mark_inferred_param_ivar(bound, node)]
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# Issue #2189 — the ivar twin of {#bind_local_write}'s ADR-67 WD6b stamp: `@x = param; @x.upcase` reads the
|
|
1506
|
+
# parameter's open-call-site lower bound back, so the ivar carries the `:inferred_param` mark under its
|
|
1507
|
+
# `@`-prefixed name and {Analysis::CheckRules::InferredParamGuard} declines on the read. A write from any
|
|
1508
|
+
# other RHS clears it. Method-local only: the class-ivar census seeding a sibling method types the RHS
|
|
1509
|
+
# without the inference seed, so no lower bound crosses the method boundary to guard.
|
|
1510
|
+
def mark_inferred_param_ivar(bound, node)
|
|
1511
|
+
return bound.with_inferred_param_mark(node.name) if
|
|
1512
|
+
Analysis::CheckRules::InferredParamGuard.rooted?(node.value, scope)
|
|
1513
|
+
|
|
1514
|
+
bound.without_inferred_param_mark(node.name)
|
|
682
1515
|
end
|
|
683
1516
|
|
|
684
1517
|
def eval_cvar_write(node)
|
|
@@ -832,11 +1665,24 @@ module Rigor
|
|
|
832
1665
|
|
|
833
1666
|
def compound_eval(node, kind:, op:) # rubocop:disable Naming/MethodParameterName
|
|
834
1667
|
current_type = current_type_for(kind, node.name)
|
|
835
|
-
rhs_type, post_rhs = sub_eval(node.value,
|
|
1668
|
+
rhs_type, post_rhs = sub_eval(node.value, compound_value_scope(kind, node.name, op))
|
|
836
1669
|
result_type = compound_result_type(current_type, rhs_type, op)
|
|
837
1670
|
[result_type, rebind_variable(post_rhs, kind, node.name, result_type)]
|
|
838
1671
|
end
|
|
839
1672
|
|
|
1673
|
+
# Issue #2214 — the scope a compound write's value operand runs in. `x &&= v` runs `v` only when `x` is truthy,
|
|
1674
|
+
# as `x && (x = v)` does, so `v` reads `x` narrowed to its truthy part: `c &&= c + 1` on an `Integer?` `c` reads
|
|
1675
|
+
# `Integer`, not the `nil` that drew `call.possible-nil-receiver`. `x ||= v` and an operator write run `v` from
|
|
1676
|
+
# the scope they start in; a variable the scope does not bind is left alone.
|
|
1677
|
+
def compound_value_scope(kind, name, operator)
|
|
1678
|
+
return scope unless operator == :and
|
|
1679
|
+
|
|
1680
|
+
current = scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
|
|
1681
|
+
return scope if current.nil?
|
|
1682
|
+
|
|
1683
|
+
scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, Narrowing.narrow_truthy(current))
|
|
1684
|
+
end
|
|
1685
|
+
|
|
840
1686
|
VAR_KIND_GETTERS = {
|
|
841
1687
|
local: :local, ivar: :ivar, cvar: :cvar, global: :global
|
|
842
1688
|
}.freeze
|
|
@@ -879,8 +1725,7 @@ module Rigor
|
|
|
879
1725
|
# narrowing keyed on `(receiver_kind, receiver_name,
|
|
880
1726
|
# literal_key)` when both receiver and key are stable
|
|
881
1727
|
# (see {Inference::IndexedNarrowing}). Unstable shapes
|
|
882
|
-
#
|
|
883
|
-
# `Prism::IndexOrWriteNode` default-branch behaviour.
|
|
1728
|
+
# record no narrowing.
|
|
884
1729
|
#
|
|
885
1730
|
# The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
|
|
886
1731
|
# post-`||=` value, not the rvalue alone.
|
|
@@ -906,7 +1751,63 @@ module Rigor
|
|
|
906
1751
|
arg_types: index_write_arg_types(node, result_type))
|
|
907
1752
|
post = post.with_indexed_narrowing(*address, result_type) if address
|
|
908
1753
|
|
|
909
|
-
|
|
1754
|
+
# The value the expression yields takes the memoizing reading ({#index_compound_write_value}, #1155) for a
|
|
1755
|
+
# fresh container or construction rvalue, with a container's literal precision erased
|
|
1756
|
+
# ({#memo_bound_value}).
|
|
1757
|
+
[memo_bound_value(node, result_type), forget_rebound_specials(post, node)]
|
|
1758
|
+
end
|
|
1759
|
+
|
|
1760
|
+
# What `x = (tbl[k] ||= v)` binds `x` to. Where {#index_compound_write_value} reads the memoizing idiom, the
|
|
1761
|
+
# slot may hold ANY earlier value of the table, so `x` is the rvalue only as far as its CLASS is known: a
|
|
1762
|
+
# `{}` / `[]` rvalue (a shape or tuple) erases to `Hash[untyped, untyped]` / `Array[untyped]`, because the
|
|
1763
|
+
# stored table is not empty after the first store (`entry[:name].upcase` is no call on nil), and a
|
|
1764
|
+
# construction (`new(...)`, a nominal) is kept. Every other rvalue (a Constant, a union) keeps the stored
|
|
1765
|
+
# `Dynamic | v`, since a literal would read the slot as freshly created at every later read.
|
|
1766
|
+
def memo_bound_value(node, stored)
|
|
1767
|
+
return stored unless node.is_a?(Prism::IndexOrWriteNode)
|
|
1768
|
+
|
|
1769
|
+
value = index_compound_write_value(node)
|
|
1770
|
+
return stored if value == stored
|
|
1771
|
+
|
|
1772
|
+
erased = erase_memo_container(value)
|
|
1773
|
+
erased || stored
|
|
1774
|
+
end
|
|
1775
|
+
|
|
1776
|
+
# The memoizing value on an untracked receiver, or nil when it keeps the stored `Dynamic[top] | v`. A `{}` /
|
|
1777
|
+
# `[]` rvalue (a shape, a tuple, a bare `Hash` / `Array`) erases to `Hash[untyped, untyped]` /
|
|
1778
|
+
# `Array[untyped]`, since the table is not empty after the first store. A construction (a nominal,
|
|
1779
|
+
# parameterised or not, or a `Data` / `Struct` instance, possibly with a `nil` / `false` arm) is kept as the
|
|
1780
|
+
# #1155 reading has it. Anything else carries literal precision (`1`, `:a`, an integer range) and yields nil.
|
|
1781
|
+
def untracked_memo_value(rhs)
|
|
1782
|
+
erased = erase_memo_literal(rhs)
|
|
1783
|
+
return erased if erased
|
|
1784
|
+
|
|
1785
|
+
truthy = Narrowing.narrow_truthy(rhs)
|
|
1786
|
+
members = truthy.is_a?(Type::Union) ? truthy.members : [truthy]
|
|
1787
|
+
rhs if members.all? { |member| MEMO_CONSTRUCTIONS.any? { |klass| member.is_a?(klass) } }
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
MEMO_CONSTRUCTIONS = [Type::Nominal, Type::DataInstance, Type::StructInstance, Type::Dynamic, Type::Top].freeze
|
|
1791
|
+
private_constant :MEMO_CONSTRUCTIONS
|
|
1792
|
+
|
|
1793
|
+
def erase_memo_literal(type)
|
|
1794
|
+
return erase_memo_container(type) unless type.is_a?(Type::Nominal)
|
|
1795
|
+
|
|
1796
|
+
erase_memo_container(type) if type.type_args.empty? && %w[Hash Array].include?(type.class_name)
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
def erase_memo_container(type)
|
|
1800
|
+
any = Type::Combinator.untyped
|
|
1801
|
+
case type
|
|
1802
|
+
when Type::HashShape then Type::Combinator.nominal_of("Hash", type_args: [any, any])
|
|
1803
|
+
when Type::Tuple then Type::Combinator.nominal_of("Array", type_args: [any])
|
|
1804
|
+
when Type::Nominal
|
|
1805
|
+
case type.class_name
|
|
1806
|
+
when "Hash" then Type::Combinator.nominal_of("Hash", type_args: [any, any])
|
|
1807
|
+
when "Array" then Type::Combinator.nominal_of("Array", type_args: [any])
|
|
1808
|
+
else type
|
|
1809
|
+
end
|
|
1810
|
+
end
|
|
910
1811
|
end
|
|
911
1812
|
|
|
912
1813
|
# `h[k] &&= v` / `h[k] += v`. Neither had a handler, so both fell to `evaluate`'s default — typed as a pure
|
|
@@ -979,6 +1880,28 @@ module Rigor
|
|
|
979
1880
|
end
|
|
980
1881
|
end
|
|
981
1882
|
|
|
1883
|
+
# Whether a memoizing `||=` on `receiver` reads as the fresh rvalue ({#index_compound_write_value}): the
|
|
1884
|
+
# receiver's type passes the #544 test, and the receiver is a local the current body binds, captured into a
|
|
1885
|
+
# block or not, and no parameter of the enclosing method names ({MatchRebinding::Frame#body_local?}), or
|
|
1886
|
+
# an object fresh at every run (`Hash.new`, {RepeatedOrWrites.fresh_object?}) (issue
|
|
1887
|
+
# #2354). An ivar, cvar or global read, a parameter, or any other expression (a call result, a
|
|
1888
|
+
# parenthesised `||=`) is a table whose gradual slot another call, or an earlier run of this one, may have
|
|
1889
|
+
# filled — an ivar seeded `{}` in `initialize`, a parameter declared `Hash[Symbol, untyped]` — so it widens
|
|
1890
|
+
# through {#untracked_memo_value} like an untracked receiver. Without a frame (no method, class or file
|
|
1891
|
+
# body stamped one) a local receiver is treated as body-bound, as before #2354.
|
|
1892
|
+
def tracked_memo_receiver?(receiver)
|
|
1893
|
+
return false unless body_bound_memo_receiver?(receiver)
|
|
1894
|
+
|
|
1895
|
+
IndexedNarrowing.fully_tracked_receiver_type?(scope.type_of(receiver, tracer: tracer))
|
|
1896
|
+
end
|
|
1897
|
+
|
|
1898
|
+
def body_bound_memo_receiver?(receiver)
|
|
1899
|
+
return RepeatedOrWrites.fresh_object?(receiver) unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
1900
|
+
|
|
1901
|
+
frame = scope.match_frame
|
|
1902
|
+
frame.nil? || frame.body_local?(receiver.name)
|
|
1903
|
+
end
|
|
1904
|
+
|
|
982
1905
|
# True when {#index_compound_write_value} reads the `||=` `node` as the memoization idiom's rvalue: the slot
|
|
983
1906
|
# reads wholly gradual, the rvalue can store something truthy, and no block-return pass marked the site.
|
|
984
1907
|
def memoizing_index_read?(node, current, rhs)
|
|
@@ -1026,17 +1949,46 @@ module Rigor
|
|
|
1026
1949
|
# Both mutator-receiver widenings, against one typing of the call's arguments: the bindings the
|
|
1027
1950
|
# receiver NAMES ({MutationWidening.widen_after_call}) and, when it names none because the
|
|
1028
1951
|
# receiver is an element read into a local, that element's pin inside its container
|
|
1029
|
-
# ({ElementReadWidening.widen_element_read}, issue #643)
|
|
1952
|
+
# ({ElementReadWidening.widen_element_read}, issue #643) — and, issue #2499, the String a stdlib
|
|
1953
|
+
# out-buffer call fills ({OutBufferMutation}), which names no receiver of the call at all.
|
|
1030
1954
|
def widen_mutated_receivers(call_node, current_scope)
|
|
1031
1955
|
arg_types = mutator_arg_types(call_node, current_scope)
|
|
1032
1956
|
widened = MutationWidening.widen_after_call(call_node: call_node, current_scope: current_scope,
|
|
1033
|
-
arg_types: arg_types
|
|
1034
|
-
|
|
1957
|
+
arg_types: arg_types,
|
|
1958
|
+
rewritten_element: rewritten_element(call_node, current_scope))
|
|
1959
|
+
widened = ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened,
|
|
1960
|
+
arg_types: arg_types)
|
|
1961
|
+
# Issue #2499 — a stdlib call that fills a String argument (`io.read(n, buf)`, `StringIO.new(s)`).
|
|
1962
|
+
OutBufferMutation.floor(call_node, widened)
|
|
1963
|
+
end
|
|
1964
|
+
|
|
1965
|
+
# Issue #2427 — the element an element rewriter (`map!`, `collect!`, `flatten!`) leaves in its receiver, read
|
|
1966
|
+
# off what the non-bang counterpart answers for the same receiver, block and arguments
|
|
1967
|
+
# ({RewriteMutation::TYPED_COUNTERPARTS}): `a.map!(&:to_i)` stores what `a.map(&:to_i)` returns. `nil` when the
|
|
1968
|
+
# call is no such rewriter, or the counterpart has no single element; the widening then takes
|
|
1969
|
+
# {RewriteMutation}'s gradual arm.
|
|
1970
|
+
#
|
|
1971
|
+
# `nil` too when the receiver may name more than one binding (`(flag ? a : b).map!(&:to_i)`): Ruby rewrites
|
|
1972
|
+
# only the one the branch selects, and typing every candidate gave the other one an element it never holds.
|
|
1973
|
+
#
|
|
1974
|
+
# The block and receiver gates only save typing a counterpart nothing consumes: with no block `map` answers an
|
|
1975
|
+
# Enumerator, which has no element to read, and {RewriteMutation.typed} answers nothing for a receiver the
|
|
1976
|
+
# widening does not rewrite.
|
|
1977
|
+
def rewritten_element(call_node, current_scope)
|
|
1978
|
+
counterpart = RewriteMutation::TYPED_COUNTERPARTS[call_node.name]
|
|
1979
|
+
return nil if counterpart.nil?
|
|
1980
|
+
return nil if counterpart != :flatten && call_node.block.nil? # cost guard
|
|
1981
|
+
return nil unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) # cost guard
|
|
1982
|
+
return nil unless ReceiverAlias.mutated_reads(call_node.receiver).size == 1
|
|
1983
|
+
|
|
1984
|
+
RewriteMutation.counterpart_element(operand_scope.type_of(call_node.copy(name: counterpart)))
|
|
1035
1985
|
end
|
|
1036
1986
|
|
|
1037
1987
|
# `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
|
|
1038
1988
|
def mutator_arg_types(call_node, current_scope)
|
|
1039
|
-
|
|
1989
|
+
name = call_node.name
|
|
1990
|
+
unless ContentJoin::CONTENT_ADDERS.include?(name) || StringMutation::TRANSLATORS.include?(name) ||
|
|
1991
|
+
SetMutation::CONTENT_ADDERS.include?(name)
|
|
1040
1992
|
return MutationWidening::NO_ARG_TYPES
|
|
1041
1993
|
end
|
|
1042
1994
|
unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
|
|
@@ -1306,7 +2258,7 @@ module Rigor
|
|
|
1306
2258
|
sub_eval(node.statements, scope)
|
|
1307
2259
|
end
|
|
1308
2260
|
|
|
1309
|
-
# `case pred; when ...; when ...; else; end` and the pattern-
|
|
2261
|
+
# `case pred; when ...; when ...; else; end` and the pattern-matching variant. The predicate's post-scope is
|
|
1310
2262
|
# shared with every branch (including the else); branches are evaluated independently and merged with
|
|
1311
2263
|
# nil-injection so half-bound names degrade to `T | nil`.
|
|
1312
2264
|
def eval_case(node)
|
|
@@ -1446,7 +2398,10 @@ module Rigor
|
|
|
1446
2398
|
after
|
|
1447
2399
|
end
|
|
1448
2400
|
body_entry = reduce_scopes_with_nil_injection(test_exits)
|
|
1449
|
-
|
|
2401
|
+
if rebound
|
|
2402
|
+
body_entry, after = [body_entry, after].map { |s| forget_called_global_subject(subject, s) }
|
|
2403
|
+
return [Narrowing.apply_when_regex_globals(conditions, body_entry), after, true]
|
|
2404
|
+
end
|
|
1450
2405
|
|
|
1451
2406
|
[Narrowing.case_when_scopes(subject, conditions, body_entry).first,
|
|
1452
2407
|
Narrowing.case_when_scopes(subject, conditions, after).last, false]
|
|
@@ -1493,26 +2448,56 @@ module Rigor
|
|
|
1493
2448
|
# reads: its binding moved, the test writes it, or — for an instance variable — a call in the test may write
|
|
1494
2449
|
# it ({InterveningIvarReset.may_write?}), or — for a global or constant — the test may run code that rebinds
|
|
1495
2450
|
# one ({GuardRebinding.may_rebind?}).
|
|
2451
|
+
#
|
|
2452
|
+
# A subject that writes a local or instance variable (`case (@x = v)`) is read as that variable, as
|
|
2453
|
+
# {Narrowing.case_when_scopes} narrows it (issue #2211). For such an instance variable the binding test above
|
|
2454
|
+
# answers first (the test's call widens it in {#run_when_test}), so its `may_write?` arm is a guard.
|
|
1496
2455
|
def when_test_rebinds_subject?(subject, condition, before, after)
|
|
2456
|
+
subject = case_subject_variable(subject)
|
|
1497
2457
|
name = case_subject_name(subject)
|
|
1498
2458
|
return false if name.nil?
|
|
1499
2459
|
return true unless case_subject_binding(subject, before) == case_subject_binding(subject, after)
|
|
1500
2460
|
return true if OperandEffects.written_variables(condition).include?(name)
|
|
1501
2461
|
|
|
1502
2462
|
case subject
|
|
1503
|
-
when Prism::InstanceVariableReadNode
|
|
2463
|
+
when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
|
|
1504
2464
|
InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
|
|
1505
|
-
when Prism::LocalVariableReadNode
|
|
2465
|
+
when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
|
|
1506
2466
|
false
|
|
2467
|
+
when Prism::GlobalVariableReadNode
|
|
2468
|
+
# Issue #2314 — the question the statement walk asks past a call ({#forget_reached_globals}).
|
|
2469
|
+
body = when_lambda_body(condition)
|
|
2470
|
+
GlobalWriterReach.may_write?(condition, name, before) ||
|
|
2471
|
+
(!body.nil? && GlobalWriterReach.may_write?(body, name, before))
|
|
1507
2472
|
else
|
|
1508
2473
|
body = when_lambda_body(condition)
|
|
1509
2474
|
GuardRebinding.may_rebind?(condition, before) || (!body.nil? && GuardRebinding.may_rebind?(body, before))
|
|
1510
2475
|
end
|
|
1511
2476
|
end
|
|
1512
2477
|
|
|
1513
|
-
|
|
2478
|
+
# Issue #2211 — a global subject from a `when` test that may rebind it on ({#when_test_rebinds_subject?}): a
|
|
2479
|
+
# method may assign a global, and the statement walk keeps a global's binding past a call, so the clause
|
|
2480
|
+
# bodies and the later clauses read it as `Dynamic[top]`, as the scope index's walk of a value-position `case`
|
|
2481
|
+
# reads it
|
|
2482
|
+
# (`ScopeIndexer#forget_called_subject`). `$g = nil; case $g when String then 1 when reset! then 2 else $g + 1
|
|
2483
|
+
# end`, with `reset!` assigning `$g`, is `6` in Ruby. An instance variable a test's call may write is widened by
|
|
2484
|
+
# {#run_when_test} instead; any other subject is left as is.
|
|
2485
|
+
def forget_called_global_subject(subject, scope)
|
|
2486
|
+
return scope unless subject.is_a?(Prism::GlobalVariableReadNode)
|
|
2487
|
+
|
|
2488
|
+
scope.with_global(subject.name, Type::Combinator.untyped)
|
|
2489
|
+
end
|
|
2490
|
+
|
|
2491
|
+
# The subject node the rebinding test reads: a parenthesised one unwrapped, and a local or instance variable
|
|
2492
|
+
# write kept as the variable it writes.
|
|
2493
|
+
def case_subject_variable(subject)
|
|
2494
|
+
Narrowing.unwrap_case_subject(subject)
|
|
2495
|
+
end
|
|
2496
|
+
|
|
2497
|
+
def case_subject_name(subject)
|
|
1514
2498
|
case subject
|
|
1515
|
-
when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode
|
|
2499
|
+
when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode,
|
|
2500
|
+
Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode
|
|
1516
2501
|
subject.name
|
|
1517
2502
|
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1518
2503
|
Narrowing.constant_key(subject)
|
|
@@ -1521,8 +2506,8 @@ module Rigor
|
|
|
1521
2506
|
|
|
1522
2507
|
def case_subject_binding(subject, scope)
|
|
1523
2508
|
case subject
|
|
1524
|
-
when Prism::LocalVariableReadNode then scope.local(subject.name)
|
|
1525
|
-
when Prism::InstanceVariableReadNode then scope.ivar(subject.name)
|
|
2509
|
+
when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.local(subject.name)
|
|
2510
|
+
when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.ivar(subject.name)
|
|
1526
2511
|
when Prism::GlobalVariableReadNode then scope.global(subject.name)
|
|
1527
2512
|
else scope.constant_narrowing(Narrowing.constant_key(subject))
|
|
1528
2513
|
end
|
|
@@ -2222,16 +3207,37 @@ module Rigor
|
|
|
2222
3207
|
# ({#loop_body_ivar_writes}).
|
|
2223
3208
|
def eval_repeating_for(node) = ReflectiveIvarWrite.in_repeating_body { eval_for(node) }
|
|
2224
3209
|
|
|
3210
|
+
# A loop's evaluation, with its value recorded for `ExpressionTyper#type_of_loop` ({LoopValueRecord}).
|
|
3211
|
+
# A conditional's evaluation, recorded when it evaluated a loop ({LoopValueRecord.conditional}): the typer
|
|
3212
|
+
# types a conditional that holds a loop again from the scope it was evaluated from, and the loop in its branch
|
|
3213
|
+
# is evaluated from a branch scope no record matches.
|
|
3214
|
+
def eval_recorded_if(node) = record_conditional_value(node) { eval_if(node) }
|
|
3215
|
+
|
|
3216
|
+
def eval_recorded_unless(node) = record_conditional_value(node) { eval_unless(node) }
|
|
3217
|
+
|
|
3218
|
+
# The "evaluated a loop" gate is a memory-only guard: recording every conditional would give the same answers, at
|
|
3219
|
+
# the cost of a retained scope per `if` node, so no behavioural example pins it.
|
|
3220
|
+
def record_conditional_value(node)
|
|
3221
|
+
before = LoopValueRecord.loops_evaluated
|
|
3222
|
+
result = yield
|
|
3223
|
+
LoopValueRecord.conditional(node, scope, result.first) unless LoopValueRecord.loops_evaluated == before
|
|
3224
|
+
result
|
|
3225
|
+
end
|
|
3226
|
+
|
|
3227
|
+
def eval_recorded_loop(node) = record_loop_value(node, eval_loop(node))
|
|
3228
|
+
|
|
3229
|
+
def eval_recorded_for(node) = record_loop_value(node, eval_repeating_for(node))
|
|
3230
|
+
|
|
3231
|
+
def record_loop_value(node, result)
|
|
3232
|
+
LoopValueRecord.record(node, scope, result.first)
|
|
3233
|
+
result
|
|
3234
|
+
end
|
|
3235
|
+
|
|
2225
3236
|
# `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
|
|
2226
|
-
# degrade to `T | nil` in the post-loop scope. The loop expression
|
|
2227
|
-
#
|
|
3237
|
+
# degrade to `T | nil` in the post-loop scope. The loop expression is `nil` joined with the value of every `break`
|
|
3238
|
+
# that leaves this loop ({#loop_value}, issue #2031).
|
|
2228
3239
|
def eval_loop(node)
|
|
2229
|
-
|
|
2230
|
-
# `instance_variable_set` in it joins its value and `Dynamic[top]` ({ReflectiveIvarWrite.in_repeating_body}):
|
|
2231
|
-
# the rebind fixpoint tracks the body's writes only.
|
|
2232
|
-
entry = loop_entry_scope(node)
|
|
2233
|
-
_pred_type, post_pred = ReflectiveIvarWrite.in_repeating_body { sub_eval(node.predicate, entry) }
|
|
2234
|
-
post_pred = widen_predicate_pins(node, post_pred)
|
|
3240
|
+
post_pred = loop_predicate_scope(node)
|
|
2235
3241
|
return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
|
|
2236
3242
|
|
|
2237
3243
|
# The historical single body pass joined with the pre-loop scope. This continues to carry everything the
|
|
@@ -2248,7 +3254,7 @@ module Rigor
|
|
|
2248
3254
|
# The pass ends with its `next` exits as well as its fall-through ({#loop_iteration}). Its `break` arms are
|
|
2249
3255
|
# superseded by the fixpoint's converged pass ({#loop_break_arms}) except in a `begin … end while` loop, below.
|
|
2250
3256
|
jumps = loop_jumps(node.statements)
|
|
2251
|
-
body_scope, first_breaks = single_pass(node, post_pred, jumps)
|
|
3257
|
+
body_scope, first_breaks, first_values = single_pass(node, post_pred, jumps)
|
|
2252
3258
|
base_scope = join_with_nil_injection(post_pred, body_scope)
|
|
2253
3259
|
|
|
2254
3260
|
names, body_first = loop_rebound_names(node.statements, post_pred)
|
|
@@ -2258,17 +3264,50 @@ module Rigor
|
|
|
2258
3264
|
# the single-pass join before returning.
|
|
2259
3265
|
if names.empty?
|
|
2260
3266
|
fast = loop_content_writeback(node.statements, base_scope, pre_body: post_pred)
|
|
2261
|
-
return [
|
|
3267
|
+
return [loop_value(node, first_values), loop_exit(node, post_pred, fast)]
|
|
2262
3268
|
end
|
|
2263
3269
|
|
|
2264
|
-
post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
|
|
3270
|
+
post_loop, values = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
|
|
2265
3271
|
# A `begin … end while` / `until` body runs once before the predicate is first tested, so that iteration's
|
|
2266
3272
|
# entry lies outside every fixpoint pass's predicate-narrowed one, and a `break` only it can take (`if state ==
|
|
2267
3273
|
# :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
|
|
2268
3274
|
# un-narrowed post-predicate scope, so its arms stand in for that first iteration.
|
|
2269
3275
|
post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
|
|
2270
3276
|
post_loop = loop_exit(node, post_pred, post_loop)
|
|
2271
|
-
[
|
|
3277
|
+
[loop_value(node, first_values + values), post_loop]
|
|
3278
|
+
end
|
|
3279
|
+
|
|
3280
|
+
# The scope a loop's predicate leaves, which the body and the exit edge start from. Issue #1930 — the predicate
|
|
3281
|
+
# runs before every iteration but is walked once, so a literal-name `instance_variable_set` in it joins its value
|
|
3282
|
+
# and `Dynamic[top]` ({ReflectiveIvarWrite.in_repeating_body}): the rebind fixpoint tracks the body's writes only.
|
|
3283
|
+
def loop_predicate_scope(node)
|
|
3284
|
+
entry = loop_entry_scope(node)
|
|
3285
|
+
_pred_type, post_pred = ReflectiveIvarWrite.in_repeating_body { sub_eval(node.predicate, entry) }
|
|
3286
|
+
widen_predicate_pins(node, post_pred)
|
|
3287
|
+
end
|
|
3288
|
+
|
|
3289
|
+
# Issue #2031 — a `while` / `until` expression is `nil` when its predicate ends it and the value of the `break`
|
|
3290
|
+
# that ends it otherwise: `x = while i < k; break @r if i == 4; end` is `"four"` or `nil`. `break_values` holds
|
|
3291
|
+
# what each `break` targeting this loop carried, from the single body pass (the first iteration) and the pass
|
|
3292
|
+
# from the converged bindings ({#loop_break_arms}), which together cover the entry of every iteration. A literal
|
|
3293
|
+
# `while true` / `until false` / `until nil` is never ended by its predicate, so only its `break`s give it a
|
|
3294
|
+
# value; any other predicate is assumed to end it, since its first evaluation says nothing about later ones. A
|
|
3295
|
+
# loop with no reachable `break` keeps its `nil`.
|
|
3296
|
+
def loop_value(node, break_values)
|
|
3297
|
+
nil_type = Type::Combinator.constant_of(nil)
|
|
3298
|
+
return nil_type if break_values.empty?
|
|
3299
|
+
return Type::Combinator.union(*break_values) if endless_loop_predicate?(node)
|
|
3300
|
+
|
|
3301
|
+
Type::Combinator.union(nil_type, *break_values)
|
|
3302
|
+
end
|
|
3303
|
+
|
|
3304
|
+
def endless_loop_predicate?(node)
|
|
3305
|
+
predicate = node.predicate
|
|
3306
|
+
if node.is_a?(Prism::UntilNode)
|
|
3307
|
+
predicate.is_a?(Prism::FalseNode) || predicate.is_a?(Prism::NilNode)
|
|
3308
|
+
else
|
|
3309
|
+
predicate.is_a?(Prism::TrueNode)
|
|
3310
|
+
end
|
|
2272
3311
|
end
|
|
2273
3312
|
|
|
2274
3313
|
# Issue #1359 — the scope a loop's predicate first runs from: a body that may set `$_` runs again after it ran,
|
|
@@ -2330,7 +3369,8 @@ module Rigor
|
|
|
2330
3369
|
|
|
2331
3370
|
# The continuation scope for a loop whose body rebinds locals: the ADR-56 slice-B rebind fixpoint overlaid on
|
|
2332
3371
|
# `base_scope`, then the slice-C receiver-content writeback, then the `break`-path bindings the fall-through
|
|
2333
|
-
# dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`).
|
|
3372
|
+
# dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`). Returns `[scope, values]`,
|
|
3373
|
+
# `values` being what the converged pass's `break`s carry ({#loop_value}).
|
|
2334
3374
|
def converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
|
|
2335
3375
|
# ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and may compound (`d *= 2`), so the historical
|
|
2336
3376
|
# single body pass joined with the pre-loop scope kept stale folded constants (`d = 1; while …; d *= 2; end` →
|
|
@@ -2338,7 +3378,7 @@ module Rigor
|
|
|
2338
3378
|
# fixpoint slice A uses for non-escaping block captures. Seed: a pre-existing local seeds with its
|
|
2339
3379
|
# post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the 0-iteration path
|
|
2340
3380
|
# degrades it to `T | nil`, matching the nil-injection treatment.
|
|
2341
|
-
break_pass =
|
|
3381
|
+
break_pass = { entry: nil, arms: [], values: [] } if jumps.breaks || later_pass_returns?(node.statements)
|
|
2342
3382
|
result = loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
|
|
2343
3383
|
# Display-path re-record: the fixpoint's body re-evaluations fire `on_enter` with the cap-N INTERMEDIATE
|
|
2344
3384
|
# assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
|
|
@@ -2350,8 +3390,8 @@ module Rigor
|
|
|
2350
3390
|
# the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
|
|
2351
3391
|
# from `post_loop` for one it also rebinds, so composition still works — see {#loop_content_writeback}.
|
|
2352
3392
|
post_loop = loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
|
|
2353
|
-
arms = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
|
|
2354
|
-
join_break_scopes(post_loop, arms, names)
|
|
3393
|
+
arms, values = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
|
|
3394
|
+
[join_break_scopes(post_loop, arms, names), values]
|
|
2355
3395
|
end
|
|
2356
3396
|
|
|
2357
3397
|
# A loop's continuation: a local its body and predicate only narrowed from `Dynamic[top]` re-joins to it
|
|
@@ -2444,8 +3484,11 @@ module Rigor
|
|
|
2444
3484
|
[sink, result]
|
|
2445
3485
|
end
|
|
2446
3486
|
|
|
2447
|
-
# One evaluation of a loop body from `entry`. Returns `[exit, breaks]`: the scope the iteration ends with,
|
|
2448
|
-
# scopes at the `break`s that target the loop ({LoopJumps})
|
|
3487
|
+
# One evaluation of a loop body from `entry`. Returns `[exit, breaks, values]`: the scope the iteration ends with,
|
|
3488
|
+
# the scopes at the `break`s that target the loop ({LoopJumps}), and the values those `break`s carry, read from a
|
|
3489
|
+
# `break`-value sink of the loop's own ({#loop_value}); filtering it by the loop's `break`s is defensive, since a
|
|
3490
|
+
# nested loop installs its own sink and a nested block's call evaluates its body under another, so no example
|
|
3491
|
+
# tells the filter apart today. Every reader of a loop body goes through here — the
|
|
2449
3492
|
# single pass `eval_loop` joins with the pre-loop scope, each pass of its rebind fixpoint, and `eval_for`'s only
|
|
2450
3493
|
# pass.
|
|
2451
3494
|
#
|
|
@@ -2463,18 +3506,21 @@ module Rigor
|
|
|
2463
3506
|
recording = recorded ? {} : UNRECORDED
|
|
2464
3507
|
evaluate = -> { sub_eval(statements, entry, next_scope_sink: next_sink, **recording).last }
|
|
2465
3508
|
if jumps.breaks
|
|
2466
|
-
break_sink, fall_through = collect_break_scopes
|
|
3509
|
+
break_sink, (fall_through, value_sink) = collect_break_scopes do
|
|
3510
|
+
StatementEvaluator.with_break_value_sink(&evaluate)
|
|
3511
|
+
end
|
|
2467
3512
|
breaks = targeted_scopes(break_sink, jumps.breaks)
|
|
3513
|
+
values = targeted_scopes(value_sink, jumps.breaks)
|
|
2468
3514
|
else
|
|
2469
3515
|
fall_through = evaluate.call
|
|
2470
|
-
breaks = NO_BREAK_ARMS
|
|
3516
|
+
breaks = values = NO_BREAK_ARMS
|
|
2471
3517
|
end
|
|
2472
|
-
return [fall_through, breaks] if next_sink.nil?
|
|
3518
|
+
return [fall_through, breaks, values] if next_sink.nil?
|
|
2473
3519
|
|
|
2474
3520
|
exit_scope = targeted_scopes(next_sink, jumps.nexts).reduce(fall_through) do |acc, next_scope|
|
|
2475
3521
|
join_with_nil_injection(acc, next_scope)
|
|
2476
3522
|
end
|
|
2477
|
-
[exit_scope, breaks]
|
|
3523
|
+
[exit_scope, breaks, values]
|
|
2478
3524
|
end
|
|
2479
3525
|
|
|
2480
3526
|
# The `break` scopes the continuation joins. A `break` leaves the loop, so its binding starts no further iteration
|
|
@@ -2494,12 +3540,24 @@ module Rigor
|
|
|
2494
3540
|
# The unrecorded pass is otherwise an ordinary evaluation: a `return` it reaches joins the enclosing method's
|
|
2495
3541
|
# inferred return type, as one any other pass reaches does. That only widens the return, toward values a capped
|
|
2496
3542
|
# fixpoint's own passes never evaluated.
|
|
3543
|
+
#
|
|
3544
|
+
# Returns `[arms, values]`, `values` being what the same pass's `break`s carry ({#loop_value}).
|
|
2497
3545
|
def loop_break_arms(node, post_pred, converged, body_first, jumps, break_pass)
|
|
2498
|
-
return NO_BREAK_ARMS if break_pass.nil?
|
|
2499
|
-
return break_pass
|
|
3546
|
+
return [NO_BREAK_ARMS, NO_BREAK_ARMS] if break_pass.nil?
|
|
3547
|
+
return break_pass.values_at(:arms, :values) if break_pass[:entry] == converged.except(*body_first)
|
|
2500
3548
|
|
|
2501
3549
|
entry = loop_pass_entry(node, post_pred, converged, body_first, jumps)
|
|
2502
|
-
loop_iteration(node.statements, entry, jumps, recorded: false).
|
|
3550
|
+
loop_iteration(node.statements, entry, jumps, recorded: false).drop(1)
|
|
3551
|
+
end
|
|
3552
|
+
|
|
3553
|
+
# Issue #2128 — whether a pass from the converged bindings must run for the `return`s in a loop body: a method's
|
|
3554
|
+
# return sink is active and the body holds a `return` that exits the method ({ReturnBarrier.exits_method?}). A
|
|
3555
|
+
# `return` whose condition reads a loop-carried local (`return false if seen > 3` after `seen += 1`) is dead in
|
|
3556
|
+
# every capped fixpoint pass, which runs from a value-pinned assumption (`seen` at most `3`), and only the pass
|
|
3557
|
+
# from the widened binding reaches it ({#loop_break_arms}); without it the method inferred `true` and a
|
|
3558
|
+
# caller's test on it folded always-truthy on correct code.
|
|
3559
|
+
def later_pass_returns?(statements)
|
|
3560
|
+
!Thread.current[RETURN_SINK_KEY].nil? && ReturnBarrier.exits_method?(statements)
|
|
2503
3561
|
end
|
|
2504
3562
|
|
|
2505
3563
|
# Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
|
|
@@ -2674,10 +3732,11 @@ module Rigor
|
|
|
2674
3732
|
# it.
|
|
2675
3733
|
def loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass = nil)
|
|
2676
3734
|
entry = loop_pass_entry(node, post_pred, bindings, body_first, jumps)
|
|
2677
|
-
exit_scope, breaks = loop_iteration(node.statements, entry, jumps)
|
|
3735
|
+
exit_scope, breaks, values = loop_iteration(node.statements, entry, jumps)
|
|
2678
3736
|
if break_pass
|
|
2679
3737
|
break_pass[:entry] = bindings.except(*body_first)
|
|
2680
3738
|
break_pass[:arms] = breaks
|
|
3739
|
+
break_pass[:values] = values
|
|
2681
3740
|
end
|
|
2682
3741
|
names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
|
|
2683
3742
|
end
|
|
@@ -2736,29 +3795,159 @@ module Rigor
|
|
|
2736
3795
|
# index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
|
|
2737
3796
|
# once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
|
|
2738
3797
|
# `post_collection`) and the body scope, with half-bound names degraded to `T | nil` via nil-injection. The loop
|
|
2739
|
-
# expression
|
|
2740
|
-
# `for`, whose value in Ruby is the collection it iterated (issue #1216).
|
|
3798
|
+
# expression is the collection's `each` value joined with every `break` value ({#for_value}, issue #1216).
|
|
2741
3799
|
def eval_for(node)
|
|
2742
3800
|
coll_type, post_coll = sub_eval(node.collection, scope)
|
|
2743
|
-
|
|
2744
|
-
# Issue #1359 — a body that may set `$_` runs again after it ran, as a `while` body does ({#eval_loop}).
|
|
2745
|
-
body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
|
|
2746
|
-
body_entry = forget_guard_for_body(node, post_coll, body_entry)
|
|
2747
|
-
body_entry = loop_content_entry(node.statements, post_coll, body_entry)
|
|
3801
|
+
body_entry = for_body_entry(node, for_iteration_element_type(coll_type), post_coll)
|
|
2748
3802
|
|
|
2749
3803
|
if node.statements.nil?
|
|
2750
|
-
return [
|
|
3804
|
+
return [for_value(node, coll_type, post_coll, NO_BREAK_ARMS),
|
|
3805
|
+
join_with_nil_injection(post_coll, body_entry)]
|
|
2751
3806
|
end
|
|
2752
3807
|
|
|
2753
3808
|
# The body pass ends with its `next` exits as well as its fall-through, and its `break` arms are recovered into
|
|
2754
3809
|
# the continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single pass is
|
|
2755
3810
|
# the only continuation and the only source of `break` arms).
|
|
2756
3811
|
jumps = loop_jumps(node.statements)
|
|
2757
|
-
body_scope, breaks = loop_iteration(node.statements, body_entry, jumps)
|
|
3812
|
+
body_scope, breaks, values = loop_iteration(node.statements, body_entry, jumps)
|
|
3813
|
+
values += later_for_pass_values(node.statements, body_entry, body_scope, jumps)
|
|
2758
3814
|
continuation = join_with_nil_injection(post_coll, body_scope)
|
|
2759
3815
|
pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
|
|
2760
|
-
continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
|
|
2761
|
-
|
|
3816
|
+
continuation = for_exit(node, post_coll, join_break_scopes(continuation, breaks, pre_existing + body_first))
|
|
3817
|
+
continuation = widen_for_elements(node, continuation)
|
|
3818
|
+
[for_value(node, coll_type, continuation, values), continuation]
|
|
3819
|
+
end
|
|
3820
|
+
|
|
3821
|
+
# Issue #2262 — the variables the collection reads, past a body that mutates an element in place: `for x in a;
|
|
3822
|
+
# x << 2; end` leaves `a[0]` holding what the literal's shape did not.
|
|
3823
|
+
def widen_for_elements(node, scope)
|
|
3824
|
+
DeepErasure.widen_element_aliases(
|
|
3825
|
+
node.collection, node.statements, DeepErasure.parameter_names(node.index), scope
|
|
3826
|
+
)
|
|
3827
|
+
end
|
|
3828
|
+
|
|
3829
|
+
# Issue #2128 — a `for` body has no rebind fixpoint, so its only pass enters with every loop-carried local at its
|
|
3830
|
+
# pre-loop binding, and a `return` or `break` whose condition needs a later iteration (`n = 0; for x in xs;
|
|
3831
|
+
# return :f if (n += 1) > 2; end`) was never reached. When the body holds such a jump (a `break`, or a `return`
|
|
3832
|
+
# that exits the method while a return sink is active, {#later_pass_returns?}), one more unrecorded pass runs
|
|
3833
|
+
# with each local and instance variable the body rebinds at its value-pinned widening
|
|
3834
|
+
# (`Type::Combinator.widen_value_pinned`) of its entry binding joined with the first pass's exit, a binding that
|
|
3835
|
+
# covers what any iteration enters with, as the `while` fixpoint's widened binding does. The `return`s it
|
|
3836
|
+
# reaches join the method's return type as any pass's do, and the `break` values it collects are returned for
|
|
3837
|
+
# the loop's value ({#for_value}). The pass records no scope, and the continuation keeps the first pass's
|
|
3838
|
+
# answer.
|
|
3839
|
+
def later_for_pass_values(statements, body_entry, body_scope, jumps)
|
|
3840
|
+
return NO_BREAK_ARMS unless jumps.breaks || later_pass_returns?(statements)
|
|
3841
|
+
|
|
3842
|
+
names, body_first = loop_rebound_names(statements, body_entry)
|
|
3843
|
+
bindings = (names - body_first).filter_map do |name|
|
|
3844
|
+
joined = [body_entry, body_scope].filter_map { |s| CapturedLocals.bound_type(s, name) }
|
|
3845
|
+
[name, Type::Combinator.widen_value_pinned(Type::Combinator.union(*joined))] unless joined.empty?
|
|
3846
|
+
end
|
|
3847
|
+
return NO_BREAK_ARMS if bindings.empty?
|
|
3848
|
+
|
|
3849
|
+
entry = bindings.reduce(body_entry) { |acc, (name, type)| bind_capture(acc, name, type) }
|
|
3850
|
+
loop_iteration(statements, entry, jumps, recorded: false)[2]
|
|
3851
|
+
end
|
|
3852
|
+
|
|
3853
|
+
# The core collections whose `each` with a block answers its receiver.
|
|
3854
|
+
FOR_SELF_EACH_CLASSES = Set["Array", "Hash", "Range"].freeze
|
|
3855
|
+
private_constant :FOR_SELF_EACH_CLASSES
|
|
3856
|
+
|
|
3857
|
+
# The nodes through which a `for` body or index may change the iterated collection's contents: a call (an
|
|
3858
|
+
# in-place mutator on the collection or on an alias of it, or a method that reaches it), `yield` / `super`, an
|
|
3859
|
+
# index store, and an operator write, which calls its operator on the variable's value (`b <<= 9` appends to
|
|
3860
|
+
# the array `b` holds).
|
|
3861
|
+
FOR_MUTATING_NODES = Set[
|
|
3862
|
+
Prism::CallNode, Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::IndexTargetNode,
|
|
3863
|
+
Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
|
|
3864
|
+
Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
|
|
3865
|
+
Prism::LocalVariableOperatorWriteNode, Prism::InstanceVariableOperatorWriteNode,
|
|
3866
|
+
Prism::ClassVariableOperatorWriteNode, Prism::GlobalVariableOperatorWriteNode
|
|
3867
|
+
].freeze
|
|
3868
|
+
FOR_VARIABLE_READS = Set[Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode].freeze
|
|
3869
|
+
private_constant :FOR_MUTATING_NODES, :FOR_VARIABLE_READS
|
|
3870
|
+
|
|
3871
|
+
# Issue #1216 — `for x in coll` is `coll.each { |x| … }`, so the loop's value is what that `each` answers, or
|
|
3872
|
+
# the value of the `break` that ends it. An Array, a Hash and a Range answer themselves, including when
|
|
3873
|
+
# they are empty and the body never runs, so the value is the collection ({#for_collection_after}). Any other
|
|
3874
|
+
# collection (`Dynamic`, a project class, an `Enumerator`, a union with `nil`) answers `Dynamic[top]`: its
|
|
3875
|
+
# `each` is not known to answer its receiver, and a guessed type would be a claim the analysis cannot back.
|
|
3876
|
+
def for_value(node, coll_type, continuation, break_values)
|
|
3877
|
+
each_value = each_answers_self?(coll_type) ? for_collection_after(node, coll_type, continuation) : Type::Combinator.untyped
|
|
3878
|
+
break_values.empty? ? each_value : Type::Combinator.union(each_value, *break_values)
|
|
3879
|
+
end
|
|
3880
|
+
|
|
3881
|
+
# The collection as the loop leaves it. `each` answers the object it iterated, after the body ran, so a body that
|
|
3882
|
+
# may change its contents ({FOR_MUTATING_NODES}: `a.pop`, `h.delete(k)`, `b << 3` on an alias `b`) leaves
|
|
3883
|
+
# neither its shape nor its size: `a = [1, 2]; for i in a; a.pop; end` is `[1]`. The value is then the
|
|
3884
|
+
# collection's type with every shape and refinement dropped to its nominal ({#shape_erased_collection}), joined,
|
|
3885
|
+
# when the collection is a local or instance variable the loop does not rebind, with that variable's binding
|
|
3886
|
+
# after the loop, which holds the contents the body's own mutator calls add. A body that runs no such code
|
|
3887
|
+
# leaves the collection as it was.
|
|
3888
|
+
def for_collection_after(node, coll_type, continuation)
|
|
3889
|
+
return coll_type unless [node.index, node.statements].any? { |part| for_may_mutate?(part) }
|
|
3890
|
+
|
|
3891
|
+
types = [coll_type]
|
|
3892
|
+
name = for_collection_variable(node)
|
|
3893
|
+
types << CapturedLocals.bound_type(continuation, name) if name
|
|
3894
|
+
deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index)) do
|
|
3895
|
+
DeepErasure.holds_set?(coll_type)
|
|
3896
|
+
end
|
|
3897
|
+
Type::Combinator.union(*types.compact.map { |type| shape_erased_collection(type, deep: deep) })
|
|
3898
|
+
end
|
|
3899
|
+
|
|
3900
|
+
def for_may_mutate?(part)
|
|
3901
|
+
return false if part.nil?
|
|
3902
|
+
|
|
3903
|
+
found = false
|
|
3904
|
+
Source::NodeWalker.each(part) { |descendant| found ||= FOR_MUTATING_NODES.include?(descendant.class) }
|
|
3905
|
+
found || FOR_MUTATING_NODES.include?(part.class)
|
|
3906
|
+
end
|
|
3907
|
+
|
|
3908
|
+
# The local or instance variable `for` iterates, when neither its index nor its body rebinds it.
|
|
3909
|
+
def for_collection_variable(node)
|
|
3910
|
+
collection = node.collection
|
|
3911
|
+
return nil unless FOR_VARIABLE_READS.include?(collection.class)
|
|
3912
|
+
|
|
3913
|
+
written = OperandEffects.written_variables(node.index) + OperandEffects.written_variables(node.statements)
|
|
3914
|
+
written.include?(collection.name) ? nil : collection.name
|
|
3915
|
+
end
|
|
3916
|
+
|
|
3917
|
+
# `type` with each `Tuple` / `HashShape` / literal Array or Hash dropped to its nominal, each refinement to its
|
|
3918
|
+
# base. A Range is immutable and keeps its value.
|
|
3919
|
+
def shape_erased_collection(type, deep: false)
|
|
3920
|
+
# A body that mutates an element in place changes the shapes one level down too, which `each` answers
|
|
3921
|
+
# as the elements' own objects ({DeepErasure.widen}).
|
|
3922
|
+
return DeepErasure.widen(type) if deep && !(type.is_a?(Type::Constant) && type.value.is_a?(Range))
|
|
3923
|
+
|
|
3924
|
+
case type
|
|
3925
|
+
when Type::Tuple then MutationWidening.widen_tuple(type)
|
|
3926
|
+
when Type::HashShape then MutationWidening.widen_hash_shape(type)
|
|
3927
|
+
when Type::Constant then type.value.is_a?(Range) ? type : Type::Combinator.widen_value_pinned(type)
|
|
3928
|
+
when Type::Refined, Type::Difference then shape_erased_collection(type.base)
|
|
3929
|
+
when Type::Union then Type::Combinator.union(*type.members.map { |member| shape_erased_collection(member) })
|
|
3930
|
+
else type
|
|
3931
|
+
end
|
|
3932
|
+
end
|
|
3933
|
+
|
|
3934
|
+
def each_answers_self?(type)
|
|
3935
|
+
case type
|
|
3936
|
+
when Type::Tuple, Type::HashShape then true
|
|
3937
|
+
when Type::Nominal then FOR_SELF_EACH_CLASSES.include?(type.class_name)
|
|
3938
|
+
when Type::Constant then type.value.is_a?(Array) || type.value.is_a?(Hash) || type.value.is_a?(Range)
|
|
3939
|
+
when Type::Refined, Type::Difference then each_answers_self?(type.base)
|
|
3940
|
+
when Type::Union then type.members.all? { |member| each_answers_self?(member) }
|
|
3941
|
+
else false
|
|
3942
|
+
end
|
|
3943
|
+
end
|
|
3944
|
+
|
|
3945
|
+
# The scope a `for` body enters with: the index bound to the element. Issue #1359 — a body that may set `$_` runs
|
|
3946
|
+
# again after it ran, as a `while` body does ({#eval_loop}).
|
|
3947
|
+
def for_body_entry(node, element_type, post_coll)
|
|
3948
|
+
body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
|
|
3949
|
+
body_entry = forget_guard_for_body(node, post_coll, body_entry)
|
|
3950
|
+
loop_content_entry(node.statements, post_coll, body_entry)
|
|
2762
3951
|
end
|
|
2763
3952
|
|
|
2764
3953
|
# Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
|
|
@@ -2769,11 +3958,11 @@ module Rigor
|
|
|
2769
3958
|
forget_guard_if_rebinds(body_entry, node.statements)
|
|
2770
3959
|
end
|
|
2771
3960
|
|
|
2772
|
-
# `for x in coll` is semantically `coll.each { |x| ... }`.
|
|
2773
|
-
#
|
|
2774
|
-
#
|
|
3961
|
+
# `for x in coll` is semantically `coll.each { |x| ... }`. The structural `collection_element_type` answers first;
|
|
3962
|
+
# only when it is untyped do we ask the method dispatcher for `coll.each`'s expected block parameter types, which
|
|
3963
|
+
# consult RBS and the iterator dispatch table (they know, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
|
|
2775
3964
|
# the receiver is not a literal Hash carrier in our local lattice). When the dispatcher returns nothing (no
|
|
2776
|
-
# signature, unknown receiver)
|
|
3965
|
+
# signature, unknown receiver) the structural answer stands.
|
|
2777
3966
|
def for_iteration_element_type(coll_type)
|
|
2778
3967
|
structural = collection_element_type(coll_type)
|
|
2779
3968
|
return structural unless structural.equal?(Type::Combinator.untyped)
|
|
@@ -2835,7 +4024,7 @@ module Rigor
|
|
|
2835
4024
|
|
|
2836
4025
|
# Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
|
|
2837
4026
|
# elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
|
|
2838
|
-
# V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; `
|
|
4027
|
+
# V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; a `Range` constant reads the
|
|
2839
4028
|
# literal range's element class. Anything else falls back to `untyped`.
|
|
2840
4029
|
def collection_element_type(type)
|
|
2841
4030
|
case type
|
|
@@ -3136,8 +4325,9 @@ module Rigor
|
|
|
3136
4325
|
|
|
3137
4326
|
# `class << expr; body; end`. When `expr` is `self`, the body defines class methods on the immediate enclosing
|
|
3138
4327
|
# class — the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
|
|
3139
|
-
# rather than `instance_method`. For
|
|
3140
|
-
# the existing context and accept that nested defs degrade to the `Dynamic[Top]`
|
|
4328
|
+
# rather than `instance_method`. For an expression that is neither `self` nor a constant we cannot statically
|
|
4329
|
+
# resolve the receiver, so we keep the existing context and accept that nested defs degrade to the `Dynamic[Top]`
|
|
4330
|
+
# default.
|
|
3141
4331
|
# Issue #652 — the body INHERITS this evaluator's chain. Ruby pushes the singleton class onto
|
|
3142
4332
|
# `Module.nesting` and leaves the enclosing entries beneath it, so `class << Other` written inside
|
|
3143
4333
|
# `module Admin` reads `Admin::Y`: the enclosing rung is live, and only the singleton rung itself
|
|
@@ -3203,11 +4393,9 @@ module Rigor
|
|
|
3203
4393
|
invoked = call_operand_scope(node, walk, scope)
|
|
3204
4394
|
invoked = forget_operand_specials(node, invoked)
|
|
3205
4395
|
operand_types = walk.types(tracer)
|
|
4396
|
+
# The `each_with_object` memo widening (ADR-56 slice C, B3) is part of the call's type, applied by
|
|
4397
|
+
# `ExpressionTyper#call_type_for` ({#each_with_object_value}).
|
|
3206
4398
|
call_type = OperandWalk.type_of(scope, node, tracer, operand_types)
|
|
3207
|
-
# ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
|
|
3208
|
-
# types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
|
|
3209
|
-
# block-param and adopt it as the call's return type.
|
|
3210
|
-
call_type = each_with_object_return(node, call_type)
|
|
3211
4399
|
[call_type, invoke_from(node, invoked, call_type, operand_types)]
|
|
3212
4400
|
end
|
|
3213
4401
|
|
|
@@ -3396,8 +4584,7 @@ module Rigor
|
|
|
3396
4584
|
# B2.2 — intervening method call ivar invalidation. An implicit-self / self-receiver call could mutate any ivar
|
|
3397
4585
|
# of the enclosing class (we cannot prove purity without an effect system). Reset each ivar whose current local
|
|
3398
4586
|
# binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
|
|
3399
|
-
# observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts).
|
|
3400
|
-
# [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
|
|
4587
|
+
# observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts).
|
|
3401
4588
|
post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
|
|
3402
4589
|
# Issue #1889 — `instance_variable_set` on `self` writes an ivar no write node shows. It runs after the reset
|
|
3403
4590
|
# above, which would join a literal-name write back into the class seed.
|
|
@@ -3431,9 +4618,32 @@ module Rigor
|
|
|
3431
4618
|
post_scope.forget_guard_narrowings
|
|
3432
4619
|
end
|
|
3433
4620
|
end
|
|
4621
|
+
post_scope = forget_reached_globals(node, post_scope)
|
|
3434
4622
|
forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
|
|
3435
4623
|
end
|
|
3436
4624
|
|
|
4625
|
+
def guard_narrowed_global?(scope, name)
|
|
4626
|
+
scope.guard_narrowed? && scope.guard_records.key?([:global, name])
|
|
4627
|
+
end
|
|
4628
|
+
|
|
4629
|
+
# Issue #2314 — `post_scope` with each global a project method may write rebound to `Dynamic[top]` when the
|
|
4630
|
+
# statement call `node` may reach that method ({GlobalWriterReach.may_write?}): `$g = nil; reset!; $g + 1`,
|
|
4631
|
+
# with `reset!` assigning `$g`, is `2` in Ruby. A global no reachable method writes keeps its binding. A
|
|
4632
|
+
# `case` whose `when` test may rebind a global subject reads it the same way ({#when_test_rebinds_subject?}).
|
|
4633
|
+
# A global a guard narrowed where the operands started keeps the union issue #1429's restore reads instead
|
|
4634
|
+
# (`return unless $sep; puts(reset_sep); $sep.length` reads `$sep` as the union of its seed and `","`).
|
|
4635
|
+
def forget_reached_globals(node, post_scope)
|
|
4636
|
+
names = GlobalWriterReach.candidates(post_scope)
|
|
4637
|
+
return post_scope if names.empty?
|
|
4638
|
+
|
|
4639
|
+
names.reduce(post_scope) do |acc, name|
|
|
4640
|
+
next acc if guard_narrowed_global?(operand_scope, name)
|
|
4641
|
+
next acc unless GlobalWriterReach.may_write?(node, name, operand_scope)
|
|
4642
|
+
|
|
4643
|
+
acc.with_global(name, Type::Combinator.untyped)
|
|
4644
|
+
end
|
|
4645
|
+
end
|
|
4646
|
+
|
|
3437
4647
|
# True when the call may rebind this frame's match globals: it is match-capable itself ({#match_capable_call?}),
|
|
3438
4648
|
# its own block may run a match ({MatchRebinding.block_may_match?} — issue #1358: the block runs in this frame,
|
|
3439
4649
|
# so `items.each { |i| i =~ re }` rebinds the enclosing method's `$~`, while a match inside a called Ruby method
|
|
@@ -3553,7 +4763,8 @@ module Rigor
|
|
|
3553
4763
|
|
|
3554
4764
|
# Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
|
|
3555
4765
|
# is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
|
|
3556
|
-
# External-
|
|
4766
|
+
# External-receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged, apart from
|
|
4767
|
+
# the literal-block case below.
|
|
3557
4768
|
# Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
|
|
3558
4769
|
#
|
|
3559
4770
|
# Issue #1402 — a proc's call (`blk.call`) is a candidate too, and so is a call on another receiver whose literal
|
|
@@ -3561,11 +4772,19 @@ module Rigor
|
|
|
3561
4772
|
# calls do, marking a binding whose narrowing had ruled `nil` out, since the code they run is far more often
|
|
3562
4773
|
# code that leaves the variable alone. A `yield` or proc call in the call's receiver or arguments
|
|
3563
4774
|
# (`@items << yield`, `puts(yield)`) widens every narrowed ivar ({InterveningIvarReset.after_operands}).
|
|
4775
|
+
#
|
|
4776
|
+
# Issue #2039 — an implicit-self or `self.` call marks on the same rule only when its write set is unproven
|
|
4777
|
+
# and its callee's body shows no possibly-nil write of the ivar (`mark: :unproven`, {DirectIvarWrites}):
|
|
4778
|
+
# `v2?`, an `attr_reader` call or `respond_to?(:x)` forgets the narrowing without making the ivar newly
|
|
4779
|
+
# nil-reportable, while a callee that assigns it where the program shows (`clear_tag`) widens unmarked.
|
|
3564
4780
|
def invalidate_ivars_for_intervening_call(call_node, current_scope)
|
|
3565
4781
|
widened =
|
|
3566
4782
|
if intervening_call_candidate?(call_node)
|
|
3567
|
-
mark =
|
|
3568
|
-
|
|
4783
|
+
mark = InterveningIvarReset.self_call?(call_node) ? :unproven : true
|
|
4784
|
+
direct_writes = -> { DirectIvarWrites.of(call_node, operand_scope) }
|
|
4785
|
+
InterveningIvarReset.widen(current_scope, mark: mark, direct_writes: direct_writes) do
|
|
4786
|
+
self_call_ivar_writes(call_node)
|
|
4787
|
+
end
|
|
3569
4788
|
else
|
|
3570
4789
|
InterveningIvarReset.after_block(call_node, current_scope)
|
|
3571
4790
|
end
|
|
@@ -3755,7 +4974,7 @@ module Rigor
|
|
|
3755
4974
|
# `resolve_call_method`); plugins recognising their own synthetic call shapes (e.g. `T.assert_type!`) have no
|
|
3756
4975
|
# method_def and the parameter facts silently skip — the plugin's own diagnostics_for_file path covers those
|
|
3757
4976
|
# cases. The full plugin-side parameter-targeting story (PHPStan-style Type-Specifying Extensions on
|
|
3758
|
-
# plugin-recognised calls) lives behind a follow-up slice that introduces
|
|
4977
|
+
# plugin-recognised calls) lives behind a follow-up slice that introduces an `:argument_at` target kind.
|
|
3759
4978
|
def apply_plugin_assertions(call_node, current_scope)
|
|
3760
4979
|
registry = current_scope.environment&.plugin_registry
|
|
3761
4980
|
return current_scope if registry.nil? || registry.empty?
|
|
@@ -3910,7 +5129,7 @@ module Rigor
|
|
|
3910
5129
|
def lookup_post_return_arg(call_node, method_def, target_name)
|
|
3911
5130
|
# Plugin-source contributions arrive without an authoritative method_def (the plugin recognised the call shape
|
|
3912
5131
|
# directly). Parameter-targeting falls back to "no narrow" in that case — the wider plugin-side parameter
|
|
3913
|
-
# mapping (`:
|
|
5132
|
+
# mapping (`:argument_at`) is a follow-up slice.
|
|
3914
5133
|
return nil if method_def.nil?
|
|
3915
5134
|
|
|
3916
5135
|
arguments = call_node.arguments&.arguments || []
|
|
@@ -3981,176 +5200,1314 @@ module Rigor
|
|
|
3981
5200
|
target = ScopeIndexer.refine_target(node)
|
|
3982
5201
|
return nil if target.nil?
|
|
3983
5202
|
|
|
3984
|
-
return nil if InEffectRefinements.class_body_refine?(scope, node)
|
|
5203
|
+
return nil if InEffectRefinements.class_body_refine?(scope, node)
|
|
5204
|
+
|
|
5205
|
+
name = ScopeIndexer.refined_class_name(target, scope)
|
|
5206
|
+
return nil if name.nil?
|
|
5207
|
+
|
|
5208
|
+
[ClassFrame.new(name: name, singleton: false, refinement: true)]
|
|
5209
|
+
end
|
|
5210
|
+
|
|
5211
|
+
# The block calls whose body `return` leaves only the block ({ReturnBarrier.block_call?}). Like a `->` body
|
|
5212
|
+
# ({#eval_lambda}), each runs with the enclosing method's return sink suspended.
|
|
5213
|
+
def return_barrier_block?(node)
|
|
5214
|
+
ReturnBarrier.block_call?(node)
|
|
5215
|
+
end
|
|
5216
|
+
|
|
5217
|
+
# Runs the block with the method's return sink suspended, for a body whose `return` is not the method's.
|
|
5218
|
+
def without_return_sink
|
|
5219
|
+
outer_sink = Thread.current[RETURN_SINK_KEY]
|
|
5220
|
+
Thread.current[RETURN_SINK_KEY] = nil
|
|
5221
|
+
begin
|
|
5222
|
+
yield
|
|
5223
|
+
ensure
|
|
5224
|
+
Thread.current[RETURN_SINK_KEY] = outer_sink
|
|
5225
|
+
end
|
|
5226
|
+
end
|
|
5227
|
+
|
|
5228
|
+
# Issue #963 — `define_method(:name) { ... }` in a class body defines an INSTANCE method, and Ruby runs
|
|
5229
|
+
# the block with `self` bound to the receiving instance. Without this the block inherits the class body's
|
|
5230
|
+
# `Singleton[C]`, and #618's own-method veto asks the singleton side of a name the instance side answers.
|
|
5231
|
+
# {DefineMethodBlockSelf} owns the match; a non-match leaves the entry scope exactly as it was.
|
|
5232
|
+
#
|
|
5233
|
+
# The exclusion — the `class << ...` BODY, where `self` is the singleton class and the call defines a class
|
|
5234
|
+
# method — rides on `Scope#singleton_class_body?`, not on the frame stack: a `def` reached from that body
|
|
5235
|
+
# still carries the singleton frame although its `self` is the class object, and it is an instance method
|
|
5236
|
+
# the call defines there. Carrying the mark on the scope is also what lets the return-typing path apply the
|
|
5237
|
+
# same exclusion, which has no frame stack of its own.
|
|
5238
|
+
def narrow_define_method_block_self(call_node, block_entry)
|
|
5239
|
+
narrowed = DefineMethodBlockSelf.narrow_self_type_for(
|
|
5240
|
+
scope: scope, call_node: call_node
|
|
5241
|
+
)
|
|
5242
|
+
return block_entry unless narrowed
|
|
5243
|
+
|
|
5244
|
+
block_entry.with_block_self_type(narrowed, keeps_unknown: scope.block_self_narrowing_unknown?(call_node))
|
|
5245
|
+
end
|
|
5246
|
+
|
|
5247
|
+
# Enters a meta-new `block` as the body of the class `class_context` names: `self_type` is that class's
|
|
5248
|
+
# singleton, so a `def` inside binds an instance method on it through the ordinary
|
|
5249
|
+
# {#self_type_for_method_body} route, while `block_entry` keeps the outer locals visible. Shared by the two
|
|
5250
|
+
# positions such a block is reached from — a statement-level call ({#evaluate_block_if_present}) and a
|
|
5251
|
+
# constant-write rvalue ({#eval_constant_write}) — so the two cannot drift on what a class body's entry is.
|
|
5252
|
+
#
|
|
5253
|
+
# Issue #652 — the body keeps this evaluator's `Module.nesting`. A BLOCK never pushes a cref, however
|
|
5254
|
+
# much `class_eval` semantics make it behave like a class body in every other respect, so
|
|
5255
|
+
# `K = Class.new do … end` written inside `module Outer` reads `Outer::LABEL` at runtime and MUST NOT
|
|
5256
|
+
# record a `Outer::K` entry: that is a rung Ruby's constant lookup does not have. The `class_context`
|
|
5257
|
+
# frame is still pushed — it is what a nested `def` registers its method under — which is exactly the
|
|
5258
|
+
# divergence that makes the chain a separate record rather than a view of the frame stack.
|
|
5259
|
+
#
|
|
5260
|
+
# Issue #1759 — a `refine X do … end` block runs on the refinement module, not on X, which Rigor does not
|
|
5261
|
+
# model: the body keeps X's frame and singleton so its `def`s and implicit-self calls resolve as before, but
|
|
5262
|
+
# carries the unknown-`self` mark ({Scope#block_self_unknown?}), so an explicit `self.import_methods(M)` is
|
|
5263
|
+
# not checked against `singleton(X)`. The indexer leaves a value-position refine block unknown the same way.
|
|
5264
|
+
def enter_meta_class_body(block, block_entry, class_context)
|
|
5265
|
+
# Issue #1717 — the body runs on the new class whatever encloses it, so its `self` is known.
|
|
5266
|
+
entry = block_entry.with_block_self_type(self_type_for_class_body(class_context),
|
|
5267
|
+
keeps_unknown: class_context.last.refinement &&
|
|
5268
|
+
Scope::REFINEMENT_BLOCK_SELF)
|
|
5269
|
+
.with_singleton_class_body(false)
|
|
5270
|
+
sub_eval(block, stamp_nesting(entry, @lexical_nesting), class_context: class_context)
|
|
5271
|
+
end
|
|
5272
|
+
|
|
5273
|
+
# Slice 6 phase C sub-phase 3b/3c. When the call carries a block whose receiving method is NOT proven
|
|
5274
|
+
# non-escaping:
|
|
5275
|
+
#
|
|
5276
|
+
# - 3b: attach a `dynamic_origin` `closure_escape` fact to the
|
|
5277
|
+
# post-call scope so consumers can see that the closure may
|
|
5278
|
+
# have been retained past the call.
|
|
5279
|
+
# - 3c: drop the narrowed type of every captured outer local
|
|
5280
|
+
# that the block body can rebind, replacing it with
|
|
5281
|
+
# `Dynamic[Top]` through `Scope#with_local` (which also
|
|
5282
|
+
# invalidates the local's `local_binding` facts). Locals
|
|
5283
|
+
# shadowed by a block parameter or a `;`-prefixed
|
|
5284
|
+
# block-local declaration are untouched. Locals the block
|
|
5285
|
+
# only reads (without writing) are also untouched: read-only
|
|
5286
|
+
# captures cannot rebind the outer variable.
|
|
5287
|
+
#
|
|
5288
|
+
# A `:non_escaping` classification (or any block-less call) leaves the post-call scope unchanged.
|
|
5289
|
+
def record_closure_escape_if_any(node)
|
|
5290
|
+
# ADR-57 slice 3 work-item 1: an escaping block can also be attached to a RECEIVER call in a chain rather than
|
|
5291
|
+
# to `node` itself — the canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end .parse!(argv)` idiom,
|
|
5292
|
+
# where the content-mutating block hangs off `OptionParser.new` but the statement-level call node is the chained
|
|
5293
|
+
# `.parse!`. A receiver call is evaluated as an expression, never as a statement, so its block never reaches
|
|
5294
|
+
# this escape handler on its own. Fold each escaping receiver-chain block's content widening into the
|
|
5295
|
+
# continuation here so the captured collection is floored regardless of how deep in the receiver chain its
|
|
5296
|
+
# mutating block lives.
|
|
5297
|
+
post_scope = widen_escaping_receiver_chain_captures(node, scope)
|
|
5298
|
+
|
|
5299
|
+
return post_scope unless node.block.is_a?(Prism::BlockNode)
|
|
5300
|
+
|
|
5301
|
+
classification = classify_closure_escape(node)
|
|
5302
|
+
return post_scope if classification == :non_escaping
|
|
5303
|
+
|
|
5304
|
+
post_scope = escaping_closure_captures(node.block, post_scope, foreign_self: foreign_self_block?(node))
|
|
5305
|
+
post_scope.with_fact(
|
|
5306
|
+
Analysis::FactStore::Fact.new(
|
|
5307
|
+
bucket: :dynamic_origin,
|
|
5308
|
+
target: Analysis::FactStore::Target.new(kind: :closure, name: node.name.to_sym),
|
|
5309
|
+
predicate: :closure_escape,
|
|
5310
|
+
payload: { method_name: node.name.to_sym, classification: classification },
|
|
5311
|
+
stability: :unstable
|
|
5312
|
+
)
|
|
5313
|
+
)
|
|
5314
|
+
end
|
|
5315
|
+
|
|
5316
|
+
# Floor each caller-argument local whose matching parameter the resolved callee escape-mutates (see the call-site
|
|
5317
|
+
# comment). Only self-dispatch calls resolving to a discovered user def are considered; the per-def "which
|
|
5318
|
+
# parameters escape-mutate" set is memoised on the def node.
|
|
5319
|
+
def widen_callee_escaped_argument_captures(node, base_scope)
|
|
5320
|
+
# Apply to the statement call AND every call in its receiver chain: the
|
|
5321
|
+
# `build_option_parser(options).parse!(argv)` idiom puts the escape- mutating helper call in the RECEIVER
|
|
5322
|
+
# position, where its argument is never the statement node's own argument.
|
|
5323
|
+
acc = floor_callee_escaped_args_for_call(node, base_scope)
|
|
5324
|
+
receiver = node.receiver
|
|
5325
|
+
while receiver.is_a?(Prism::CallNode)
|
|
5326
|
+
acc = floor_callee_escaped_args_for_call(receiver, acc)
|
|
5327
|
+
receiver = receiver.receiver
|
|
5328
|
+
end
|
|
5329
|
+
# Issue #2397 — and every self-call in the call's own non-escaping block.
|
|
5330
|
+
floor_block_callee_captures(node, acc)
|
|
5331
|
+
end
|
|
5332
|
+
|
|
5333
|
+
# Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
|
|
5334
|
+
# before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
|
|
5335
|
+
#
|
|
5336
|
+
# Issue #2296 — two more channels follow the bare-carrier floor, each keeping the argument's element type
|
|
5337
|
+
# ({UnknownStoreWidening.callee_block_floor} / {UnknownStoreWidening.callee_set_floor}): a parameter the callee
|
|
5338
|
+
# mutates inside a block (`2.times { |i| acc[i] = v }`), and a Set argument the callee grows through a Set-only
|
|
5339
|
+
# adder (`s.add(x)`, `s.merge(xs)`), which the bare census leaves out because `Hash#merge` does not mutate.
|
|
5340
|
+
#
|
|
5341
|
+
# Issue #2297 — a call may resolve to more than one def ({#resolve_self_callees}); each channel floors through
|
|
5342
|
+
# every one of them before the next channel runs, so a bare-census mutation in any of them still goes first.
|
|
5343
|
+
#
|
|
5344
|
+
# `climbing`, when given, keeps only the argument reads deeper than it: those that climb out of a block that
|
|
5345
|
+
# many levels in ({#floor_block_callee_captures}).
|
|
5346
|
+
def floor_callee_escaped_args_for_call(node, base_scope, climbing: nil)
|
|
5347
|
+
callees = argument_mutating_callees(node)
|
|
5348
|
+
return base_scope if callees.empty?
|
|
5349
|
+
|
|
5350
|
+
acc = floor_callee_channel(node, callees, :callee_content_mutated_parameters, base_scope, climbing) do |type|
|
|
5351
|
+
content_floor_for(type)
|
|
5352
|
+
end
|
|
5353
|
+
acc = floor_splat_arguments(node, callees, acc)
|
|
5354
|
+
# Each closure below is only worth computing when an argument holds something its floor could widen.
|
|
5355
|
+
if floorable_local_argument?(node, acc) { |type| UnknownStoreWidening.callee_floorable?(type) }
|
|
5356
|
+
acc = floor_callee_channel(node, callees, :callee_block_mutated_parameters, acc, climbing) do |type|
|
|
5357
|
+
UnknownStoreWidening.callee_block_floor(type)
|
|
5358
|
+
end
|
|
5359
|
+
end
|
|
5360
|
+
return acc unless floorable_local_argument?(node, acc) { |type| SetMutation.carrier_in?(type) }
|
|
5361
|
+
|
|
5362
|
+
callees.each do |def_node, offset, owner|
|
|
5363
|
+
acc = floor_arguments(node, census_of(owner, :callee_set_mutated_parameters, def_node), acc, offset,
|
|
5364
|
+
climbing) do |type|
|
|
5365
|
+
UnknownStoreWidening.callee_set_floor(type)
|
|
5366
|
+
end
|
|
5367
|
+
end
|
|
5368
|
+
acc
|
|
5369
|
+
end
|
|
5370
|
+
|
|
5371
|
+
# Every callee's `census` channel floors the arguments it names with the block's floor, made gradual when the
|
|
5372
|
+
# floor is an over-approximation ({#callee_gradual_floor}). Only a String result is split; the Set channel and
|
|
5373
|
+
# the container elements a floor widens (#2647) are not.
|
|
5374
|
+
def floor_callee_channel(node, callees, census, base_scope, climbing)
|
|
5375
|
+
several = callees.size > 1
|
|
5376
|
+
acc = base_scope
|
|
5377
|
+
callees.each do |def_node, offset, owner|
|
|
5378
|
+
acc = floor_arguments(node, census_of(owner, census, def_node), acc, offset, climbing) do |type|
|
|
5379
|
+
callee_gradual_floor(yield(type), def_node, owner, several)
|
|
5380
|
+
end
|
|
5381
|
+
end
|
|
5382
|
+
acc
|
|
5383
|
+
end
|
|
5384
|
+
|
|
5385
|
+
# Issue #2397 — the continuation of a `:non_escaping` block whose body hands a captured outer local to a
|
|
5386
|
+
# self-call whose callee mutates it: `acc = [7, 9]; [1].each { poke(acc) }`. The body's own statement floor
|
|
5387
|
+
# rebinds `acc` only inside the block, and neither write-back reads it there (the rebind write-back follows
|
|
5388
|
+
# local writes, the content join follows mutator calls), so the continuation kept the literal and
|
|
5389
|
+
# `acc[0].upcase` reported on correct code.
|
|
5390
|
+
#
|
|
5391
|
+
# Every call the statement `node` makes before it returns is visited: `node` itself and the calls in its
|
|
5392
|
+
# receiver chain and arguments (`[1].each { poke(acc) }.size`, `foo([1].each { poke(acc) })`), never inside a
|
|
5393
|
+
# block or lambda of them. For each one with a `:non_escaping` block not run on another object
|
|
5394
|
+
# ({#foreign_self_block?}), each self-call in the block floors as at a statement, by position or keyword, its
|
|
5395
|
+
# arguments limited to reads that climb out of the block: a block parameter or a nested block's parameter
|
|
5396
|
+
# shadowing the outer name is not the outer local. The body walk ({#each_node_in_method_scope}) leaves out a
|
|
5397
|
+
# lambda and the block of a nested `instance_eval` / `define_method` / …, whose self-calls are not ours.
|
|
5398
|
+
#
|
|
5399
|
+
# Two readings are conservative, since the floor only forgets: a block that may run zero times floors, as the
|
|
5400
|
+
# escaping path ({#floor_block_body_callee_escaped_args}) does, and the floor applies after the block's
|
|
5401
|
+
# write-back, so a rebind later in the body (`poke(acc); acc = [1, 2]`) is floored too.
|
|
5402
|
+
def floor_block_callee_captures(node, post_scope)
|
|
5403
|
+
acc = post_scope
|
|
5404
|
+
each_node_outside_nested_scopes(node) do |call|
|
|
5405
|
+
next unless call.is_a?(Prism::CallNode) && call.block.is_a?(Prism::BlockNode) && call.block.body
|
|
5406
|
+
|
|
5407
|
+
acc = floor_block_body_callee_captures(call, acc)
|
|
5408
|
+
end
|
|
5409
|
+
acc
|
|
5410
|
+
end
|
|
5411
|
+
|
|
5412
|
+
def floor_block_body_callee_captures(call_node, post_scope)
|
|
5413
|
+
return post_scope unless classify_closure_escape(call_node) == :non_escaping
|
|
5414
|
+
return post_scope if foreign_self_block?(call_node)
|
|
5415
|
+
|
|
5416
|
+
acc = post_scope
|
|
5417
|
+
each_node_in_caller_block(call_node.block.body, 0) do |node, depth|
|
|
5418
|
+
acc = floor_callee_escaped_args_for_call(node, acc, climbing: depth)
|
|
5419
|
+
end
|
|
5420
|
+
acc
|
|
5421
|
+
end
|
|
5422
|
+
|
|
5423
|
+
# Yields every call under `node` with the number of blocks and lambdas it sits in, for
|
|
5424
|
+
# {#floor_block_callee_captures}. A lambda is entered, as the escaping path floors one: it may be called in the
|
|
5425
|
+
# block (`l.call`, `-> { ... }.call`). A nested def is not. Nor is the block of a nested call whose self-calls
|
|
5426
|
+
# are provably not the caller's: one run on another object ({#foreign_block_call?}), a `define_method` /
|
|
5427
|
+
# `define_singleton_method` method body, or a `Class.new` / `Module.new` / `Struct.new` / `Data.define` block.
|
|
5428
|
+
# An `instance_eval` / `class_exec` / … on any other receiver (none, `self`, an instance) is entered.
|
|
5429
|
+
def each_node_in_caller_block(node, depth, &)
|
|
5430
|
+
return if node.nil?
|
|
5431
|
+
|
|
5432
|
+
yield node, depth if node.is_a?(Prism::CallNode)
|
|
5433
|
+
skip_block = node.is_a?(Prism::CallNode) && foreign_block_call?(node)
|
|
5434
|
+
node.rigor_each_child do |child|
|
|
5435
|
+
next if child.is_a?(Prism::DefNode)
|
|
5436
|
+
|
|
5437
|
+
if child.is_a?(Prism::BlockNode) || child.is_a?(Prism::LambdaNode)
|
|
5438
|
+
each_node_in_caller_block(child, depth + 1, &) unless skip_block && child.is_a?(Prism::BlockNode)
|
|
5439
|
+
else
|
|
5440
|
+
each_node_in_caller_block(child, depth, &)
|
|
5441
|
+
end
|
|
5442
|
+
end
|
|
5443
|
+
end
|
|
5444
|
+
|
|
5445
|
+
def foreign_block_call?(call_node)
|
|
5446
|
+
return false unless self_rebinding_block_call?(call_node)
|
|
5447
|
+
return true if %i[define_method define_singleton_method].include?(call_node.name)
|
|
5448
|
+
return true unless AnonymousMetaClass.block_form_receiver(call_node).nil?
|
|
5449
|
+
|
|
5450
|
+
foreign_self_block?(call_node)
|
|
5451
|
+
end
|
|
5452
|
+
|
|
5453
|
+
# Issue #2515 — the floor for a `*x` argument whose `x` is a local holding an array (`args = [parser, options];
|
|
5454
|
+
# Opts.add_format(*args)`). The hash travels inside the array, so the caller's `options` is the array's element,
|
|
5455
|
+
# not an argument, and the per-parameter floor never sees it. An element the callee writes is floored at its place
|
|
5456
|
+
# in the array's type and in every local bound to an equal type (the element's source: only the type is known, so
|
|
5457
|
+
# an equal literal in another local is floored too).
|
|
5458
|
+
#
|
|
5459
|
+
# A known tuple whose splat sits at a fixed position (only plain positional arguments before it) binds element
|
|
5460
|
+
# `j` to the callee's slot `index - offset + j` and floors it when that slot is in the channel's census. Any other
|
|
5461
|
+
# case floors every element when the callee writes any positional parameter: an array that is no longer a tuple
|
|
5462
|
+
# (it was mutated), a splat after another splat, an `offset` that swallows the splat. An element that reaches the
|
|
5463
|
+
# callee's rest or post parameter floors when the callee may write it ({#rest_parameter_written?}). A splat of
|
|
5464
|
+
# anything but a local (a call result, an ivar) is not followed.
|
|
5465
|
+
def floor_splat_arguments(node, callees, base_scope)
|
|
5466
|
+
arguments = node.arguments.arguments
|
|
5467
|
+
return base_scope unless arguments.any?(Prism::SplatNode)
|
|
5468
|
+
|
|
5469
|
+
fixed = true
|
|
5470
|
+
arguments.each_with_index.reduce(base_scope) do |acc, (argument, index)|
|
|
5471
|
+
next acc unless argument.is_a?(Prism::SplatNode)
|
|
5472
|
+
|
|
5473
|
+
read = argument.expression
|
|
5474
|
+
at = fixed ? index : nil
|
|
5475
|
+
fixed = false
|
|
5476
|
+
next acc unless read.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(read.name)
|
|
5477
|
+
|
|
5478
|
+
floor_splat_local(read.name, at, callees, acc)
|
|
5479
|
+
end
|
|
5480
|
+
end
|
|
5481
|
+
|
|
5482
|
+
# Every callee's channels in turn. The callees are a call's own ({#argument_mutating_callees}), never the nil
|
|
5483
|
+
# sentinel {CAPPED_HAND_OFF}, which only a hand-off inside a def answers ({#rest_hand_off_writes?} reads it).
|
|
5484
|
+
def floor_splat_local(name, at, callees, base_scope)
|
|
5485
|
+
channels = splat_channels
|
|
5486
|
+
origin = base_scope.local(name)
|
|
5487
|
+
callees.reduce(base_scope) do |acc, (def_node, offset, owner)|
|
|
5488
|
+
rest = rest_parameter_written?(def_node, owner)
|
|
5489
|
+
channels.reduce(acc) do |inner, (census, floor)|
|
|
5490
|
+
mutated = census_of(owner, census, def_node)
|
|
5491
|
+
writer = ->(count, position) { splat_written_elements(count, position, offset, def_node, mutated, rest) }
|
|
5492
|
+
floor_splat_arrays(name, at, writer, floor, inner, origin)
|
|
5493
|
+
end
|
|
5494
|
+
end
|
|
5495
|
+
end
|
|
5496
|
+
|
|
5497
|
+
# `base_scope` with the elements of the array local `name` the callee writes floored. The local may be a union
|
|
5498
|
+
# (`cond ? [p, o] : [p, q]`, or an array rebound in a block) and may include nil (`args = [...] if cond`): each
|
|
5499
|
+
# array member floors on its own, and a union of arrays, whose position is unknown, takes the any-slot rule. A
|
|
5500
|
+
# local bound to an equal array type (`args2 = args`) is rebuilt too.
|
|
5501
|
+
def floor_splat_arrays(name, at, writer, floor, base_scope, origin)
|
|
5502
|
+
current = base_scope.local(name)
|
|
5503
|
+
members = current.is_a?(Type::Union) ? current.members : [current]
|
|
5504
|
+
arrays = members.count { |member| splat_elements(member) }
|
|
5505
|
+
return base_scope if arrays.zero?
|
|
5506
|
+
|
|
5507
|
+
origins = splat_origin_members(origin, members)
|
|
5508
|
+
acc = base_scope
|
|
5509
|
+
floored = members.each_with_index.map do |member, index|
|
|
5510
|
+
elements = splat_elements(member)
|
|
5511
|
+
next member if elements.nil?
|
|
5512
|
+
|
|
5513
|
+
position = member.is_a?(Type::Tuple) && arrays == 1 ? at : nil
|
|
5514
|
+
written = writer.call(elements.size, position)
|
|
5515
|
+
next member if written.empty?
|
|
5516
|
+
|
|
5517
|
+
source = splat_elements(origins[index]) || elements
|
|
5518
|
+
rebuilt, acc = floor_array_elements(member, elements, [written, source], floor, acc, name)
|
|
5519
|
+
rebuilt
|
|
5520
|
+
end
|
|
5521
|
+
return acc if floored == members
|
|
5522
|
+
|
|
5523
|
+
rebind_array_aliases(name, current, Type::Combinator.union(*floored), acc)
|
|
5524
|
+
end
|
|
5525
|
+
|
|
5526
|
+
# The members of the array local as it was before the channels ran, aligned with `members`, or `members` itself
|
|
5527
|
+
# when a channel merged or dropped some.
|
|
5528
|
+
def splat_origin_members(origin, members)
|
|
5529
|
+
origin_members = splat_parts(origin)
|
|
5530
|
+
origin_members.size == members.size ? origin_members : members
|
|
5531
|
+
end
|
|
5532
|
+
|
|
5533
|
+
# `scope_in` with `name`, and every local bound to the same array type `before`, rebound to `after`.
|
|
5534
|
+
def rebind_array_aliases(name, before, after, scope_in)
|
|
5535
|
+
aliased = scope_in.locals.reduce(scope_in) do |acc, (other, bound)|
|
|
5536
|
+
other != name && bound == before ? acc.with_mutated_local(other, after) : acc
|
|
5537
|
+
end
|
|
5538
|
+
aliased.with_mutated_local(name, after)
|
|
5539
|
+
end
|
|
5540
|
+
|
|
5541
|
+
def splat_channels
|
|
5542
|
+
[[:callee_content_mutated_parameters, method(:content_floor_for)],
|
|
5543
|
+
[:callee_block_mutated_parameters, UnknownStoreWidening.method(:callee_block_floor)],
|
|
5544
|
+
[:callee_set_mutated_parameters, UnknownStoreWidening.method(:callee_set_floor)]]
|
|
5545
|
+
end
|
|
5546
|
+
|
|
5547
|
+
# The element types of `type` when it is an array a splat spreads, or nil: a tuple's, or a nominal `Array[T]`'s.
|
|
5548
|
+
def splat_elements(type)
|
|
5549
|
+
if type.is_a?(Type::Tuple) then type.elements
|
|
5550
|
+
elsif type.is_a?(Type::Nominal) && type.class_name == "Array" && type.type_args.size == 1 then type.type_args
|
|
5551
|
+
end
|
|
5552
|
+
end
|
|
5553
|
+
|
|
5554
|
+
# The indexes of the `count` array elements the callee writes, for a splat at the call's argument `at` (nil: a
|
|
5555
|
+
# position the call does not show, or an array of unknown length). `rest` is true when the callee may write its
|
|
5556
|
+
# rest parameter ({#rest_parameter_written?}).
|
|
5557
|
+
def splat_written_elements(count, at, offset, def_node, mutated, rest)
|
|
5558
|
+
slots = mutated.values
|
|
5559
|
+
return NO_ELEMENTS unless rest || slots.any? { |slot| slot.is_a?(Integer) || slot == ANY_SLOT }
|
|
5560
|
+
return (0...count).to_a if at.nil? || at < offset || slots.include?(ANY_SLOT)
|
|
5561
|
+
|
|
5562
|
+
named = content_parameter_slots(def_node).each_value.grep(Integer).size
|
|
5563
|
+
(0...count).select do |j|
|
|
5564
|
+
slot = at - offset + j
|
|
5565
|
+
slot >= named ? rest : slots.include?(slot)
|
|
5566
|
+
end
|
|
5567
|
+
end
|
|
5568
|
+
|
|
5569
|
+
NO_ELEMENTS = [].freeze
|
|
5570
|
+
private_constant :NO_ELEMENTS
|
|
5571
|
+
|
|
5572
|
+
# `[array, scope]`: the array type `member` with its `written` elements floored by `floor`, and `scope_in` with
|
|
5573
|
+
# every caller local (other than `name`) the floored elements could be floored too. A union element floors member
|
|
5574
|
+
# by member; every element of the array holding a floored type is rebuilt (`[options, options]`, one slot
|
|
5575
|
+
# written); a local is floored (gradually, {#floor_locals_accepted_by}) when a floored part accepts its type.
|
|
5576
|
+
# Locals are matched against the `source` elements, the array as it was before an earlier channel floored it,
|
|
5577
|
+
# so a floored `Hash[untyped, untyped]` element does not match every hash.
|
|
5578
|
+
def floor_array_elements(member, elements, written_and_source, floor, scope_in, name)
|
|
5579
|
+
written, source = written_and_source
|
|
5580
|
+
acc = scope_in
|
|
5581
|
+
pairs = []
|
|
5582
|
+
written.each do |j|
|
|
5583
|
+
splat_parts(source[j]).each { |part| acc = floor_locals_accepted_by(part, name, floor, acc) }
|
|
5584
|
+
splat_parts(elements[j]).each do |part|
|
|
5585
|
+
replacement = floor.call(part)
|
|
5586
|
+
pairs << [part, replacement] unless replacement.nil? || replacement == part
|
|
5587
|
+
end
|
|
5588
|
+
end
|
|
5589
|
+
return [member, acc] if pairs.empty?
|
|
5590
|
+
|
|
5591
|
+
floored = elements.map { |element| replace_splat_parts(element, pairs) }
|
|
5592
|
+
rebuilt = if member.is_a?(Type::Tuple)
|
|
5593
|
+
Type::Combinator.tuple_of(*floored)
|
|
5594
|
+
else
|
|
5595
|
+
Type::Combinator.nominal_of("Array", type_args: floored)
|
|
5596
|
+
end
|
|
5597
|
+
[rebuilt, acc]
|
|
5598
|
+
end
|
|
5599
|
+
|
|
5600
|
+
def splat_parts(type) = type.is_a?(Type::Union) ? type.members : [type]
|
|
5601
|
+
|
|
5602
|
+
# `element` with the floored parts replaced, gradually ({#gradual_floor}): an element is floored by
|
|
5603
|
+
# over-approximation as often as by a precise slot (a repeated type, an unknown position, a rest read as written),
|
|
5604
|
+
# and one the callee never writes must not start failing against a literal parameter type.
|
|
5605
|
+
def replace_splat_parts(element, pairs)
|
|
5606
|
+
parts = splat_parts(element)
|
|
5607
|
+
replaced = parts.map do |part|
|
|
5608
|
+
replacement = pairs.find { |from, _| from == part }&.last
|
|
5609
|
+
replacement ? gradual_floor(replacement) : part
|
|
5610
|
+
end
|
|
5611
|
+
replaced == parts ? element : Type::Combinator.union(*replaced)
|
|
5612
|
+
end
|
|
5613
|
+
|
|
5614
|
+
# `scope_in` with each carrier local other than `except` whose type `part` accepts (it is a subtype of, or equal
|
|
5615
|
+
# to, the element: `args << options` widens the pushed `{ format: "text" }` to `Hash[Symbol, String]`) floored
|
|
5616
|
+
# with `floor`. Such a local is only a guess at the element's source (the type is all the array records), so its
|
|
5617
|
+
# floor is gradual ({#gradual_floor}): it may forget a fact, never add a finding. A `Dynamic` part matches no
|
|
5618
|
+
# local: it stands for any value and would floor every carrier in scope.
|
|
5619
|
+
def floor_locals_accepted_by(part, except, floor, scope_in)
|
|
5620
|
+
return scope_in if part.is_a?(Type::Dynamic)
|
|
5621
|
+
|
|
5622
|
+
scope_in.locals.reduce(scope_in) do |acc, (other, bound)|
|
|
5623
|
+
next acc if other == except || !UnknownStoreWidening.callee_floorable?(bound)
|
|
5624
|
+
next acc unless Acceptance.accepts(part, bound).yes?
|
|
5625
|
+
|
|
5626
|
+
floored = floor.call(bound)
|
|
5627
|
+
floored.nil? || floored == bound ? acc : acc.with_mutated_local(other, gradual_floor(floored))
|
|
5628
|
+
end
|
|
5629
|
+
end
|
|
5630
|
+
|
|
5631
|
+
# `type` with a bare `String` carrier made gradual (`Dynamic[String]`): a floor to the nominal `String` fails
|
|
5632
|
+
# against a literal parameter type (`("text") -> String`) that the unmutated local satisfied.
|
|
5633
|
+
def gradual_floor(type)
|
|
5634
|
+
return Type::Combinator.union(*type.members.map { |member| gradual_floor(member) }) if type.is_a?(Type::Union)
|
|
5635
|
+
|
|
5636
|
+
string_carrier?(type) ? Type::Combinator.dynamic(type) : type
|
|
5637
|
+
end
|
|
5638
|
+
|
|
5639
|
+
# `floored`, a callee floor (content or block channel) of an argument of a call to `def_node` under `owner`,
|
|
5640
|
+
# made gradual when the floor is an over-approximation ({#over_approximating_callee?}) or the call has several
|
|
5641
|
+
# callees. The one place both channels take the rule.
|
|
5642
|
+
def callee_gradual_floor(floored, def_node, owner, several)
|
|
5643
|
+
# Only a String carrier turns gradual, so the callee is walked only for one (this runs per floored argument).
|
|
5644
|
+
return floored unless string_carrier_in?(floored)
|
|
5645
|
+
|
|
5646
|
+
several || over_approximating_callee?(def_node, owner) ? gradual_floor(floored) : floored
|
|
5647
|
+
end
|
|
5648
|
+
|
|
5649
|
+
def string_carrier_in?(type)
|
|
5650
|
+
type.is_a?(Type::Union) ? type.members.any? { |member| string_carrier?(member) } : string_carrier?(type)
|
|
5651
|
+
end
|
|
5652
|
+
|
|
5653
|
+
# Issue #2515 — true when the floor a call applies to `def_node`'s arguments is an over-approximation, with no
|
|
5654
|
+
# proof that the callee writes the argument it floors: the owner is {FLOOR_ALL} (an inherited def, a
|
|
5655
|
+
# `method_missing`) or {FLOOR_EVERY} (a top-level def that may not be the one called), or the callee hands a
|
|
5656
|
+
# parameter on, through any hop, to one of those, by name or to several candidates. A def read as written that
|
|
5657
|
+
# writes the argument keeps the
|
|
5658
|
+
# precise floor, which is what a later `Integer` parameter or `no_such_method` on the String needs to report.
|
|
5659
|
+
# The walk is bounded by {CalleeMutationChain::MAX_NODES}; past it the answer is true.
|
|
5660
|
+
def over_approximating_callee?(def_node, owner)
|
|
5661
|
+
return true if OVER_APPROXIMATING.include?(owner)
|
|
5662
|
+
|
|
5663
|
+
seen = {}.compare_by_identity
|
|
5664
|
+
queue = [[def_node, owner]]
|
|
5665
|
+
until queue.empty?
|
|
5666
|
+
node, node_owner = queue.shift
|
|
5667
|
+
next if (seen[node] ||= {}).key?(node_owner)
|
|
5668
|
+
|
|
5669
|
+
seen[node][node_owner] = true
|
|
5670
|
+
return true if seen.sum { |_, owners| owners.size } > CalleeMutationChain::MAX_NODES
|
|
5671
|
+
|
|
5672
|
+
census_of(node_owner, :forwarding_edges, node).each do |edge|
|
|
5673
|
+
return true if edge.approx || OVER_APPROXIMATING.include?(edge.owner)
|
|
5674
|
+
|
|
5675
|
+
queue << [edge.callee, edge.owner || node_owner]
|
|
5676
|
+
end
|
|
5677
|
+
end
|
|
5678
|
+
false
|
|
5679
|
+
end
|
|
5680
|
+
|
|
5681
|
+
def string_carrier?(type) = type.is_a?(Type::Nominal) && type.class_name == "String"
|
|
5682
|
+
|
|
5683
|
+
# True when `def_node` has a rest, post or `...` parameter, so a slot no longer names the parameter an element
|
|
5684
|
+
# binds.
|
|
5685
|
+
def variable_arity?(def_node)
|
|
5686
|
+
params = def_node.parameters
|
|
5687
|
+
return false if params.nil?
|
|
5688
|
+
|
|
5689
|
+
!params.rest.nil? || !params.posts.empty? || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
|
|
5690
|
+
end
|
|
5691
|
+
|
|
5692
|
+
# Issue #2515 — true when a callee's rest parameter (`*args`, `*`, `...`) may be written, so an element a splat
|
|
5693
|
+
# binds to it floors: the body reads it anywhere but as a splat argument of a call (`args.first[:k] = 1`), or
|
|
5694
|
+
# hands it on (`b(*args)`) to a call that resolves to a def which writes a positional parameter or has a rest
|
|
5695
|
+
# parameter of its own that it hands on. A rest handed to a call that resolves to no user def hands off nothing,
|
|
5696
|
+
# as for any hand-off. A callee reached through an inherited lookup ({FLOOR_ALL}) reads as written, as its
|
|
5697
|
+
# parameters do.
|
|
5698
|
+
def rest_parameter_written?(def_node, owner)
|
|
5699
|
+
return true if def_node.nil?
|
|
5700
|
+
return false unless variable_arity?(def_node)
|
|
5701
|
+
return true if owner == FLOOR_ALL
|
|
5702
|
+
|
|
5703
|
+
other, calls = def_census(:rest_uses, def_node) { rest_uses(def_node) }
|
|
5704
|
+
return true if other
|
|
5705
|
+
|
|
5706
|
+
evaluator = owner.nil? ? self : owner_evaluator(owner)
|
|
5707
|
+
calls.any? { |call_node, depth| evaluator.__send__(:rest_hand_off_writes?, call_node, def_node, depth) }
|
|
5708
|
+
end
|
|
5709
|
+
|
|
5710
|
+
# True when a call that splats a rest parameter hands it to a def that writes a positional parameter or has a
|
|
5711
|
+
# rest or post parameter of its own, read in this evaluator, the one the call's def runs in. A nil callee is
|
|
5712
|
+
# {CAPPED_HAND_OFF}'s sentinel (a name with too many defs) and reads as written.
|
|
5713
|
+
def rest_hand_off_writes?(call_node, def_node, depth)
|
|
5714
|
+
hand_off_callees(call_node, def_node, depth).any? do |callee, _offset, callee_owner|
|
|
5715
|
+
next true if callee.nil? || variable_arity?(callee)
|
|
5716
|
+
|
|
5717
|
+
census = census_of(callee_owner, :callee_content_mutated_parameters, callee)
|
|
5718
|
+
census.value?(ANY_SLOT) || census.each_value.any?(Integer)
|
|
5719
|
+
end
|
|
5720
|
+
end
|
|
5721
|
+
|
|
5722
|
+
# `[other, calls]` for `def_node`'s rest parameter: `other` is true when it is read except as a splat argument
|
|
5723
|
+
# (`*args`, `*`) of a call, `calls` the calls that splat it ({#rest_parameter_written?}). An AST property.
|
|
5724
|
+
def rest_uses(def_node)
|
|
5725
|
+
params = def_node.parameters
|
|
5726
|
+
name = params.rest.is_a?(Prism::RestParameterNode) ? params.rest.name : nil
|
|
5727
|
+
forwarding = params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
|
|
5728
|
+
reads = 0
|
|
5729
|
+
splatted = 0
|
|
5730
|
+
calls = []
|
|
5731
|
+
each_node_in_method_scope(def_node.body, 0) do |node, depth|
|
|
5732
|
+
reads += 1 if name && splat_reads?(node, name, depth)
|
|
5733
|
+
next unless node.is_a?(Prism::CallNode) && node.arguments
|
|
5734
|
+
|
|
5735
|
+
spread = node.arguments.arguments.select { |a| rest_splat?(a, name, forwarding, depth) }
|
|
5736
|
+
next if spread.empty?
|
|
5737
|
+
|
|
5738
|
+
splatted += spread.count { |a| a.is_a?(Prism::SplatNode) && a.expression }
|
|
5739
|
+
calls << [node, depth]
|
|
5740
|
+
end
|
|
5741
|
+
[reads > splatted, calls.freeze].freeze
|
|
5742
|
+
end
|
|
5743
|
+
|
|
5744
|
+
def rest_splat?(argument, name, forwarding, depth)
|
|
5745
|
+
case argument
|
|
5746
|
+
when Prism::ForwardingArgumentsNode then forwarding
|
|
5747
|
+
when Prism::SplatNode
|
|
5748
|
+
argument.expression.nil? ? name.nil? : !name.nil? && splat_reads?(argument.expression, name, depth)
|
|
5749
|
+
else false
|
|
5750
|
+
end
|
|
5751
|
+
end
|
|
5752
|
+
|
|
5753
|
+
def splat_reads?(node, name, depth)
|
|
5754
|
+
node.is_a?(Prism::LocalVariableReadNode) && node.name == name && node.depth == depth
|
|
5755
|
+
end
|
|
5756
|
+
|
|
5757
|
+
# True when a local `node` passes (positionally or as a keyword's value) is bound in `scope` to a type the
|
|
5758
|
+
# block accepts.
|
|
5759
|
+
def floorable_local_argument?(node, scope, &)
|
|
5760
|
+
node.arguments.arguments.any? do |argument|
|
|
5761
|
+
if argument.is_a?(Prism::KeywordHashNode)
|
|
5762
|
+
argument.elements.any? do |pair|
|
|
5763
|
+
pair.is_a?(Prism::AssocNode) && floorable_local?(pair.value, scope, &)
|
|
5764
|
+
end
|
|
5765
|
+
else
|
|
5766
|
+
floorable_local?(argument, scope, &)
|
|
5767
|
+
end
|
|
5768
|
+
end
|
|
5769
|
+
end
|
|
5770
|
+
|
|
5771
|
+
def floorable_local?(node, scope)
|
|
5772
|
+
node = node.value if node.is_a?(Prism::ImplicitNode)
|
|
5773
|
+
node.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(node.name) && yield(scope.local(node.name))
|
|
5774
|
+
end
|
|
5775
|
+
|
|
5776
|
+
# `base_scope` with each local `node` passes to a parameter of `mutated` rebound to what the block answers for
|
|
5777
|
+
# its binding; a nil answer, or an answer equal to the binding, leaves it.
|
|
5778
|
+
def floor_arguments(node, mutated, base_scope, offset, climbing = nil)
|
|
5779
|
+
arguments = arguments_for_slots(node, mutated, offset)
|
|
5780
|
+
return base_scope if arguments.empty?
|
|
5781
|
+
|
|
5782
|
+
arguments.reduce(base_scope) do |acc, argument|
|
|
5783
|
+
next acc unless acc.locals.key?(argument.name)
|
|
5784
|
+
next acc if climbing && argument.depth <= climbing
|
|
5785
|
+
|
|
5786
|
+
current = acc.local(argument.name)
|
|
5787
|
+
floored = yield(current)
|
|
5788
|
+
floored.nil? || floored == current ? acc : acc.with_mutated_local(argument.name, floored)
|
|
5789
|
+
end
|
|
5790
|
+
end
|
|
5791
|
+
|
|
5792
|
+
# The `{ name => slot }` parameters whose content the callee mutates, a slot being a positional index or a
|
|
5793
|
+
# keyword symbol: those it mutates itself ({#own_content_parameters}) and, issue #2104, those it passes on to a
|
|
5794
|
+
# self-call whose callee mutates them, through any number of such hand-offs ({CalleeMutationChain}). Each leaves
|
|
5795
|
+
# the caller's argument binding stale after the call, so each floors it. Memoised per def node (the closure is
|
|
5796
|
+
# otherwise recomputed at every call site), and across evaluators by {#resolved_census}.
|
|
5797
|
+
def callee_content_mutated_parameters(def_node)
|
|
5798
|
+
cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
|
|
5799
|
+
cache[def_node] ||= resolved_census(:close, def_node) do
|
|
5800
|
+
CalleeMutationChain.close(def_node, own: hop_census(:own_content_parameters),
|
|
5801
|
+
edges: hop_census(:forwarding_edges), **callee_chain_store)
|
|
5802
|
+
end
|
|
5803
|
+
end
|
|
5804
|
+
|
|
5805
|
+
# Issue #2515 — `census` as the `(def, owner)` callback {CalleeMutationChain} reads a hop through: `owner` is the
|
|
5806
|
+
# evaluator a cross-class hand-off resolves in, nil for the evaluator the closure started in.
|
|
5807
|
+
def hop_census(census) = ->(def_node, owner) { census_of(owner, census, def_node) }
|
|
5808
|
+
|
|
5809
|
+
# ADR-127 WD2 — the run's successor store for {CalleeMutationChain} keyed by discovery: `def => owner => name =>
|
|
5810
|
+
# [callee, name, owner, ...]`, a graph fact (the nodes a parameter is handed to), never a walk's conclusion. The
|
|
5811
|
+
# edges do not depend on which census a closure reads, so one store serves all three. The nil owner is stored
|
|
5812
|
+
# under the side and class the closure runs in, the same context {#resolved_census} keys by. Bypassed (nothing
|
|
5813
|
+
# read or written) under {#hand_off_memo_bypassed?}, as the memos it sits beside.
|
|
5814
|
+
def callee_chain_store
|
|
5815
|
+
return NO_CHAIN_STORE if hand_off_memo_bypassed?
|
|
5816
|
+
|
|
5817
|
+
table = (Thread.current[CHAIN_STORE_KEY] ||= ObjectSpace::WeakKeyMap.new)
|
|
5818
|
+
self_type = scope.self_type
|
|
5819
|
+
{ store: (table[scope.discovery] ||= {}.compare_by_identity),
|
|
5820
|
+
ctx: [self_type.is_a?(Type::Singleton), enclosing_class_name_for(self_type)] }
|
|
5821
|
+
end
|
|
5822
|
+
|
|
5823
|
+
CHAIN_STORE_KEY = RunScopedSlots.register(:rigor_callee_chain_successors)
|
|
5824
|
+
NO_CHAIN_STORE = {}.freeze
|
|
5825
|
+
private_constant :CHAIN_STORE_KEY, :NO_CHAIN_STORE
|
|
5826
|
+
|
|
5827
|
+
# Issue #2296 — the `{ name => slot }` parameters the callee mutates inside a block in its body
|
|
5828
|
+
# ({#own_block_content_parameters}), closed over hand-offs as {#callee_content_mutated_parameters} is.
|
|
5829
|
+
def callee_block_mutated_parameters(def_node)
|
|
5830
|
+
cache = (@callee_block_param_cache ||= {}.compare_by_identity)
|
|
5831
|
+
cache[def_node] ||= resolved_census(:close_block, def_node) do
|
|
5832
|
+
CalleeMutationChain.close(def_node, own: hop_census(:own_block_content_parameters),
|
|
5833
|
+
edges: hop_census(:forwarding_edges), **callee_chain_store)
|
|
5834
|
+
end
|
|
5835
|
+
end
|
|
5836
|
+
|
|
5837
|
+
# Issue #2296 — the `{ name => slot }` parameters the callee grows through a Set-only adder
|
|
5838
|
+
# ({#own_set_content_parameters}), closed over hand-offs the same way.
|
|
5839
|
+
def callee_set_mutated_parameters(def_node)
|
|
5840
|
+
cache = (@callee_set_param_cache ||= {}.compare_by_identity)
|
|
5841
|
+
cache[def_node] ||= resolved_census(:close_set, def_node) do
|
|
5842
|
+
CalleeMutationChain.close(def_node, own: hop_census(:own_set_content_parameters),
|
|
5843
|
+
edges: hop_census(:forwarding_edges), **callee_chain_store)
|
|
5844
|
+
end
|
|
5845
|
+
end
|
|
5846
|
+
|
|
5847
|
+
# Issue #2104 — the per-thread memo of a census that also resolves self-calls ({#resolve_self_callees}), so it
|
|
5848
|
+
# reads, besides `def_node`'s AST, the class self-calls resolve in and the discovery index whose def table they
|
|
5849
|
+
# resolve through. Those two key it: the index by identity, as it is immutable (one entry per live index, held
|
|
5850
|
+
# weakly: a table dropped with its index), the class by name. A run builds hundreds of
|
|
5851
|
+
# thousands of evaluators, and a per-evaluator memo alone recomputed each closure and each def's edges about
|
|
5852
|
+
# thirty times over (#2295's cost on the release gate). Issue #2297 — the side self-calls resolve on
|
|
5853
|
+
# ({#resolve_self_callees}) keys it too: a singleton-side table apart from the instance-side one.
|
|
5854
|
+
#
|
|
5855
|
+
# While a dependency recorder is active the memo is bypassed: a resolution records an ADR-46 read in the file
|
|
5856
|
+
# and in each capture window open at the time, and a hit would leave that read out of them. It is bypassed, read
|
|
5857
|
+
# and write, while this scope carries guard narrowings of constants ({#hand_off_memo_bypassed?}, #2524): they
|
|
5858
|
+
# survive `with_self_type` and change what a constant in the def's body types as, so the census is no longer
|
|
5859
|
+
# the def's alone, and the first caller's narrowing must not become every later caller's answer.
|
|
5860
|
+
def resolved_census(kind, def_node, &)
|
|
5861
|
+
return yield if hand_off_memo_bypassed?
|
|
5862
|
+
|
|
5863
|
+
discovery = scope.discovery
|
|
5864
|
+
table = (Thread.current[RESOLVED_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
|
|
5865
|
+
slot = (table[discovery] ||= [{}, {}])
|
|
5866
|
+
self_type = scope.self_type
|
|
5867
|
+
by_class = (slot[self_type.is_a?(Type::Singleton) ? 1 : 0][kind] ||= {})
|
|
5868
|
+
class_name = enclosing_class_name_for(self_type)
|
|
5869
|
+
by_def = (by_class[class_name] ||= {}.compare_by_identity)
|
|
5870
|
+
return by_def[def_node] = yield unless by_def.key?(def_node)
|
|
5871
|
+
|
|
5872
|
+
MemoAudit.check("resolved census", by_def[def_node], kind, class_name, &)
|
|
5873
|
+
end
|
|
5874
|
+
|
|
5875
|
+
RESOLVED_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_resolved_census)
|
|
5876
|
+
private_constant :RESOLVED_CENSUS_KEY
|
|
5877
|
+
|
|
5878
|
+
# The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
|
|
5879
|
+
# run later / repeatedly) AND those mutated directly in the method body during the call itself
|
|
5880
|
+
# (`declaration[:prefix] = v`). A property of the def's AST alone, so it is memoised per def node for the thread
|
|
5881
|
+
# ({#def_census}) rather than per evaluator: a chain reads its callees' census from every evaluator that floors a
|
|
5882
|
+
# call into it.
|
|
5883
|
+
def own_content_parameters(def_node)
|
|
5884
|
+
def_census(:own, def_node) do
|
|
5885
|
+
own = escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
|
|
5886
|
+
written = callee_write_census(def_node).written
|
|
5887
|
+
own = own.merge(written_slots(def_node, written)) unless written.empty?
|
|
5888
|
+
handed = super_forwarded_parameters(def_node)
|
|
5889
|
+
handed.empty? ? own : own.merge(handed)
|
|
5890
|
+
end
|
|
5891
|
+
end
|
|
5892
|
+
|
|
5893
|
+
# Issue #2595 — the parameter writes the content scans above miss: a shape mutator they do not count
|
|
5894
|
+
# (`h.merge!(x)`), a write through an alias (`other = h`, `h.tap { |x| }`, `[h].each { |x| }`, `@x = h`), and
|
|
5895
|
+
# the aliases themselves, which {#forwarded_parameters} follows as hand-offs ({CalleeWriteCensus}). A property
|
|
5896
|
+
# of the def's AST alone, memoised per def node for the thread.
|
|
5897
|
+
def callee_write_census(def_node)
|
|
5898
|
+
def_census(:writes, def_node) do
|
|
5899
|
+
CalleeWriteCensus.scan(def_node.body, census_seeds(def_node),
|
|
5900
|
+
tables: CENSUS_TABLES, rebinds: parameter_rebinds(def_node),
|
|
5901
|
+
returns: returns_counted?(def_node), anonymous: anonymous_rests?(def_node))
|
|
5902
|
+
end
|
|
5903
|
+
end
|
|
5904
|
+
|
|
5905
|
+
# Issue #2336 — the calls whose block is a method body of the class (`define_method(:go) { poke(h) }`): the
|
|
5906
|
+
# hand-off walk leaves it out ({#each_node_in_method_scope}), so the write census reads a call in it that passes
|
|
5907
|
+
# a parameter as written ({CalleeWriteCensus}). A block run on another `self` (`instance_exec`, `Class.new`)
|
|
5908
|
+
# stays out: a self-call there is not a call on the method's receiver.
|
|
5909
|
+
METHOD_BODY_BLOCK_CALLS = %i[define_method define_singleton_method].to_set.freeze
|
|
5910
|
+
private_constant :METHOD_BODY_BLOCK_CALLS
|
|
5911
|
+
|
|
5912
|
+
# Issue #2336 — whether the value a def returns counts as a write of the parameter it is ({CalleeWriteCensus}):
|
|
5913
|
+
# not for `initialize`, whose value `new` drops, nor for a setter (`x=`, `[]=`), whose value an assignment drops.
|
|
5914
|
+
def returns_counted?(def_node)
|
|
5915
|
+
name = def_node.name
|
|
5916
|
+
name != :initialize && name != :[]= && !SETTER_NAME.match?(name)
|
|
5917
|
+
end
|
|
5918
|
+
|
|
5919
|
+
# An attribute writer's name: an identifier followed by `=` (`format=`), not an operator (`==`, `<=`, `===`).
|
|
5920
|
+
SETTER_NAME = /\A[A-Za-z_][A-Za-z0-9_]*=\z/
|
|
5921
|
+
private_constant :SETTER_NAME
|
|
5922
|
+
|
|
5923
|
+
# Issue #2336 — true when `def_node` has an anonymous rest (`*`), keyword rest (`**`) or `...`, whose arguments
|
|
5924
|
+
# an opaque sink may receive ({CalleeWriteCensus::ANONYMOUS_REST}).
|
|
5925
|
+
def anonymous_rests?(def_node)
|
|
5926
|
+
params = def_node.parameters
|
|
5927
|
+
return false if params.nil?
|
|
5928
|
+
|
|
5929
|
+
(params.rest.is_a?(Prism::RestParameterNode) && params.rest.name.nil?) ||
|
|
5930
|
+
(params.keyword_rest.is_a?(Prism::KeywordRestParameterNode) && params.keyword_rest.name.nil?) ||
|
|
5931
|
+
params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
|
|
5932
|
+
end
|
|
5933
|
+
|
|
5934
|
+
# The parameters {#callee_write_census} follows: each content parameter holds the caller's argument itself
|
|
5935
|
+
# (`:same`); a named rest (`*args`) or keyword rest (`**kw`) holds the caller's arguments as elements (`:held`).
|
|
5936
|
+
def census_seeds(def_node)
|
|
5937
|
+
seeds = content_parameter_slots(def_node).transform_values { :same }
|
|
5938
|
+
rest_parameter_names(def_node).each_key { |name| seeds[name] = :held }
|
|
5939
|
+
seeds
|
|
5940
|
+
end
|
|
5941
|
+
|
|
5942
|
+
# `{ name => census key }` for a named rest (`*args` reads as {REST_PARAMETER}) and keyword rest (`**kw` reads as
|
|
5943
|
+
# {KEYWORD_REST_PARAMETER}) of `def_node`.
|
|
5944
|
+
def rest_parameter_names(def_node)
|
|
5945
|
+
params = def_node.parameters
|
|
5946
|
+
return NO_PARAMETER_SLOTS if params.nil?
|
|
5947
|
+
|
|
5948
|
+
rest = params.rest
|
|
5949
|
+
keyword_rest = params.keyword_rest
|
|
5950
|
+
names = {}
|
|
5951
|
+
names[rest.name] = REST_PARAMETER if rest.is_a?(Prism::RestParameterNode) && rest.name
|
|
5952
|
+
names[keyword_rest.name] = KEYWORD_REST_PARAMETER if keyword_rest.is_a?(Prism::KeywordRestParameterNode) &&
|
|
5953
|
+
keyword_rest.name
|
|
5954
|
+
names
|
|
5955
|
+
end
|
|
5956
|
+
|
|
5957
|
+
# The own-census entries for the parameter names {#callee_write_census} wrote: a content parameter at its slot,
|
|
5958
|
+
# a rest at {ANY_SLOT} and a keyword rest at {ANY_KEYWORD}, the slots every argument they bind reaches.
|
|
5959
|
+
#
|
|
5960
|
+
# Issue #2336 — an anonymous rest, keyword rest or `...` an opaque sink received
|
|
5961
|
+
# ({CalleeWriteCensus::ANONYMOUS_REST}) is written at its slot too.
|
|
5962
|
+
def written_slots(def_node, written)
|
|
5963
|
+
found = content_parameter_slots(def_node).slice(*written)
|
|
5964
|
+
rest_parameter_names(def_node).each do |name, key|
|
|
5965
|
+
found[key] = key == REST_PARAMETER ? ANY_SLOT : ANY_KEYWORD if written.include?(name)
|
|
5966
|
+
end
|
|
5967
|
+
rests = rest_parameters(def_node)
|
|
5968
|
+
if written.include?(CalleeWriteCensus::ANONYMOUS_REST) && rests.include?(REST_PARAMETER)
|
|
5969
|
+
found[REST_PARAMETER] = ANY_SLOT
|
|
5970
|
+
end
|
|
5971
|
+
if written.include?(CalleeWriteCensus::ANONYMOUS_KEYWORD_REST) && rests.include?(KEYWORD_REST_PARAMETER)
|
|
5972
|
+
found[KEYWORD_REST_PARAMETER] = ANY_KEYWORD
|
|
5973
|
+
end
|
|
5974
|
+
found
|
|
5975
|
+
end
|
|
5976
|
+
|
|
5977
|
+
# Issue #2521 (#2570 review) — the parameters `def_node` hands to the method it overrides: every content
|
|
5978
|
+
# parameter under a zsuper (`super`, which passes them all), and each one an explicit `super(...)` passes as an
|
|
5979
|
+
# argument or keyword value, at the block depth the call sits at. The overridden def is not followed; the
|
|
5980
|
+
# parameter reads as mutated, so a literal handed through an override that forwards it is floored.
|
|
5981
|
+
#
|
|
5982
|
+
# A rest parameter (`*`, `*args`, `...`) handed on whole (a zsuper, `super(*args)`, `super(...)`) reads as
|
|
5983
|
+
# mutated under {REST_PARAMETER}, the name an argument the call binds to the rest parameter reaches
|
|
5984
|
+
# ({#compute_forwarding_edges}), at {ANY_SLOT} ({#arguments_for_slots}).
|
|
5985
|
+
def super_forwarded_parameters(def_node)
|
|
5986
|
+
# A cheap gate first: the walk below runs for the few defs whose source mentions `super` at all.
|
|
5987
|
+
body = def_node.body
|
|
5988
|
+
return NO_PARAMETER_SLOTS if body.nil? || !body.slice.include?("super")
|
|
5989
|
+
|
|
5990
|
+
slots = content_parameter_slots(def_node)
|
|
5991
|
+
rests = rest_parameters(def_node)
|
|
5992
|
+
return NO_PARAMETER_SLOTS if slots.empty? && rests.empty?
|
|
5993
|
+
|
|
5994
|
+
names = super_forwarded_names(def_node, slots, rests)
|
|
5995
|
+
return NO_PARAMETER_SLOTS if names.nil?
|
|
5996
|
+
|
|
5997
|
+
found = slots.slice(*names)
|
|
5998
|
+
found[REST_PARAMETER] = ANY_SLOT if names.include?(REST_PARAMETER)
|
|
5999
|
+
found[KEYWORD_REST_PARAMETER] = ANY_KEYWORD if names.include?(KEYWORD_REST_PARAMETER)
|
|
6000
|
+
found
|
|
6001
|
+
end
|
|
6002
|
+
|
|
6003
|
+
# The parameter names `def_node` hands to `super` ({#super_forwarded_parameters}), or nil when it calls none.
|
|
6004
|
+
# `rests` holds the rest names the def has ({#rest_parameters}).
|
|
6005
|
+
def super_forwarded_names(def_node, slots, rests)
|
|
6006
|
+
names = nil
|
|
6007
|
+
each_node_in_method_scope(def_node.body, 0) do |node, depth|
|
|
6008
|
+
case node
|
|
6009
|
+
when Prism::ForwardingSuperNode
|
|
6010
|
+
(names ||= Set.new).merge(slots.keys.reject { |name| after_rebind?(def_node, name, node) }).merge(rests)
|
|
6011
|
+
when Prism::SuperNode
|
|
6012
|
+
handed = super_argument_parameters(node, slots, depth).reject { |name| after_rebind?(def_node, name, node) }
|
|
6013
|
+
(names ||= Set.new).merge(handed)
|
|
6014
|
+
names.merge(rests & splatted_rests(node))
|
|
6015
|
+
end
|
|
6016
|
+
end
|
|
6017
|
+
names
|
|
6018
|
+
end
|
|
6019
|
+
|
|
6020
|
+
# The names {#super_forwarded_parameters} records a positional and a keyword rest parameter handed to `super`
|
|
6021
|
+
# under; no local can be named with a leading `*`.
|
|
6022
|
+
REST_PARAMETER = :"*rest"
|
|
6023
|
+
KEYWORD_REST_PARAMETER = :"**rest"
|
|
6024
|
+
REST_NAMES = [REST_PARAMETER].freeze
|
|
6025
|
+
KEYWORD_REST_NAMES = [KEYWORD_REST_PARAMETER].freeze
|
|
6026
|
+
private_constant :REST_PARAMETER, :KEYWORD_REST_PARAMETER, :REST_NAMES, :KEYWORD_REST_NAMES
|
|
6027
|
+
|
|
6028
|
+
# The rest names an argument the call binds to no named parameter of `callee` reaches, when `callee` hands that
|
|
6029
|
+
# rest parameter to `super` ({#super_forwarded_parameters}); empty otherwise. `slot` is the call's positional
|
|
6030
|
+
# index, or the keyword.
|
|
6031
|
+
def handed_rest_names(callee, slot)
|
|
6032
|
+
own = own_content_parameters(callee)
|
|
6033
|
+
if slot.is_a?(Symbol) then own.key?(KEYWORD_REST_PARAMETER) ? KEYWORD_REST_NAMES : NO_FORWARDS
|
|
6034
|
+
else own.key?(REST_PARAMETER) ? REST_NAMES : NO_FORWARDS
|
|
6035
|
+
end
|
|
6036
|
+
end
|
|
6037
|
+
|
|
6038
|
+
# The rest parameters `def_node` has, as {REST_PARAMETER} / {KEYWORD_REST_PARAMETER}: `*args` / `*`, `**kw` /
|
|
6039
|
+
# `**`, both for `...`.
|
|
6040
|
+
def rest_parameters(def_node)
|
|
6041
|
+
params = def_node.parameters
|
|
6042
|
+
return NO_FORWARDS if params.nil?
|
|
6043
|
+
|
|
6044
|
+
forwarding = params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
|
|
6045
|
+
rests = []
|
|
6046
|
+
rests << REST_PARAMETER if forwarding || params.rest.is_a?(Prism::RestParameterNode)
|
|
6047
|
+
rests << KEYWORD_REST_PARAMETER if forwarding || params.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
|
|
6048
|
+
rests
|
|
6049
|
+
end
|
|
6050
|
+
|
|
6051
|
+
# The rests an explicit `super(...)` passes on whole: a splat (`*args`, `*`), a double splat (`**kw`, `**`) in its
|
|
6052
|
+
# keywords, or `...` for both.
|
|
6053
|
+
def splatted_rests(node)
|
|
6054
|
+
(node.arguments&.arguments || []).flat_map do |argument|
|
|
6055
|
+
case argument
|
|
6056
|
+
when Prism::SplatNode then REST_NAMES
|
|
6057
|
+
when Prism::ForwardingArgumentsNode then [REST_PARAMETER, KEYWORD_REST_PARAMETER]
|
|
6058
|
+
when Prism::KeywordHashNode
|
|
6059
|
+
argument.elements.any?(Prism::AssocSplatNode) ? KEYWORD_REST_NAMES : NO_FORWARDS
|
|
6060
|
+
else NO_FORWARDS
|
|
6061
|
+
end
|
|
6062
|
+
end
|
|
6063
|
+
end
|
|
6064
|
+
|
|
6065
|
+
def super_argument_parameters(node, slots, depth)
|
|
6066
|
+
arguments = node.arguments&.arguments || []
|
|
6067
|
+
arguments.flat_map do |argument|
|
|
6068
|
+
if argument.is_a?(Prism::KeywordHashNode)
|
|
6069
|
+
argument.elements.filter_map do |pair|
|
|
6070
|
+
parameter_read_name(pair.value, slots, depth) if pair.is_a?(Prism::AssocNode)
|
|
6071
|
+
end
|
|
6072
|
+
else
|
|
6073
|
+
[parameter_read_name(argument, slots, depth)].compact
|
|
6074
|
+
end
|
|
6075
|
+
end
|
|
6076
|
+
end
|
|
6077
|
+
|
|
6078
|
+
# Issue #2296 — the parameters `def_node` content-mutates inside a block of its body, at any nesting: a
|
|
6079
|
+
# mutation whose receiver reads the parameter itself at the block depth it sits at, so a block parameter or
|
|
6080
|
+
# block-local of the same name, and a block-local copy (`t = [acc[i]]; t[0] = v`), do not count. A lambda or
|
|
6081
|
+
# nested def is not entered (its body is not run by the call), nor is a block that runs on another `self`
|
|
6082
|
+
# ({#each_node_in_method_scope}). The walk counts the method body too: a mutation there, or in an escaping
|
|
6083
|
+
# block, is one the bare census ({#own_content_parameters}) also counts, and its floor runs first, leaving this
|
|
6084
|
+
# one nothing to widen. A property of the AST alone, memoised per def node for the thread.
|
|
6085
|
+
def own_block_content_parameters(def_node) = block_and_set_parameters(def_node)[0]
|
|
6086
|
+
|
|
6087
|
+
# Issue #2296 — the parameters `def_node` grows through a Set-only adder ({SET_ONLY_ADDERS}) in its body or a
|
|
6088
|
+
# block in it, read at the depth it sits at as above. The name alone does not say the receiver is a Set
|
|
6089
|
+
# (`Hash#merge` returns a new Hash), so only a Set argument is floored on it
|
|
6090
|
+
# ({UnknownStoreWidening.callee_set_floor}).
|
|
6091
|
+
def own_set_content_parameters(def_node) = block_and_set_parameters(def_node)[1]
|
|
6092
|
+
|
|
6093
|
+
# Both censuses above, `[block, set]`, from one walk of the method scope, memoised per def node for the thread.
|
|
6094
|
+
def block_and_set_parameters(def_node)
|
|
6095
|
+
def_census(:block_and_set, def_node) do
|
|
6096
|
+
slots = content_parameter_slots(def_node)
|
|
6097
|
+
next NO_BLOCK_AND_SET if slots.empty?
|
|
6098
|
+
|
|
6099
|
+
block = nil
|
|
6100
|
+
set = nil
|
|
6101
|
+
each_node_in_method_scope(def_node.body, 0) do |node, depth|
|
|
6102
|
+
name = content_mutation_target(node) { |r| r.depth == depth }.first
|
|
6103
|
+
(block ||= Set.new) << name if counted_mutation?(def_node, slots, name, node)
|
|
6104
|
+
name = set_only_adder_target(node, depth)
|
|
6105
|
+
(set ||= Set.new) << name if counted_mutation?(def_node, slots, name, node)
|
|
6106
|
+
end
|
|
6107
|
+
[block.nil? ? NO_PARAMETER_SLOTS : slots.slice(*block), set.nil? ? NO_PARAMETER_SLOTS : slots.slice(*set)]
|
|
6108
|
+
end
|
|
6109
|
+
end
|
|
6110
|
+
|
|
6111
|
+
# True when `name`, the local the mutation `node` targets, is one of `slots`' parameters and `node` runs before
|
|
6112
|
+
# any rebind of it ({#after_rebind?}).
|
|
6113
|
+
def counted_mutation?(def_node, slots, name, node)
|
|
6114
|
+
!name.nil? && slots.key?(name) && !after_rebind?(def_node, name, node)
|
|
6115
|
+
end
|
|
6116
|
+
|
|
6117
|
+
# The local `node` grows through a Set-only adder when its receiver reads that local at `depth`, else nil.
|
|
6118
|
+
def set_only_adder_target(node, depth)
|
|
6119
|
+
return unless node.is_a?(Prism::CallNode) && SET_ONLY_ADDERS.include?(node.name)
|
|
6120
|
+
|
|
6121
|
+
receiver = node.receiver
|
|
6122
|
+
receiver.name if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == depth
|
|
6123
|
+
end
|
|
6124
|
+
|
|
6125
|
+
NO_PARAMETER_SLOTS = {}.freeze
|
|
6126
|
+
NO_BLOCK_AND_SET = [NO_PARAMETER_SLOTS, NO_PARAMETER_SLOTS].freeze
|
|
6127
|
+
private_constant :NO_PARAMETER_SLOTS, :NO_BLOCK_AND_SET
|
|
6128
|
+
|
|
6129
|
+
# Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
|
|
6130
|
+
def def_census(kind, def_node, &)
|
|
6131
|
+
table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
|
|
6132
|
+
entry = (table[def_node] ||= {})
|
|
6133
|
+
return entry[kind] = yield unless entry.key?(kind)
|
|
6134
|
+
|
|
6135
|
+
MemoAudit.check("def census", entry[kind], kind, &)
|
|
6136
|
+
end
|
|
6137
|
+
|
|
6138
|
+
DEF_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_def_census)
|
|
6139
|
+
private_constant :DEF_CENSUS_KEY
|
|
6140
|
+
|
|
6141
|
+
# Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
|
|
6142
|
+
# a self-call resolving to a user def: a positional argument or a keyword value that reads the parameter itself
|
|
6143
|
+
# (not a block-local shadowing it), in the method body or a block in it. The callee resolves in this evaluator's
|
|
6144
|
+
# class, the receiver's class at every hop of a self-call chain. Memoised per def node, and across evaluators by
|
|
6145
|
+
# {#resolved_census}.
|
|
6146
|
+
def forwarding_edges(def_node)
|
|
6147
|
+
cache = (@forwarding_edge_cache ||= {}.compare_by_identity)
|
|
6148
|
+
cache[def_node] ||= resolved_census(:edges, def_node) { compute_forwarding_edges(def_node) }
|
|
6149
|
+
end
|
|
6150
|
+
|
|
6151
|
+
# True for a hand-off answer with more than one candidate, or resolved by name ({UnionCallees}): its edges are
|
|
6152
|
+
# over-approximations ({CalleeMutationChain::Edge#approx}).
|
|
6153
|
+
def approximate_hand_off?(callees) = callees.size > 1 || callees.is_a?(UnionCallees)
|
|
6154
|
+
|
|
6155
|
+
# True for a hand-off answer that reads the handed parameter as written whatever the callee is: a name with too
|
|
6156
|
+
# many defs ({CAPPED_HAND_OFF}) or a top-level def that may not be the one called ({FLOOR_EVERY}).
|
|
6157
|
+
def floors_outright?(callees)
|
|
6158
|
+
callees.equal?(CAPPED_HAND_OFF) || callees.any? { |_callee, _offset, owner| owner == FLOOR_EVERY }
|
|
6159
|
+
end
|
|
6160
|
+
|
|
6161
|
+
def compute_forwarding_edges(def_node)
|
|
6162
|
+
slots = forwarding_slots(def_node)
|
|
6163
|
+
edges = []
|
|
6164
|
+
each_forwarding_call(def_node, slots) do |call_node, forwarded, depth|
|
|
6165
|
+
callees = hand_off_callees(call_node, def_node, depth)
|
|
6166
|
+
if floors_outright?(callees)
|
|
6167
|
+
forwarded.map(&:first).each do |name|
|
|
6168
|
+
edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: def_node,
|
|
6169
|
+
callee_names: [name], owner: FLOOR_ALL)
|
|
6170
|
+
end
|
|
6171
|
+
next
|
|
6172
|
+
end
|
|
6173
|
+
|
|
6174
|
+
callees.each do |callee, offset, owner|
|
|
6175
|
+
callee_slots = content_parameter_slots(callee)
|
|
6176
|
+
forwarded.each do |name, index|
|
|
6177
|
+
names = bound_parameter_names(callee_slots, index.is_a?(Integer) ? index - offset : index)
|
|
6178
|
+
names = handed_rest_names(callee, index) if names.empty? && index != ANY_SLOT
|
|
6179
|
+
next if names.empty?
|
|
6180
|
+
|
|
6181
|
+
edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: callee, callee_names: names,
|
|
6182
|
+
owner: owner, approx: approximate_hand_off?(callees))
|
|
6183
|
+
end
|
|
6184
|
+
end
|
|
6185
|
+
end
|
|
6186
|
+
edges.empty? ? NO_FORWARDS : edges
|
|
6187
|
+
end
|
|
6188
|
+
|
|
6189
|
+
# Yields each self-call in `def_node`'s body that hands on one of `slots`' parameters, with
|
|
6190
|
+
# {#forwarded_parameters}' answer for it. The calls are an AST property, memoised per def node ({#def_census}).
|
|
6191
|
+
def each_forwarding_call(def_node, slots, &)
|
|
6192
|
+
return if slots.empty?
|
|
6193
|
+
|
|
6194
|
+
def_census(:forwards, def_node) { forwarding_calls(def_node, slots) }.each(&)
|
|
6195
|
+
end
|
|
6196
|
+
|
|
6197
|
+
def forwarding_calls(def_node, slots)
|
|
6198
|
+
calls = []
|
|
6199
|
+
context = HandOffContext.new(census: callee_write_census(def_node), seeds: census_seeds(def_node),
|
|
6200
|
+
rests: rest_parameter_names(def_node), rebinds: parameter_rebinds(def_node))
|
|
6201
|
+
each_node_in_method_scope(def_node.body, 0) do |descendant, depth|
|
|
6202
|
+
next unless self_dispatch_call?(descendant) || hand_off_receiver?(descendant)
|
|
6203
|
+
|
|
6204
|
+
forwarded = forwarded_parameters(descendant, slots, depth, context)
|
|
6205
|
+
calls << [descendant, forwarded, depth] unless forwarded.empty?
|
|
6206
|
+
end
|
|
6207
|
+
calls.empty? ? NO_FORWARDS : calls
|
|
6208
|
+
end
|
|
6209
|
+
|
|
6210
|
+
NO_FORWARDS = [].freeze
|
|
6211
|
+
private_constant :NO_FORWARDS
|
|
6212
|
+
|
|
6213
|
+
# `[[parameter_name, callee_slot], ...]` for the arguments of `call_node` that read one of `slots`' parameters at
|
|
6214
|
+
# `depth`, the block nesting the call sits at: a read of the method's own parameter there has that depth. An
|
|
6215
|
+
# argument after a splat or `...` has an index that says nothing about the parameter it binds, so it binds
|
|
6216
|
+
# {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index among the call's
|
|
6217
|
+
# arguments, which {#compute_forwarding_edges} shifts by each callee's offset ({#resolve_self_callees}).
|
|
6218
|
+
# Issue #2595 — an argument that reads an alias of a parameter (`other = h; poke(other)`, `@x = h; poke(@x)`, a
|
|
6219
|
+
# block parameter of `h.tap`) hands on that parameter too (`census`, {#callee_write_census}).
|
|
6220
|
+
def forwarded_parameters(call_node, slots, depth, context = NO_HAND_OFF_CONTEXT)
|
|
6221
|
+
args = call_node.arguments
|
|
6222
|
+
return NO_FORWARDS unless args.is_a?(Prism::ArgumentsNode)
|
|
6223
|
+
|
|
6224
|
+
spread = false
|
|
6225
|
+
forwarded = []
|
|
6226
|
+
args.arguments.each_with_index do |argument, index|
|
|
6227
|
+
if argument.is_a?(Prism::KeywordHashNode)
|
|
6228
|
+
forwarded.concat(forwarded_keywords(argument, slots, depth, context))
|
|
6229
|
+
elsif argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
|
|
6230
|
+
spread = true
|
|
6231
|
+
rest = splatted_rest(argument, depth, context)
|
|
6232
|
+
forwarded << [rest, ANY_SLOT] if rest
|
|
6233
|
+
else
|
|
6234
|
+
slot = spread ? ANY_SLOT : index
|
|
6235
|
+
handed_names(argument, slots, depth, context) { |name| forwarded << [name, slot] }
|
|
6236
|
+
end
|
|
6237
|
+
end
|
|
6238
|
+
forwarded
|
|
6239
|
+
end
|
|
6240
|
+
|
|
6241
|
+
# The `callee_slot` of an argument whose parameter the call does not show (one after a splat).
|
|
6242
|
+
ANY_SLOT = :__any_positional__
|
|
6243
|
+
|
|
6244
|
+
# The census {FLOOR_EVERY} answers: the rest and keyword-rest slots, which {#arguments_for_slots} reads as every
|
|
6245
|
+
# positional argument and every keyword value of the call.
|
|
6246
|
+
EVERY_ARGUMENT = { REST_PARAMETER => ANY_SLOT, KEYWORD_REST_PARAMETER => ANY_KEYWORD }.freeze
|
|
6247
|
+
private_constant :ANY_SLOT, :EVERY_ARGUMENT
|
|
6248
|
+
|
|
6249
|
+
# The names of the callee's parameters (`callee_slots`, {#content_parameter_slots}) an argument bound to `slot`
|
|
6250
|
+
# may reach: the one at that slot, or every positional one for {ANY_SLOT}.
|
|
6251
|
+
def bound_parameter_names(callee_slots, slot)
|
|
6252
|
+
if slot == ANY_SLOT
|
|
6253
|
+
callee_slots.filter_map { |name, at| name if at.is_a?(Integer) }
|
|
6254
|
+
elsif slot == ANY_KEYWORD
|
|
6255
|
+
callee_slots.filter_map { |name, at| name if at.is_a?(Symbol) }
|
|
6256
|
+
else
|
|
6257
|
+
callee_slots.filter_map { |name, at| name if at == slot }
|
|
6258
|
+
end
|
|
6259
|
+
end
|
|
6260
|
+
|
|
6261
|
+
# Issue #2336 — a double splat of the def's own keyword rest (`poke(**kw)`) hands it on whole, at {ANY_KEYWORD}.
|
|
6262
|
+
def forwarded_keywords(hash_node, slots, depth, context)
|
|
6263
|
+
found = []
|
|
6264
|
+
hash_node.elements.each do |pair|
|
|
6265
|
+
if pair.is_a?(Prism::AssocSplatNode)
|
|
6266
|
+
rest = splatted_keyword_rest(pair, depth, context)
|
|
6267
|
+
found << [rest, ANY_KEYWORD] if rest
|
|
6268
|
+
end
|
|
6269
|
+
next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
6270
|
+
|
|
6271
|
+
keyword = pair.key.unescaped.to_sym
|
|
6272
|
+
handed_names(pair.value, slots, depth, context) { |name| found << [name, keyword] }
|
|
6273
|
+
end
|
|
6274
|
+
found
|
|
6275
|
+
end
|
|
6276
|
+
|
|
6277
|
+
# Yields each parameter `node` may hand on: the one it reads ({#parameter_read_name}) and those it may be through
|
|
6278
|
+
# an alias or an expression ({CalleeWriteCensus.handed_aliases}). An element of a named rest or keyword rest
|
|
6279
|
+
# (`x = args[0]; poke(x)`, `poke(kw[:h])`) hands on that rest, under {REST_PARAMETER} / {KEYWORD_REST_PARAMETER}
|
|
6280
|
+
# ({#forwarding_slots}).
|
|
6281
|
+
#
|
|
6282
|
+
# Issue #2336 — a read of the parameter after the def rebound it to a new object (`acc = acc.dup; poke(acc)`)
|
|
6283
|
+
# hands on the copy, not the caller's argument ({#after_rebind?}).
|
|
6284
|
+
def handed_names(node, slots, depth, context)
|
|
6285
|
+
name = parameter_read_name(node, slots, depth)
|
|
6286
|
+
yield name if name && !CalleeWriteCensus.rebound?(context.rebinds, name, node)
|
|
6287
|
+
node = node.value if node.is_a?(Prism::ImplicitNode)
|
|
6288
|
+
aliases = CalleeWriteCensus.handed_aliases(context.census, context.seeds, node, depth,
|
|
6289
|
+
fresh: FRESH_RESULTS, copiers: CENSUS_COPIERS)
|
|
6290
|
+
aliases.each do |aliased|
|
|
6291
|
+
next if aliased == name
|
|
6292
|
+
|
|
6293
|
+
if slots.key?(aliased) then yield aliased
|
|
6294
|
+
elsif (rest = context.rests[aliased]) then yield rest
|
|
6295
|
+
end
|
|
6296
|
+
end
|
|
6297
|
+
end
|
|
3985
6298
|
|
|
3986
|
-
|
|
3987
|
-
|
|
6299
|
+
# The keyword-rest key a `**kw` double splat of the def's own named keyword rest hands on whole, or nil.
|
|
6300
|
+
def splatted_keyword_rest(pair, depth, context)
|
|
6301
|
+
read = pair.value
|
|
6302
|
+
return unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == depth
|
|
3988
6303
|
|
|
3989
|
-
[
|
|
6304
|
+
rest = context.rests[read.name]
|
|
6305
|
+
rest if rest == KEYWORD_REST_PARAMETER
|
|
3990
6306
|
end
|
|
3991
6307
|
|
|
3992
|
-
# The
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
6308
|
+
# The rest key a `*args` splat of the def's own named rest hands on whole (`poke(*args)`), or nil.
|
|
6309
|
+
def splatted_rest(argument, depth, context)
|
|
6310
|
+
read = argument.is_a?(Prism::SplatNode) ? argument.expression : nil
|
|
6311
|
+
return unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == depth
|
|
6312
|
+
|
|
6313
|
+
rest = context.rests[read.name]
|
|
6314
|
+
rest if rest == REST_PARAMETER
|
|
3996
6315
|
end
|
|
3997
6316
|
|
|
3998
|
-
#
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
6317
|
+
# What {#forwarded_parameters} reads a def's hand-offs with: its write census, its census seeds, its named
|
|
6318
|
+
# rests and its parameter rebinds ({#parameter_rebinds}).
|
|
6319
|
+
HandOffContext = Data.define(:census, :seeds, :rests, :rebinds)
|
|
6320
|
+
NO_HAND_OFF_CONTEXT = HandOffContext.new(census: CalleeWriteCensus::EMPTY, seeds: {}.freeze, rests: {}.freeze,
|
|
6321
|
+
rebinds: {}.freeze)
|
|
6322
|
+
private_constant :HandOffContext, :NO_HAND_OFF_CONTEXT
|
|
6323
|
+
|
|
6324
|
+
# The slots a hand-off's forwarded name maps to: each content parameter's, and {ANY_SLOT} / {ANY_KEYWORD} for the
|
|
6325
|
+
# rest keys an element of a named rest or keyword rest hands on under ({#handed_names}).
|
|
6326
|
+
def forwarding_slots(def_node)
|
|
6327
|
+
slots = content_parameter_slots(def_node)
|
|
6328
|
+
rest_parameter_names(def_node).each_value { |key| slots[key] = key == REST_PARAMETER ? ANY_SLOT : ANY_KEYWORD }
|
|
6329
|
+
slots
|
|
4007
6330
|
end
|
|
4008
6331
|
|
|
4009
|
-
#
|
|
4010
|
-
#
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
# The exclusion — the `class << ...` BODY, where `self` is the singleton class and the call defines a class
|
|
4015
|
-
# method — rides on `Scope#singleton_class_body?`, not on the frame stack: a `def` reached from that body
|
|
4016
|
-
# still carries the singleton frame although its `self` is the class object, and it is an instance method
|
|
4017
|
-
# the call defines there. Carrying the mark on the scope is also what lets the return-typing path apply the
|
|
4018
|
-
# same exclusion, which has no frame stack of its own.
|
|
4019
|
-
def narrow_define_method_block_self(call_node, block_entry)
|
|
4020
|
-
narrowed = DefineMethodBlockSelf.narrow_self_type_for(
|
|
4021
|
-
scope: scope, call_node: call_node
|
|
4022
|
-
)
|
|
4023
|
-
return block_entry unless narrowed
|
|
6332
|
+
# The parameter `node` reads when it is a read of one of `slots`' names at `depth` (a keyword shorthand
|
|
6333
|
+
# `poke(acc:)` wraps the read in an `ImplicitNode`), else nil.
|
|
6334
|
+
def parameter_read_name(node, slots, depth)
|
|
6335
|
+
node = node.value if node.is_a?(Prism::ImplicitNode)
|
|
6336
|
+
return unless node.is_a?(Prism::LocalVariableReadNode) && node.depth == depth
|
|
4024
6337
|
|
|
4025
|
-
|
|
6338
|
+
node.name if slots.key?(node.name)
|
|
4026
6339
|
end
|
|
4027
6340
|
|
|
4028
|
-
#
|
|
4029
|
-
#
|
|
4030
|
-
# {#
|
|
4031
|
-
#
|
|
4032
|
-
# constant-write rvalue ({#eval_constant_write}) — so the two cannot drift on what a class body's entry is.
|
|
6341
|
+
# The `[def, offset]` pairs a self-dispatch `node` resolves to in the enclosing class, `offset` being how many
|
|
6342
|
+
# leading arguments of `node` are not the def's. Issue #2521 — the instance def is looked up along the class's
|
|
6343
|
+
# ancestors ({Scope#user_def_through_ancestors}), as Ruby dispatches the call: an inherited def is now walked
|
|
6344
|
+
# under the receiver's class, so a self-call inside it to another inherited def must still be found there.
|
|
4033
6345
|
#
|
|
4034
|
-
# Issue #
|
|
4035
|
-
#
|
|
4036
|
-
#
|
|
4037
|
-
#
|
|
4038
|
-
#
|
|
4039
|
-
#
|
|
6346
|
+
# Issue #2297 — in a singleton-side body (`def self.x`, a `def` in `class << self`), whose `self` is the class
|
|
6347
|
+
# object, a self-call resolves to the singleton def of that name AND to the same-named instance def, so the floor
|
|
6348
|
+
# applies when either mutates. Ruby calls only the singleton side, but the hand-offs that reach an instance def
|
|
6349
|
+
# from there (`new.call(acc)`, `instance.helper(acc)`) and the singleton defs the index does not record
|
|
6350
|
+
# (`module_function :poke`, `define_singleton_method`, `alias_method` in `class << self`) are not followed, and
|
|
6351
|
+
# dropping the instance def floored too little on correct code
|
|
6352
|
+
# (`approval-queue.d/callee-floor-singleton-side.md`).
|
|
6353
|
+
# An instance-side body resolves to instance defs only.
|
|
4040
6354
|
#
|
|
4041
|
-
#
|
|
4042
|
-
#
|
|
4043
|
-
#
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
6355
|
+
# A `send(:name, ...)` / `public_send(:name, ...)` with a symbol literal resolves as the self-call `name(...)`,
|
|
6356
|
+
# offset 1, on each side that has no def of the `send` / `public_send` name itself; on a side that has one, it is
|
|
6357
|
+
# an ordinary self-call to that def, offset 0.
|
|
6358
|
+
def resolve_self_callees(node)
|
|
6359
|
+
self_type = scope.self_type
|
|
6360
|
+
class_name = enclosing_class_name_for(self_type)
|
|
6361
|
+
return top_level_callees(node, nil) if class_name.nil?
|
|
6362
|
+
|
|
6363
|
+
instance = ancestor_side_callee(node, class_name) do |name|
|
|
6364
|
+
scope.user_def_through_ancestors(class_name, name)
|
|
6365
|
+
end
|
|
6366
|
+
side = self_type.is_a?(Type::Singleton) ? :singleton : :instance
|
|
6367
|
+
singleton = inherited_singleton_callee(node, self_type) if side == :singleton
|
|
6368
|
+
return unanswered_self_callees(node, class_name, side) unless instance || singleton
|
|
6369
|
+
return [instance] if singleton.nil?
|
|
6370
|
+
return [singleton] if instance.nil?
|
|
6371
|
+
return [instance] if singleton.first.equal?(instance.first)
|
|
6372
|
+
|
|
6373
|
+
[singleton, instance]
|
|
6374
|
+
end
|
|
6375
|
+
|
|
6376
|
+
# Issue #2515 — the `[def, offset, owner]` a receiverless call resolves to in the project's top-level defs: a
|
|
6377
|
+
# top-level def is a private method on `Object`, so a call outside any class body, and a call in a class whose
|
|
6378
|
+
# chain does not answer the name, reaches it. `class_name` is nil outside a class (the callee's own hand-offs
|
|
6379
|
+
# resolve in this evaluator, owner nil), else the class and side the chain was searched on. The def is read as
|
|
6380
|
+
# written only when nothing else can answer it ({ToplevelDefCensus.reliable?}, and for a class the chain
|
|
6381
|
+
# {#chain_may_answer?}); otherwise it reads as {FLOOR_EVERY}, every argument written. Nothing when no top-level
|
|
6382
|
+
# def has the name.
|
|
6383
|
+
def top_level_callees(node, class_name, side = nil)
|
|
6384
|
+
target = nil
|
|
6385
|
+
found = side_callee(node) do |name|
|
|
6386
|
+
def_node = scope.top_level_def_for(name)
|
|
6387
|
+
target = name unless def_node.nil?
|
|
6388
|
+
def_node
|
|
6389
|
+
end
|
|
6390
|
+
return NO_CALLEES if found.nil?
|
|
6391
|
+
|
|
6392
|
+
def_node, offset = found
|
|
6393
|
+
floor = !ToplevelDefCensus.reliable?(scope, target) ||
|
|
6394
|
+
(!class_name.nil? && chain_may_answer?(class_name, side, target))
|
|
6395
|
+
[[def_node, offset, if floor
|
|
6396
|
+
FLOOR_EVERY
|
|
6397
|
+
else
|
|
6398
|
+
(class_name.nil? ? nil : TOPLEVEL_OWNER)
|
|
6399
|
+
end]]
|
|
6400
|
+
end
|
|
6401
|
+
|
|
6402
|
+
# A self-call the class's own chain found no def for: the chain's `method_missing` answers it, else the project's
|
|
6403
|
+
# top-level def of that name (Ruby searches `Object` before `method_missing`, so with both the top-level def runs
|
|
6404
|
+
# and the `method_missing` reading is the over-approximation; both are returned and the top-level one floors
|
|
6405
|
+
# every argument).
|
|
6406
|
+
#
|
|
6407
|
+
# Issue #2336 — a name nothing above answers floors every argument when a `define_method` /
|
|
6408
|
+
# `define_singleton_method` / `alias_method` call gives it a body ({#self_ownerless_body?}).
|
|
6409
|
+
def unanswered_self_callees(node, class_name, side)
|
|
6410
|
+
missing = self_method_missing_callees(node, class_name, side)
|
|
6411
|
+
top = top_level_callees(node, class_name, side)
|
|
6412
|
+
if missing.empty? && top.empty?
|
|
6413
|
+
name = send_hand_off?(node) ? send_target(node) : node.name
|
|
6414
|
+
return self_ownerless_body?(name, class_name, side) ? OPAQUE_CALLEES : NO_CALLEES
|
|
6415
|
+
end
|
|
6416
|
+
return top if missing.empty?
|
|
6417
|
+
return missing if top.empty?
|
|
6418
|
+
|
|
6419
|
+
missing + top.map { |def_node, offset, _owner| [def_node, offset, FLOOR_EVERY] }
|
|
4052
6420
|
end
|
|
4053
6421
|
|
|
4054
|
-
#
|
|
4055
|
-
#
|
|
4056
|
-
#
|
|
4057
|
-
#
|
|
4058
|
-
#
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
# invalidates the local's `local_binding` facts). Locals
|
|
4064
|
-
# shadowed by a block parameter or a `;`-prefixed
|
|
4065
|
-
# block-local declaration are untouched. Locals the block
|
|
4066
|
-
# only reads (without writing) are also untouched: read-only
|
|
4067
|
-
# captures cannot rebind the outer variable.
|
|
4068
|
-
#
|
|
4069
|
-
# A `:non_escaping` classification (or any block-less call) leaves the post-call scope unchanged.
|
|
4070
|
-
def record_closure_escape_if_any(node)
|
|
4071
|
-
# ADR-57 slice 3 work-item 1: an escaping block can also be attached to a RECEIVER call in a chain rather than
|
|
4072
|
-
# to `node` itself — the canonical `OptionParser.new do |opts| opts.on { o[:k] = v } end .parse!(argv)` idiom,
|
|
4073
|
-
# where the content-mutating block hangs off `OptionParser.new` but the statement-level call node is the chained
|
|
4074
|
-
# `.parse!`. A receiver call is evaluated as an expression, never as a statement, so its block never reaches
|
|
4075
|
-
# this escape handler on its own. Fold each escaping receiver-chain block's content widening into the
|
|
4076
|
-
# continuation here so the captured collection is floored regardless of how deep in the receiver chain its
|
|
4077
|
-
# mutating block lives.
|
|
4078
|
-
post_scope = widen_escaping_receiver_chain_captures(node, scope)
|
|
6422
|
+
# True when something besides `Object` may answer a receiverless call of `name` in `class_name`'s `side`: the
|
|
6423
|
+
# class is no project class, a definer other than a `def` sits on its chain (`attr_*`, `define_method`, a budget
|
|
6424
|
+
# cut), or an ancestor the project does not declare is unknown to RBS or declares the name there (a
|
|
6425
|
+
# `< ActiveRecord::Base` the environment has no RBS for may define anything). The project's own `def`s were
|
|
6426
|
+
# searched already ({#resolve_self_callees}).
|
|
6427
|
+
def chain_may_answer?(class_name, side, name)
|
|
6428
|
+
return true unless project_class?(class_name)
|
|
6429
|
+
return true if scope.discovered_method_through_ancestors?(class_name, name, side)
|
|
6430
|
+
return true if rbs_declares?(class_name, name) || (side == :singleton && rbs_declares?("Class", name))
|
|
4079
6431
|
|
|
4080
|
-
|
|
6432
|
+
external = scope.external_ancestor_name_candidates(class_name)
|
|
6433
|
+
external += scope.singleton_extends_of(class_name).map { |module_name| [module_name] } if side == :singleton
|
|
6434
|
+
external.any? do |candidates|
|
|
6435
|
+
known = candidates.find { |candidate| Reflection.rbs_class_known?(candidate, scope: scope) }
|
|
6436
|
+
known.nil? ? candidates.none? { |candidate| project_class?(candidate) } : rbs_declares?(known, name)
|
|
6437
|
+
end
|
|
6438
|
+
end
|
|
4081
6439
|
|
|
4082
|
-
|
|
4083
|
-
|
|
6440
|
+
# A class or module the project declares, even one with no method of its own.
|
|
6441
|
+
def project_class?(name) = scope.known_user_class?(name) || scope.declared_class?(name)
|
|
4084
6442
|
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
bucket: :dynamic_origin,
|
|
4089
|
-
target: Analysis::FactStore::Target.new(kind: :closure, name: node.name.to_sym),
|
|
4090
|
-
predicate: :closure_escape,
|
|
4091
|
-
payload: { method_name: node.name.to_sym, classification: classification },
|
|
4092
|
-
stability: :unstable
|
|
4093
|
-
)
|
|
4094
|
-
)
|
|
6443
|
+
def rbs_declares?(class_name, name)
|
|
6444
|
+
!Reflection.instance_method_definition(class_name, name, scope: scope).nil? ||
|
|
6445
|
+
!Reflection.singleton_method_definition(class_name, name, scope: scope).nil?
|
|
4095
6446
|
end
|
|
4096
6447
|
|
|
4097
|
-
#
|
|
4098
|
-
#
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
# position, where its argument is never the statement node's own argument.
|
|
4104
|
-
acc = floor_callee_escaped_args_for_call(node, base_scope)
|
|
4105
|
-
receiver = node.receiver
|
|
4106
|
-
while receiver.is_a?(Prism::CallNode)
|
|
4107
|
-
acc = floor_callee_escaped_args_for_call(receiver, acc)
|
|
4108
|
-
receiver = receiver.receiver
|
|
6448
|
+
# The `[def, offset, owner]` a singleton-side self-call resolves to along the singleton chain: owner nil for a
|
|
6449
|
+
# def of the class itself, {FLOOR_ALL} for one it inherits ({#walked_owner}).
|
|
6450
|
+
def inherited_singleton_callee(node, self_type)
|
|
6451
|
+
class_name = self_type.class_name
|
|
6452
|
+
ancestor_side_callee(node, class_name) do |name|
|
|
6453
|
+
scope.singleton_def_through_ancestors(class_name, name)
|
|
4109
6454
|
end
|
|
4110
|
-
acc
|
|
4111
6455
|
end
|
|
4112
6456
|
|
|
4113
|
-
#
|
|
4114
|
-
#
|
|
4115
|
-
def
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
floored = content_floor_for(acc.local(argument.name))
|
|
4123
|
-
floored.nil? ? acc : acc.with_mutated_local(argument.name, floored)
|
|
6457
|
+
# The `[def, offset, owner]` `node` resolves to through a lookup on `class_name` that answers `[def, defining]`
|
|
6458
|
+
# (the block), owner nil, or {FLOOR_ALL} ({#walked_owner}); nil when nothing answers.
|
|
6459
|
+
def ancestor_side_callee(node, class_name)
|
|
6460
|
+
defining = nil
|
|
6461
|
+
found = side_callee(node) do |name|
|
|
6462
|
+
def_node, owner = yield(name)
|
|
6463
|
+
defining = owner unless def_node.nil?
|
|
6464
|
+
def_node
|
|
4124
6465
|
end
|
|
6466
|
+
found && [*found, walked_owner(defining, class_name, nil)]
|
|
4125
6467
|
end
|
|
4126
6468
|
|
|
4127
|
-
#
|
|
4128
|
-
#
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
6469
|
+
# {#method_missing_callees} for a self-call `node` that resolves to nothing; a `send(:name, ...)` hand-off
|
|
6470
|
+
# passes the name as `method_missing`'s own first argument.
|
|
6471
|
+
def self_method_missing_callees(node, class_name, side)
|
|
6472
|
+
if send_hand_off?(node)
|
|
6473
|
+
method_missing_callees(class_name, side, node.arguments.arguments.first.unescaped.to_sym, 1)
|
|
6474
|
+
else
|
|
6475
|
+
method_missing_callees(class_name, side, node.name, 0)
|
|
6476
|
+
end
|
|
4134
6477
|
end
|
|
4135
6478
|
|
|
4136
|
-
# The
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
6479
|
+
# The `[def, offset]` `node` resolves to on one side, whose def table the block looks a name up in, or nil.
|
|
6480
|
+
def side_callee(node)
|
|
6481
|
+
if send_hand_off?(node)
|
|
6482
|
+
own = yield(node.name)
|
|
6483
|
+
return [own, 0] if own
|
|
6484
|
+
|
|
6485
|
+
target = yield(node.arguments.arguments.first.unescaped.to_sym)
|
|
6486
|
+
return target && [target, 1]
|
|
6487
|
+
end
|
|
6488
|
+
|
|
6489
|
+
found = yield(node.name)
|
|
6490
|
+
found && [found, 0]
|
|
6491
|
+
end
|
|
4142
6492
|
|
|
4143
|
-
|
|
6493
|
+
# True for a `send` / `public_send` / `__send__` whose first argument is a literal target name: a symbol, or
|
|
6494
|
+
# (issue #2336) a string (`send("poke", h)`).
|
|
6495
|
+
def send_hand_off?(node)
|
|
6496
|
+
SEND_HAND_OFFS.include?(node.name) && CalleeWriteCensus.literal_send?(node)
|
|
4144
6497
|
end
|
|
4145
6498
|
|
|
6499
|
+
SEND_HAND_OFFS = %i[send public_send __send__].freeze
|
|
6500
|
+
NO_CALLEES = [].freeze
|
|
6501
|
+
private_constant :SEND_HAND_OFFS, :NO_CALLEES
|
|
6502
|
+
|
|
4146
6503
|
def self_dispatch_call?(node)
|
|
4147
6504
|
return false unless node.is_a?(Prism::CallNode)
|
|
4148
6505
|
|
|
4149
6506
|
node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
4150
6507
|
end
|
|
4151
6508
|
|
|
4152
|
-
# The set of `[name,
|
|
4153
|
-
# Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape-
|
|
6509
|
+
# The set of `[name, slot]` parameters of `def_node` whose content a block in the body escape-mutates.
|
|
6510
|
+
# Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape-mutated"
|
|
4154
6511
|
# when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
|
|
4155
6512
|
# non-escaping.
|
|
4156
6513
|
def escaped_content_parameters(def_node)
|
|
@@ -4159,7 +6516,7 @@ module Rigor
|
|
|
4159
6516
|
end
|
|
4160
6517
|
|
|
4161
6518
|
def compute_escaped_content_parameters(def_node)
|
|
4162
|
-
positions =
|
|
6519
|
+
positions = content_parameter_slots(def_node)
|
|
4163
6520
|
return {} if positions.empty?
|
|
4164
6521
|
|
|
4165
6522
|
mutated = Set.new
|
|
@@ -4167,35 +6524,140 @@ module Rigor
|
|
|
4167
6524
|
next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
|
|
4168
6525
|
next if syntactically_non_escaping_call?(descendant)
|
|
4169
6526
|
|
|
4170
|
-
collect_content_mutations(descendant.block.body).
|
|
4171
|
-
|
|
6527
|
+
collect_content_mutations(descendant.block.body).each do |name, nodes|
|
|
6528
|
+
next unless positions.key?(name)
|
|
6529
|
+
|
|
6530
|
+
mutated << name if nodes.any? { |node| !after_rebind?(def_node, name, node) }
|
|
4172
6531
|
end
|
|
4173
6532
|
end
|
|
4174
6533
|
positions.slice(*mutated)
|
|
4175
6534
|
end
|
|
4176
6535
|
|
|
4177
|
-
# The `{ name =>
|
|
4178
|
-
#
|
|
4179
|
-
#
|
|
6536
|
+
# The `{ name => slot }` parameters whose CONTENT the callee mutates directly in its method body — a top-level
|
|
6537
|
+
# `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case is
|
|
6538
|
+
# `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
|
|
4180
6539
|
# parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
|
|
4181
6540
|
# merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
|
|
6541
|
+
#
|
|
6542
|
+
# A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted here: the bare-collection
|
|
6543
|
+
# floor this census applies dropped the element type (af3efef33, and again under #2104). Issue #2296 counts it in
|
|
6544
|
+
# {#own_block_content_parameters}, whose floor keeps the element type.
|
|
4182
6545
|
def direct_content_parameters(def_node)
|
|
4183
6546
|
cache = (@direct_param_cache ||= {}.compare_by_identity)
|
|
4184
6547
|
cache[def_node] ||= compute_direct_content_parameters(def_node)
|
|
4185
6548
|
end
|
|
4186
6549
|
|
|
4187
6550
|
def compute_direct_content_parameters(def_node)
|
|
4188
|
-
positions =
|
|
6551
|
+
positions = content_parameter_slots(def_node)
|
|
4189
6552
|
return {} if positions.empty?
|
|
4190
6553
|
|
|
4191
6554
|
mutated = Set.new
|
|
4192
6555
|
each_node_outside_nested_scopes(def_node.body) do |descendant|
|
|
4193
6556
|
name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
|
|
4194
|
-
mutated << name if !name.nil? && positions.key?(name)
|
|
6557
|
+
mutated << name if !name.nil? && positions.key?(name) && !after_rebind?(def_node, name, descendant)
|
|
4195
6558
|
end
|
|
4196
6559
|
positions.slice(*mutated)
|
|
4197
6560
|
end
|
|
4198
6561
|
|
|
6562
|
+
# Issue #2296 — true when `node` runs after the def has rebound its parameter `name` to a fresh object
|
|
6563
|
+
# (`acc = acc.dup` as a statement of the method body, ahead of `node`): from there `name` holds another object,
|
|
6564
|
+
# so a mutation of it does not reach the caller's argument. Only an unconditional rebind counts, a write that is
|
|
6565
|
+
# itself a statement of the body, not one under a branch, a loop, a block or `||=`. Its value MUST provably be a
|
|
6566
|
+
# new object ({#fresh_value?}): `acc = acc.to_a` or `s = s.to_s` hands back the same object, so a mutation after
|
|
6567
|
+
# it still reaches the caller. A def whose body has a `rescue` or `ensure` clause never rebinds, since the clause
|
|
6568
|
+
# runs with whichever binding the raise left. And only a node that starts after the write ends counts, so
|
|
6569
|
+
# `acc = poke(acc)` still passes the original.
|
|
6570
|
+
def after_rebind?(def_node, name, node)
|
|
6571
|
+
offset = parameter_rebinds(def_node)[name]
|
|
6572
|
+
!offset.nil? && node.location.start_offset >= offset
|
|
6573
|
+
end
|
|
6574
|
+
|
|
6575
|
+
# `{ name => end_offset }` of the first unconditional fresh rebind of each parameter ({#after_rebind?}),
|
|
6576
|
+
# memoised per def node for the thread.
|
|
6577
|
+
def parameter_rebinds(def_node)
|
|
6578
|
+
def_census(:rebinds, def_node) do
|
|
6579
|
+
slots = content_parameter_slots(def_node)
|
|
6580
|
+
rebinds = nil
|
|
6581
|
+
body_statements(def_node.body).each do |statement|
|
|
6582
|
+
next unless fresh_rebind?(statement, slots)
|
|
6583
|
+
|
|
6584
|
+
(rebinds ||= {})[statement.name] ||= statement.location.end_offset
|
|
6585
|
+
end
|
|
6586
|
+
rebinds || NO_PARAMETER_SLOTS
|
|
6587
|
+
end
|
|
6588
|
+
end
|
|
6589
|
+
|
|
6590
|
+
def fresh_rebind?(statement, slots)
|
|
6591
|
+
case statement
|
|
6592
|
+
when Prism::LocalVariableWriteNode
|
|
6593
|
+
statement.depth.zero? && slots.key?(statement.name) && fresh_value?(statement.value, statement.name)
|
|
6594
|
+
when Prism::LocalVariableOperatorWriteNode
|
|
6595
|
+
statement.depth.zero? && slots.key?(statement.name) &&
|
|
6596
|
+
FRESH_OPERATORS.include?(statement.binary_operator)
|
|
6597
|
+
else false
|
|
6598
|
+
end
|
|
6599
|
+
end
|
|
6600
|
+
|
|
6601
|
+
# A value that is always a new object: a `dup` / `clone`, an Array / Hash / String literal, `new` on a core
|
|
6602
|
+
# collection class ({#core_collection_constant?}), or one of {FRESH_COPIERS} called on the parameter itself. Any
|
|
6603
|
+
# other call may return its receiver.
|
|
6604
|
+
def fresh_value?(value, name)
|
|
6605
|
+
case value
|
|
6606
|
+
when Prism::ArrayNode, Prism::HashNode, Prism::StringNode, Prism::InterpolatedStringNode then true
|
|
6607
|
+
when Prism::CallNode then fresh_call?(value, name)
|
|
6608
|
+
else false
|
|
6609
|
+
end
|
|
6610
|
+
end
|
|
6611
|
+
|
|
6612
|
+
def fresh_call?(call, name)
|
|
6613
|
+
return true if FRESH_DUPLICATORS.include?(call.name)
|
|
6614
|
+
return core_collection_constant?(call.receiver) if call.name == :new
|
|
6615
|
+
|
|
6616
|
+
receiver = call.receiver
|
|
6617
|
+
FRESH_COPIERS.include?(call.name) && receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
|
|
6618
|
+
end
|
|
6619
|
+
|
|
6620
|
+
# `::Array` / `::Hash` / `::String` / `::Set`, written `::`-rooted: the core classes whose `new` always builds a
|
|
6621
|
+
# new object. A project class's `new` may hand back its argument (`Ident.new(acc)`) or wrap it (`Wrap.new(acc)`,
|
|
6622
|
+
# whose `[]=` writes through), so it is no fresh rebind. The census reads the AST alone and cannot resolve a bare
|
|
6623
|
+
# `Array`, which a namespace may shadow (`M::Array`), so only the rooted spelling counts.
|
|
6624
|
+
def core_collection_constant?(node)
|
|
6625
|
+
node.is_a?(Prism::ConstantPathNode) && node.parent.nil? && FRESH_CONSTRUCTED.include?(node.name)
|
|
6626
|
+
end
|
|
6627
|
+
|
|
6628
|
+
FRESH_DUPLICATORS = %i[dup clone].to_set.freeze
|
|
6629
|
+
FRESH_CONSTRUCTED = %i[Array Hash String Set].to_set.freeze
|
|
6630
|
+
FRESH_COPIERS = %i[map collect select reject merge + -].to_set.freeze
|
|
6631
|
+
FRESH_OPERATORS = %i[+ -].to_set.freeze
|
|
6632
|
+
# The calls whose result {#callee_write_census} reads as a new object rather than an alias of the receiver.
|
|
6633
|
+
# The calls {#callee_write_census} reads as a new container of the receiver's elements, on an Array, a Hash and a
|
|
6634
|
+
# String alike: a write to the result misses the receiver, an element of it may be a part. `to_a`, `to_h`
|
|
6635
|
+
# (without a block; `to_h { }` is read as one in {CalleeWriteCensus}), `to_s`, `each_slice`, `each_cons` and
|
|
6636
|
+
# `each_with_index` stay out: each returns its receiver on one of them.
|
|
6637
|
+
CENSUS_COPIERS = %i[
|
|
6638
|
+
slice except compact transform_values transform_keys filter_map group_by partition sort sort_by uniq flatten
|
|
6639
|
+
reverse keys values inspect zip filter flat_map collect_concat take drop
|
|
6640
|
+
].to_set.freeze
|
|
6641
|
+
# The calls whose result {#callee_write_census} reads as a new object holding nothing of the receiver's.
|
|
6642
|
+
FRESH_RESULTS = (FRESH_DUPLICATORS | FRESH_COPIERS | %i[join]).freeze
|
|
6643
|
+
private_constant :FRESH_DUPLICATORS, :FRESH_CONSTRUCTED, :FRESH_COPIERS, :FRESH_OPERATORS, :CENSUS_COPIERS,
|
|
6644
|
+
:FRESH_RESULTS
|
|
6645
|
+
|
|
6646
|
+
# The statements a def body runs in order: a statements list, with a parenthesised list (an endless def's
|
|
6647
|
+
# `= (a; b)`) read in place, or the lone expression. A body with a `rescue` / `ensure` clause (a `BeginNode`)
|
|
6648
|
+
# answers none, so it never rebinds.
|
|
6649
|
+
def body_statements(body)
|
|
6650
|
+
case body
|
|
6651
|
+
when nil, Prism::BeginNode then NO_FORWARDS
|
|
6652
|
+
when Prism::StatementsNode then body.body.flat_map { |statement| body_statements_of(statement) }
|
|
6653
|
+
else [body]
|
|
6654
|
+
end
|
|
6655
|
+
end
|
|
6656
|
+
|
|
6657
|
+
def body_statements_of(statement)
|
|
6658
|
+
statement.is_a?(Prism::ParenthesesNode) ? body_statements(statement.body) : [statement]
|
|
6659
|
+
end
|
|
6660
|
+
|
|
4199
6661
|
# Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
|
|
4200
6662
|
# that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
|
|
4201
6663
|
# a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
|
|
@@ -4210,8 +6672,45 @@ module Rigor
|
|
|
4210
6672
|
end
|
|
4211
6673
|
end
|
|
4212
6674
|
|
|
6675
|
+
# Yields every node reachable from `node` with the number of blocks it sits in: a read of the method's own local
|
|
6676
|
+
# there has that depth. A nested def or lambda is not entered: a def has no access to the method's locals, and a
|
|
6677
|
+
# lambda body is not run by the call. Nor is the block of a {#self_rebinding_block_call?}: a self-call there
|
|
6678
|
+
# either runs on another `self`, or runs later as a method body, so the walk leaves it out.
|
|
6679
|
+
def each_node_in_method_scope(node, depth, &)
|
|
6680
|
+
return if node.nil?
|
|
6681
|
+
|
|
6682
|
+
yield node, depth
|
|
6683
|
+
rebinding = self_rebinding_block_call?(node)
|
|
6684
|
+
node.rigor_each_child do |child|
|
|
6685
|
+
next if child.is_a?(Prism::DefNode) || child.is_a?(Prism::LambdaNode)
|
|
6686
|
+
|
|
6687
|
+
if child.is_a?(Prism::BlockNode)
|
|
6688
|
+
each_node_in_method_scope(child, depth + 1, &) unless rebinding
|
|
6689
|
+
else
|
|
6690
|
+
each_node_in_method_scope(child, depth, &)
|
|
6691
|
+
end
|
|
6692
|
+
end
|
|
6693
|
+
end
|
|
6694
|
+
|
|
6695
|
+
# The calls whose block the hand-off walk leaves out. In an `instance_eval` / `class_exec` / … block, and in a
|
|
6696
|
+
# `Class.new` / `Module.new` / `Struct.new` / `Data.define` block ({AnonymousMetaClass.block_form_receiver}),
|
|
6697
|
+
# `self` is another object, so a self-call there is not a call on the method's receiver. A `define_method` or
|
|
6698
|
+
# `define_singleton_method` block keeps an instance of the class as `self`, but it is a method body that runs
|
|
6699
|
+
# later, if ever, so leaving it out is the conservative reading: a hand-off it holds is not one the call makes.
|
|
6700
|
+
SELF_REBINDING_BLOCK_CALLS = Set[
|
|
6701
|
+
:instance_eval, :instance_exec, :class_eval, :class_exec, :module_eval, :module_exec, :define_method,
|
|
6702
|
+
:define_singleton_method
|
|
6703
|
+
].freeze
|
|
6704
|
+
private_constant :SELF_REBINDING_BLOCK_CALLS
|
|
6705
|
+
|
|
6706
|
+
def self_rebinding_block_call?(node)
|
|
6707
|
+
return false unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
|
|
6708
|
+
|
|
6709
|
+
SELF_REBINDING_BLOCK_CALLS.include?(node.name) || !AnonymousMetaClass.block_form_receiver(node).nil?
|
|
6710
|
+
end
|
|
6711
|
+
|
|
4213
6712
|
# A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
|
|
4214
|
-
# a callee body where the block-
|
|
6713
|
+
# a callee body where the block-owning call's receiver TYPE is not available. A call whose method name is a known
|
|
4215
6714
|
# structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
|
|
4216
6715
|
# captured mutations are not a cross-boundary escape. Any other name (`on`, `subscribe`, `define_method`, an
|
|
4217
6716
|
# unknown DSL hook) is treated as escaping — sound, since mis-classifying a truly-non-escaping call only floors an
|
|
@@ -4230,18 +6729,20 @@ module Rigor
|
|
|
4230
6729
|
SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
|
|
4231
6730
|
end
|
|
4232
6731
|
|
|
4233
|
-
# `{ name =>
|
|
4234
|
-
#
|
|
4235
|
-
|
|
6732
|
+
# `{ name => slot }` for the parameters a caller's argument binds by name or position: the required / optional
|
|
6733
|
+
# positional ones (slot: their index) and, issue #2104, the keyword ones (slot: the keyword symbol). Rest, post
|
|
6734
|
+
# and block parameters are skipped.
|
|
6735
|
+
def content_parameter_slots(def_node)
|
|
4236
6736
|
params = def_node.parameters
|
|
4237
6737
|
return {} if params.nil?
|
|
4238
6738
|
|
|
4239
6739
|
ordered = (params.requireds || []) + (params.optionals || [])
|
|
4240
|
-
|
|
6740
|
+
slots = {}
|
|
4241
6741
|
ordered.each_with_index do |param, index|
|
|
4242
|
-
|
|
6742
|
+
slots[param.name] = index if param.respond_to?(:name)
|
|
4243
6743
|
end
|
|
4244
|
-
|
|
6744
|
+
(params.keywords || []).each { |param| slots[param.name] = param.name }
|
|
6745
|
+
slots
|
|
4245
6746
|
end
|
|
4246
6747
|
|
|
4247
6748
|
# True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
|
|
@@ -4255,11 +6756,14 @@ module Rigor
|
|
|
4255
6756
|
case arg
|
|
4256
6757
|
when Prism::LocalVariableReadNode
|
|
4257
6758
|
scope.locals.key?(arg.name)
|
|
6759
|
+
when Prism::SplatNode
|
|
6760
|
+
arg.expression.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(arg.expression.name)
|
|
4258
6761
|
when Prism::KeywordHashNode
|
|
4259
6762
|
arg.elements.any? do |pair|
|
|
4260
|
-
pair.is_a?(Prism::AssocNode)
|
|
4261
|
-
|
|
4262
|
-
|
|
6763
|
+
next false unless pair.is_a?(Prism::AssocNode)
|
|
6764
|
+
|
|
6765
|
+
value = pair.value.is_a?(Prism::ImplicitNode) ? pair.value.value : pair.value
|
|
6766
|
+
value.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(value.name)
|
|
4263
6767
|
end
|
|
4264
6768
|
else
|
|
4265
6769
|
false
|
|
@@ -4349,6 +6853,18 @@ module Rigor
|
|
|
4349
6853
|
Type::Combinator.untyped])
|
|
4350
6854
|
elsif arrayish?(type)
|
|
4351
6855
|
Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
|
|
6856
|
+
elsif setish?(type)
|
|
6857
|
+
Type::Combinator.nominal_of("Set", type_args: [Type::Combinator.untyped])
|
|
6858
|
+
end
|
|
6859
|
+
end
|
|
6860
|
+
|
|
6861
|
+
# A `Set` carrier, constant-folded or nominal, for the callee floor above (issue #2294).
|
|
6862
|
+
def setish?(type)
|
|
6863
|
+
case type
|
|
6864
|
+
when Type::Constant then SetMutation.constant?(type)
|
|
6865
|
+
when Type::Nominal then type.class_name == "Set"
|
|
6866
|
+
when Type::Union then type.members.any? { |m| setish?(m) }
|
|
6867
|
+
else false
|
|
4352
6868
|
end
|
|
4353
6869
|
end
|
|
4354
6870
|
|
|
@@ -4417,6 +6933,11 @@ module Rigor
|
|
|
4417
6933
|
# The fresh `next`, `break`-value and `break`-scope sinks are defensive: every consumer of those sinks filters by
|
|
4418
6934
|
# jump-node identity, so no example tells them apart from the outer sinks today. So is the `rescue`: a raise here
|
|
4419
6935
|
# keeps the arms the earlier passes collected rather than taking out the method.
|
|
6936
|
+
#
|
|
6937
|
+
# Issue #2127 — a block passed to a project method whose body may run it more than once
|
|
6938
|
+
# ({ProjectYielder.may_run_block_repeatedly?}) gets the same pass. No write-back runs for such a call, so its
|
|
6939
|
+
# continuation holds each rebound capture at `Dynamic[top]` ({#drop_captured_narrowing}), and the pass reads it
|
|
6940
|
+
# so: `seen = 0; yielder { return :ret if (seen += 1) > 1 }` reaches the `return`.
|
|
4420
6941
|
def collect_repeated_block_returns(call_node, post_scope)
|
|
4421
6942
|
return if Thread.current[RETURN_SINK_KEY].nil?
|
|
4422
6943
|
|
|
@@ -4440,22 +6961,25 @@ module Rigor
|
|
|
4440
6961
|
end
|
|
4441
6962
|
|
|
4442
6963
|
# Whether `block` is an ordinary block (not a return barrier or a class-body block) whose body may `return` from
|
|
4443
|
-
# the method and which `call_node` may run more than once: {#repeating_block_class}'s rule,
|
|
4444
|
-
#
|
|
6964
|
+
# the method and which `call_node` may run more than once: {#repeating_block_class}'s rule, `Kernel#loop`, which
|
|
6965
|
+
# that rule leaves out and which runs its block until it breaks or raises, or a project method whose body may
|
|
6966
|
+
# run it more than once ({ProjectYielder.may_run_block_repeatedly?}, issue #2127). The barrier, refinement and
|
|
4445
6967
|
# anonymous-class checks are defensive guards: no such call repeats under that rule today.
|
|
4446
6968
|
def repeated_block_return_candidate?(call_node, block)
|
|
4447
6969
|
return false unless block.is_a?(Prism::BlockNode)
|
|
4448
6970
|
return false unless ReturnBarrier.exits_method?(block.body)
|
|
4449
6971
|
return false if return_barrier_block?(call_node) || refined_class_context(call_node)
|
|
4450
6972
|
return false if AnonymousMetaClass.name_for(call_node, scope.source_path)
|
|
6973
|
+
return true if BlockCallTiming.kernel_loop?(call_node) || !repeating_block_class(call_node).nil?
|
|
4451
6974
|
|
|
4452
|
-
|
|
6975
|
+
receiver_type = block_call_receiver_type(call_node)
|
|
6976
|
+
!receiver_type.nil? && ProjectYielder.may_run_block_repeatedly?(call_node, receiver_type, scope)
|
|
4453
6977
|
end
|
|
4454
6978
|
|
|
4455
6979
|
# ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
|
|
4456
6980
|
# rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
|
|
4457
6981
|
# the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
|
|
4458
|
-
# — `[].each { … }` — stays sound), value-pinned
|
|
6982
|
+
# — `[].each { … }` — stays sound), value-pinned widened on the final permitted iteration, and floored to
|
|
4459
6983
|
# `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
|
|
4460
6984
|
#
|
|
4461
6985
|
# The instance variables the body rebinds (`CapturedLocals.writes` with `ivars: true`) outlive the call the same
|
|
@@ -5075,7 +7599,7 @@ module Rigor
|
|
|
5075
7599
|
end
|
|
5076
7600
|
|
|
5077
7601
|
# ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
|
|
5078
|
-
# block has mutated it through the `acc` alias. Compute the joined memo type the same way captured-
|
|
7602
|
+
# block has mutated it through the `acc` alias. Compute the joined memo type the same way captured-local content
|
|
5079
7603
|
# mutations are joined: pre-state = the memo argument's type, added evidence = the content-mutator args on the
|
|
5080
7604
|
# memo block param. Returns `call_type` unchanged for any other call, a missing block, or a memo whose pre-state
|
|
5081
7605
|
# is not a collection.
|
|
@@ -5094,28 +7618,239 @@ module Rigor
|
|
|
5094
7618
|
body = block.body
|
|
5095
7619
|
return call_type if body.nil?
|
|
5096
7620
|
|
|
7621
|
+
memo_after_block(call_node, call_type, memo_param, body)
|
|
7622
|
+
end
|
|
7623
|
+
|
|
7624
|
+
# {#each_with_object_return}'s answer once the call is known to bind a memo parameter in a block with a body.
|
|
7625
|
+
def memo_after_block(call_node, call_type, memo_param, body)
|
|
7626
|
+
memo_arg = call_node.arguments.arguments.first
|
|
7627
|
+
memo_type = scope.type_of(memo_arg, tracer: tracer)
|
|
7628
|
+
pre_state = lookup_mutated_seed(body, memo_param, memo_type, MEMO_SEED_MUTATORS) do |depth, nesting|
|
|
7629
|
+
depth == nesting
|
|
7630
|
+
end
|
|
7631
|
+
reach = memo_scan_reach(body, memo_param)
|
|
7632
|
+
return content_floor_for(pre_state) || call_type if reach == :escape
|
|
7633
|
+
|
|
5097
7634
|
# The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
|
|
5098
7635
|
# captured-local walk.
|
|
5099
7636
|
shadows = {}.compare_by_identity
|
|
5100
7637
|
calls = body_content_mutations_on(body, memo_param, shadows)
|
|
5101
|
-
|
|
7638
|
+
memo = if calls.empty?
|
|
7639
|
+
# Issue #2411 — a memo the block only reshapes (`m.merge!(s)`, `m.delete(k)`) answers its widened seed.
|
|
7640
|
+
pre_state.equal?(memo_type) ? call_type : pre_state
|
|
7641
|
+
else
|
|
7642
|
+
join_memo_content(call_node, memo_param, calls, pre_state, shadows) || call_type
|
|
7643
|
+
end
|
|
7644
|
+
memo = floor_memo_callee_escapes(body, memo_param, memo)
|
|
7645
|
+
reach == :values ? memo_value_floor(memo) : memo
|
|
7646
|
+
end
|
|
7647
|
+
|
|
7648
|
+
# Issue #2411 — `memo` with its values or elements floored to `Dynamic[top]` and a Hash's keys kept: a reader's
|
|
7649
|
+
# value is an element the block may then mutate where no scan sees it (`h[x] << "s"`, `v = h[:k]; v << "s"`,
|
|
7650
|
+
# `h[:k][:n] = "s"`), so the join's element types are not what the memo holds. A memo whose every value or
|
|
7651
|
+
# element is of a class no method mutates ({MEMO_IMMUTABLE_CLASSES}) keeps its join: `m[k] = m[k] + 1` reads an
|
|
7652
|
+
# Integer, which nothing in the block can change.
|
|
7653
|
+
def memo_value_floor(type)
|
|
7654
|
+
return type if memo_contents_immutable?(type)
|
|
5102
7655
|
|
|
5103
|
-
|
|
5104
|
-
|
|
7656
|
+
case type
|
|
7657
|
+
when Type::Union then Type::Combinator.union(*type.members.map { |member| memo_value_floor(member) })
|
|
7658
|
+
when Type::Nominal
|
|
7659
|
+
return content_floor_for(type) || type unless type.class_name == "Hash" && type.type_args.size == 2
|
|
7660
|
+
|
|
7661
|
+
Type::Combinator.nominal_of("Hash", type_args: [type.type_args.first, Type::Combinator.untyped])
|
|
7662
|
+
else content_floor_for(type) || type
|
|
5105
7663
|
end
|
|
5106
|
-
joined = join_memo_content(call_node, memo_param, calls, pre_state, shadows)
|
|
5107
|
-
joined || call_type
|
|
5108
7664
|
end
|
|
5109
7665
|
|
|
7666
|
+
# The classes whose instances no method mutates.
|
|
7667
|
+
MEMO_IMMUTABLE_CLASSES = Set[
|
|
7668
|
+
"Integer", "Float", "Symbol", "NilClass", "TrueClass", "FalseClass", "Rational", "Complex"
|
|
7669
|
+
].freeze
|
|
7670
|
+
private_constant :MEMO_IMMUTABLE_CLASSES
|
|
7671
|
+
|
|
7672
|
+
# Whether every value of a Hash memo, or every element of an Array memo, is of a {MEMO_IMMUTABLE_CLASSES} class.
|
|
7673
|
+
def memo_contents_immutable?(type)
|
|
7674
|
+
case type
|
|
7675
|
+
when Type::Union then type.members.all? { |member| memo_contents_immutable?(member) }
|
|
7676
|
+
when Type::Nominal
|
|
7677
|
+
slot = type.class_name == "Hash" ? 1 : 0
|
|
7678
|
+
%w[Hash Array].include?(type.class_name) && !type.type_args[slot].nil? &&
|
|
7679
|
+
memo_immutable_value?(type.type_args[slot])
|
|
7680
|
+
when Type::Tuple then type.elements.all? { |element| memo_immutable_value?(element) }
|
|
7681
|
+
when Type::HashShape then type.pairs.values.all? { |value| memo_immutable_value?(value) }
|
|
7682
|
+
else false
|
|
7683
|
+
end
|
|
7684
|
+
end
|
|
7685
|
+
|
|
7686
|
+
def memo_immutable_value?(type)
|
|
7687
|
+
case type
|
|
7688
|
+
when Type::Union then type.members.all? { |member| memo_immutable_value?(member) }
|
|
7689
|
+
when Type::Constant then MEMO_IMMUTABLE_CLASSES.include?(type.value.class.name)
|
|
7690
|
+
when Type::Nominal then MEMO_IMMUTABLE_CLASSES.include?(type.class_name)
|
|
7691
|
+
when Type::IntegerRange, Type::FloatRange then true
|
|
7692
|
+
else false
|
|
7693
|
+
end
|
|
7694
|
+
end
|
|
7695
|
+
|
|
7696
|
+
# Issue #2411 — whether the block can change the memo where neither the join nor the callee floor sees it, so
|
|
7697
|
+
# the memo answers its content floor ({#content_floor_for}) instead of the join. Read as an expression, the
|
|
7698
|
+
# join's answer reaches every use of the call, and one that misses a store reports on correct code:
|
|
7699
|
+
# `|x, h| g = h; g["s"] = "v"; h[:a] = 2` joined `Hash[:a, 2]`, and `.fetch("s").upcase` read `2`. Every read
|
|
7700
|
+
# or write of the memo's name in its own scope ({#scope_nesting}) must be one the scan models
|
|
7701
|
+
# ({#memo_read_modelled?}); a rebinding write (`h = {}`) is not, since the stores after it reach another object.
|
|
7702
|
+
# Answers `:escape` for such a read or write, `:values` when the only unmodelled reads are readers whose value
|
|
7703
|
+
# is used ({#memo_value_floor}), and nil when every read is modelled.
|
|
7704
|
+
def memo_scan_reach(body, memo_param)
|
|
7705
|
+
reach = nil
|
|
7706
|
+
Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
|
|
7707
|
+
next unless MEMO_NAME_NODES.any? { |klass| node.is_a?(klass) } && node.name == memo_param
|
|
7708
|
+
next unless node.depth == scope_nesting(ancestors)
|
|
7709
|
+
|
|
7710
|
+
modelled = node.is_a?(Prism::LocalVariableReadNode) && memo_read_modelled?(node, ancestors)
|
|
7711
|
+
return :escape unless modelled
|
|
7712
|
+
|
|
7713
|
+
reach = :values if modelled == :values
|
|
7714
|
+
end
|
|
7715
|
+
reach
|
|
7716
|
+
end
|
|
7717
|
+
|
|
7718
|
+
# The nodes that read or bind a local by name. `LocalVariableTargetNode` covers a multi-assign and a `rescue =>`.
|
|
7719
|
+
MEMO_NAME_NODES = [
|
|
7720
|
+
Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode, Prism::LocalVariableTargetNode,
|
|
7721
|
+
Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode
|
|
7722
|
+
].freeze
|
|
7723
|
+
private_constant :MEMO_NAME_NODES
|
|
7724
|
+
|
|
7725
|
+
# The memo reads that cannot hand the object to anything the scan does not see. A call on the memo is one when
|
|
7726
|
+
# it takes no block (`tap`, `each` and `then` yield it) and the scan knows the method: one whose value never is
|
|
7727
|
+
# the memo ({MEMO_VALUE_READERS}), or a mutator the join or the seed widening reads ({CONTENT_MUTATORS},
|
|
7728
|
+
# {MEMO_SEED_MUTATORS}) whose value, the memo for `h << x` and `h.merge!(s)`, is discarded
|
|
7729
|
+
# ({#memo_value_discarded?}). Any other name is not: `h.send(:store, :t, "t")` stores what no scan sees. An index
|
|
7730
|
+
# compound write on it is one. So is an argument of a self-call that resolves to a user def, whose census the
|
|
7731
|
+
# callee floor reads ({#floor_memo_callee_escapes}), when the call's value is discarded: `keep(h)["s"] = "v"`
|
|
7732
|
+
# stores through the value. Any other read — `g = h`, `[h]`, `Helper.fill(h, x)`, an unresolved self-call — is
|
|
7733
|
+
# not.
|
|
7734
|
+
def memo_read_modelled?(read, ancestors)
|
|
7735
|
+
parent = ancestors.last
|
|
7736
|
+
if parent.respond_to?(:receiver) && parent.receiver.equal?(read)
|
|
7737
|
+
return true if INDEX_WRITE_NODES.any? { |klass| parent.is_a?(klass) }
|
|
7738
|
+
return false unless parent.is_a?(Prism::CallNode) && parent.block.nil?
|
|
7739
|
+
|
|
7740
|
+
return memo_receiver_call_modelled?(parent, ancestors[0...-1])
|
|
7741
|
+
end
|
|
7742
|
+
memo_self_call_argument?(ancestors)
|
|
7743
|
+
end
|
|
7744
|
+
|
|
7745
|
+
# True, false, or `:values` for a reader whose value is used: the value may be an element the block mutates.
|
|
7746
|
+
# A mutator given a splat or `**` argument is not modelled: the join reads each argument by position, and
|
|
7747
|
+
# `h.store(*kv)` stored a pair it never saw.
|
|
7748
|
+
def memo_receiver_call_modelled?(call, ancestors)
|
|
7749
|
+
mutator = CONTENT_MUTATORS.include?(call.name) || MEMO_SEED_MUTATORS.include?(call.name)
|
|
7750
|
+
return false if mutator && memo_splat_arguments?(call)
|
|
7751
|
+
return memo_value_discarded?(call, ancestors) || :values if MEMO_VALUE_READERS.include?(call.name)
|
|
7752
|
+
return false unless mutator
|
|
7753
|
+
|
|
7754
|
+
memo_value_discarded?(call, ancestors)
|
|
7755
|
+
end
|
|
7756
|
+
|
|
7757
|
+
def memo_splat_arguments?(call)
|
|
7758
|
+
(call.arguments&.arguments || []).any? do |argument|
|
|
7759
|
+
argument.is_a?(Prism::SplatNode) ||
|
|
7760
|
+
(argument.is_a?(Prism::KeywordHashNode) && argument.elements.any?(Prism::AssocSplatNode))
|
|
7761
|
+
end
|
|
7762
|
+
end
|
|
7763
|
+
|
|
7764
|
+
# Methods of Hash and Array whose value is never the receiver itself, so using it hands nothing on.
|
|
7765
|
+
MEMO_VALUE_READERS = Set[
|
|
7766
|
+
:[], :[]=, :store, :fetch, :dig, :key?, :has_key?, :include?, :member?, :key, :value?, :has_value?,
|
|
7767
|
+
:size, :length, :count, :empty?, :any?, :none?, :keys, :values, :values_at, :fetch_values, :first, :last,
|
|
7768
|
+
:delete, :pop, :shift, :to_s, :inspect, :hash, :==, :!=, :eql?, :equal?, :frozen?, :nil?, :is_a?,
|
|
7769
|
+
:kind_of?, :instance_of?, :respond_to?, :class, :join, :sum, :min, :max, :dup, :clone
|
|
7770
|
+
].freeze
|
|
7771
|
+
private_constant :MEMO_VALUE_READERS
|
|
7772
|
+
|
|
7773
|
+
# Iterators whose block's value the call never reads.
|
|
7774
|
+
MEMO_BLOCK_VALUE_DROPPING = Set[
|
|
7775
|
+
:each, :each_with_index, :each_pair, :each_key, :each_value, :each_index, :reverse_each, :each_slice,
|
|
7776
|
+
:each_cons, :each_entry, :each_char, :each_line, :each_byte, :times, :upto, :downto, :step, :loop
|
|
7777
|
+
].freeze
|
|
7778
|
+
private_constant :MEMO_BLOCK_VALUE_DROPPING
|
|
7779
|
+
|
|
7780
|
+
# Whether `node`'s value is dropped: a statement that is not its sequence's last, or the last one of a sequence
|
|
7781
|
+
# whose own value is dropped, up through `if` / `unless` arms, `begin` / `rescue` bodies and parentheses, to the
|
|
7782
|
+
# `each_with_object` block body, whose value the call never answers, or to the body of a nested block whose
|
|
7783
|
+
# call never reads its value ({MEMO_BLOCK_VALUE_DROPPING}: `xs.each { |y| acc << y }`). Any other block's value
|
|
7784
|
+
# may be the memo handed on (`map { acc << y }`).
|
|
7785
|
+
def memo_value_discarded?(node, ancestors)
|
|
7786
|
+
return true if ancestors.empty?
|
|
7787
|
+
|
|
7788
|
+
parent = ancestors.last
|
|
7789
|
+
up = ancestors[0...-1]
|
|
7790
|
+
case parent
|
|
7791
|
+
when Prism::StatementsNode
|
|
7792
|
+
return true unless parent.body.last.equal?(node)
|
|
7793
|
+
|
|
7794
|
+
block = up.last
|
|
7795
|
+
return memo_value_discarded?(parent, up) unless block.is_a?(Prism::BlockNode)
|
|
7796
|
+
|
|
7797
|
+
up[-2].is_a?(Prism::CallNode) && MEMO_BLOCK_VALUE_DROPPING.include?(up[-2].name)
|
|
7798
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
7799
|
+
!parent.predicate.equal?(node) && memo_value_discarded?(parent, up)
|
|
7800
|
+
when Prism::ElseNode, Prism::BeginNode, Prism::RescueNode, Prism::ParenthesesNode
|
|
7801
|
+
memo_value_discarded?(parent, up)
|
|
7802
|
+
when Prism::EnsureNode then true
|
|
7803
|
+
else false
|
|
7804
|
+
end
|
|
7805
|
+
end
|
|
7806
|
+
|
|
7807
|
+
# Whether the read under `ancestors` is a positional or keyword argument of a self-call resolving to a user def.
|
|
7808
|
+
# Its value must be discarded: the callee may return the memo (`def keep(h) = h`) or wrap it.
|
|
7809
|
+
def memo_self_call_argument?(ancestors)
|
|
7810
|
+
depth = ancestors.size - 1
|
|
7811
|
+
depth -= 2 if ancestors[depth].is_a?(Prism::AssocNode) && ancestors[depth - 1].is_a?(Prism::KeywordHashNode)
|
|
7812
|
+
call = ancestors[depth - 1]
|
|
7813
|
+
return false unless ancestors[depth].is_a?(Prism::ArgumentsNode) && call.is_a?(Prism::CallNode)
|
|
7814
|
+
return false unless self_dispatch_call?(call) && memo_value_discarded?(call, ancestors[0...(depth - 1)])
|
|
7815
|
+
|
|
7816
|
+
!resolve_self_callees(call).empty?
|
|
7817
|
+
end
|
|
7818
|
+
|
|
7819
|
+
# Issue #2411 — `memo` floored as a captured local is when the block hands it to a self-call whose callee
|
|
7820
|
+
# mutates it ({#floor_callee_escaped_args_for_call}, the walk {#floor_block_body_callee_captures} makes): the
|
|
7821
|
+
# join reads only the stores in the block, so `|x, out| record(out, x); out[[x, :e]] = 1` joined
|
|
7822
|
+
# `Hash[[Integer, :e], 1]` while `record` stored other keys, and a memo the block only passed on kept its `{}`.
|
|
7823
|
+
# Every read of the memo's name counts, a nested block's parameter shadowing it included: the floor only forgets.
|
|
7824
|
+
# The walk runs on an evaluator whose scope binds the memo, since the floor reads only locals its scope binds.
|
|
7825
|
+
def floor_memo_callee_escapes(body, memo_param, memo)
|
|
7826
|
+
StatementEvaluator.new(scope: scope.with_local(memo_param, memo), tracer: tracer)
|
|
7827
|
+
.callee_floored_local(body, memo_param)
|
|
7828
|
+
end
|
|
7829
|
+
|
|
7830
|
+
protected
|
|
7831
|
+
|
|
7832
|
+
# `name` as the self-calls in `body` leave it, each flooring the arguments its callee mutates
|
|
7833
|
+
# ({#floor_memo_callee_escapes}).
|
|
7834
|
+
def callee_floored_local(body, name)
|
|
7835
|
+
floored = scope
|
|
7836
|
+
each_node_in_caller_block(body, 0) do |node, _depth|
|
|
7837
|
+
floored = floor_callee_escaped_args_for_call(node, floored)
|
|
7838
|
+
end
|
|
7839
|
+
floored.local(name)
|
|
7840
|
+
end
|
|
7841
|
+
|
|
7842
|
+
private
|
|
7843
|
+
|
|
5110
7844
|
# `seed` as the {HashLookupMutation} calls `body` makes on `name` leave it. They add no content, so the join
|
|
5111
7845
|
# never sees them as sites, and a seed read before `widen_after_block` is still the closed shape whose known
|
|
5112
7846
|
# values answer every missing key: `b = { a: 1 }; [1].each { b.default = 0; b[:c] = 2 }` read `b[:zz]` as
|
|
5113
7847
|
# `1 | 2`, and so did an `each_with_object({})` memo given a default beside its stores, and a `while` body. The
|
|
5114
7848
|
# block receives a read's `depth` and its enclosing block count, and says whether the read is the variable
|
|
5115
|
-
# `seed` describes. A `def` opens a scope of its own, so nothing under one is.
|
|
5116
|
-
|
|
7849
|
+
# `seed` describes. A `def` opens a scope of its own, so nothing under one is. `mutators` widens the call names
|
|
7850
|
+
# read; the `each_with_object` memo passes {MEMO_SEED_MUTATORS}.
|
|
7851
|
+
def lookup_mutated_seed(body, name, seed, mutators = HashLookupMutation::MUTATORS)
|
|
5117
7852
|
Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
|
|
5118
|
-
next unless node.is_a?(Prism::CallNode) &&
|
|
7853
|
+
next unless node.is_a?(Prism::CallNode) && mutators.include?(node.name)
|
|
5119
7854
|
next if ancestors.any?(Prism::DefNode)
|
|
5120
7855
|
|
|
5121
7856
|
receiver = node.receiver
|
|
@@ -5157,21 +7892,19 @@ module Rigor
|
|
|
5157
7892
|
second.respond_to?(:name) ? second.name : nil
|
|
5158
7893
|
end
|
|
5159
7894
|
|
|
5160
|
-
# Content
|
|
5161
|
-
#
|
|
5162
|
-
#
|
|
7895
|
+
# Content mutations of the block-local `var_name` within `body` — directly, or from a nested block that reaches
|
|
7896
|
+
# it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a different
|
|
7897
|
+
# variable and does not count. The sites are the ones {#captured_content_mutations} reads for a captured local
|
|
7898
|
+
# ({#content_mutation_target}), the index compound writes included: scanning calls alone, `h[x] ||= []` was no
|
|
7899
|
+
# store, and the memo kept its `{}`.
|
|
5163
7900
|
def body_content_mutations_on(body, var_name, shadows)
|
|
5164
7901
|
calls = []
|
|
5165
7902
|
Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
|
|
5166
|
-
|
|
5167
|
-
next unless
|
|
5168
|
-
|
|
5169
|
-
receiver = descendant.receiver
|
|
5170
|
-
next unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
5171
|
-
next unless receiver.name == var_name && receiver.depth == scope_nesting(ancestors)
|
|
7903
|
+
name, site = content_mutation_target(descendant) { |receiver| receiver.depth == scope_nesting(ancestors) }
|
|
7904
|
+
next unless name == var_name
|
|
5172
7905
|
|
|
5173
|
-
calls <<
|
|
5174
|
-
record_shadows(shadows,
|
|
7906
|
+
calls << site
|
|
7907
|
+
record_shadows(shadows, site, ancestors)
|
|
5175
7908
|
end
|
|
5176
7909
|
calls
|
|
5177
7910
|
end
|
|
@@ -5311,6 +8044,29 @@ module Rigor
|
|
|
5311
8044
|
MutationWidening::HASH_MUTATORS)).freeze
|
|
5312
8045
|
private_constant :CONTENT_MUTATORS
|
|
5313
8046
|
|
|
8047
|
+
# The memo's seed widens on every shape mutator its scan does not read evidence from, as a captured local's
|
|
8048
|
+
# binding does on the block seam ({MutationWidening.widen_after_block}): the {HashLookupMutation} calls, and the
|
|
8049
|
+
# rewriters and removers (`merge!`, `update`, `delete`, `replace`, …) whose added content no join can read. Read
|
|
8050
|
+
# off the memo argument alone, `xs.each_with_object({}) { |s, m| m.merge!(s) }` answered the empty `{}`.
|
|
8051
|
+
MEMO_SEED_MUTATORS = (MutationWidening::SHAPE_MUTATORS - CONTENT_MUTATORS).freeze
|
|
8052
|
+
private_constant :MEMO_SEED_MUTATORS
|
|
8053
|
+
|
|
8054
|
+
# Issue #2296 — the Set adders {CONTENT_MUTATORS} leaves out (`add`, `add?`, `merge`): a scan cannot see the
|
|
8055
|
+
# receiver's class, and `merge` on a Hash returns a new Hash, so the callee census keeps them apart and floors
|
|
8056
|
+
# only a Set argument on them.
|
|
8057
|
+
SET_ONLY_ADDERS = (SetMutation::CONTENT_ADDERS - CONTENT_MUTATORS).freeze
|
|
8058
|
+
private_constant :SET_ONLY_ADDERS
|
|
8059
|
+
|
|
8060
|
+
# Issue #2595 — every name the callee write census ({#callee_write_census}) counts as a write: the shape mutators
|
|
8061
|
+
# the widening reads ({MutationWidening::SHAPE_MUTATORS}) and the content scans' own names. A Set-only adder
|
|
8062
|
+
# stays out: `merge` on a Hash returns a new Hash.
|
|
8063
|
+
CALLEE_WRITE_MUTATORS = (MutationWidening::SHAPE_MUTATORS | CONTENT_MUTATORS).freeze
|
|
8064
|
+
# The tables {#callee_write_census} scans with.
|
|
8065
|
+
CENSUS_TABLES = CalleeWriteCensus::Tables.new(mutators: CALLEE_WRITE_MUTATORS, handled: CONTENT_MUTATORS,
|
|
8066
|
+
fresh: FRESH_RESULTS, copiers: CENSUS_COPIERS,
|
|
8067
|
+
foreign_calls: METHOD_BODY_BLOCK_CALLS)
|
|
8068
|
+
private_constant :CALLEE_WRITE_MUTATORS, :CENSUS_TABLES
|
|
8069
|
+
|
|
5314
8070
|
# The shared "not a content mutation" answer. This predicate runs on every node of every block, loop and
|
|
5315
8071
|
# method body it censuses (~950k calls on the lib self-check) and almost always declines, so a fresh
|
|
5316
8072
|
# `[nil, nil]` per decline was one of the largest allocation sites in the evaluator.
|
|
@@ -5523,12 +8279,7 @@ module Rigor
|
|
|
5523
8279
|
# receiver is the one the block-return pass asks about: the explicit receiver, or the implicit `self`
|
|
5524
8280
|
# (`Object` at the top level).
|
|
5525
8281
|
def repeating_block_class(call_node)
|
|
5526
|
-
receiver_type =
|
|
5527
|
-
if call_node.receiver
|
|
5528
|
-
explicit_receiver_type(call_node)
|
|
5529
|
-
else
|
|
5530
|
-
operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
|
|
5531
|
-
end
|
|
8282
|
+
receiver_type = block_call_receiver_type(call_node)
|
|
5532
8283
|
return nil if receiver_type.nil?
|
|
5533
8284
|
|
|
5534
8285
|
method_name = call_node.name
|
|
@@ -5542,6 +8293,13 @@ module Rigor
|
|
|
5542
8293
|
nil
|
|
5543
8294
|
end
|
|
5544
8295
|
|
|
8296
|
+
# The receiver a block-carrying call dispatches on: its explicit receiver's type, else `self`, else `Object`.
|
|
8297
|
+
def block_call_receiver_type(call_node)
|
|
8298
|
+
return explicit_receiver_type(call_node) if call_node.receiver
|
|
8299
|
+
|
|
8300
|
+
operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
|
|
8301
|
+
end
|
|
8302
|
+
|
|
5545
8303
|
# The binding each outer local or ivar the body rebinds ({CapturedLocals.writes}) from a sentinel seed — `nil` or
|
|
5546
8304
|
# `false`, alone or as a union of the two — enters a repeating call's body with when the write-back will not run
|
|
5547
8305
|
# its passes for it ({#write_back_block_captures} reaches only an explicit receiver classified
|
|
@@ -6129,7 +8887,7 @@ module Rigor
|
|
|
6129
8887
|
# inferred.
|
|
6130
8888
|
def eval_return(node)
|
|
6131
8889
|
sink = Thread.current[RETURN_SINK_KEY]
|
|
6132
|
-
sink
|
|
8890
|
+
sink&.record(node, jump_value_type(node))
|
|
6133
8891
|
[Type::Combinator.bot, scope]
|
|
6134
8892
|
end
|
|
6135
8893
|
|